feedback2: merge WD — 活性源石 stays on an enemy for 300 s after the tile (cleared by a 重生); 沼泽 triggers every second (2 layers at 重量 ≥ 3); 深水区 is sourceless true 持续伤害, not 环境伤害 (GitHub #33 item 6)
This commit is contained in:
commit
6b634fe132
10 files changed
+284
-44
No files matched your search
+1
-1
@@ -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
@@ -415,7 +415,7 @@ exactly (the builder warns if a multi-fragment template ever appears).
|
|||||||
| `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) |
|
| `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` |
|
| `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`) |
|
| `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 |
|
| `special` | `{"mire":{"intervalSec":1,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,"heavyWeight":3,…}}`, `{"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) |
|
| `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 (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) |
|
| `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 — kept unless the remake's (0.1.0's) road-over-floor preference route crosses strictly fewer non-blockable floor / gate / 深水区 tiles — the fewest among equal-length chains, smoothing that never cuts across floor its grid route does not walk; a corner touch does not count as a crossing — e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane, while 战场#04's keeps the official diagonal from row 9 into row 10; DESIGN §14 corrections, test/sim/pathing-official.test.js) 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 |
|
| `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 — kept unless the remake's (0.1.0's) road-over-floor preference route crosses strictly fewer non-blockable floor / gate / 深水区 tiles — the fewest among equal-length chains, smoothing that never cuts across floor its grid route does not walk; a corner touch does not count as a crossing — e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane, while 战场#04's keeps the official diagonal from row 9 into row 10; DESIGN §14 corrections, test/sim/pathing-official.test.js) 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 |
|
||||||
|
|||||||
+11
-2
@@ -1503,9 +1503,18 @@ TBD-22-intro
|
|||||||
- Tests: `test/content/bonds_core.test.js` (the chain in a normal and a 联防 battle: one knock-out and one revive each, all five standing, the next knock-out final; a marker revived by the 5-tier revive still gives its marks; a target 埃芒加德 revived in place takes none — one revive spent, not two; a target 不屈 redeployed takes none and stays standing), `test/content/kits_t5.test.js` (乌尔比安 S3 while he blocks: the anchor on his own tile, no move, no marker; a flyer over his tile, not blocked: the anchor ahead and the move, as in 0.1.1).
|
- Tests: `test/content/bonds_core.test.js` (the chain in a normal and a 联防 battle: one knock-out and one revive each, all five standing, the next knock-out final; a marker revived by the 5-tier revive still gives its marks; a target 埃芒加德 revived in place takes none — one revive spent, not two; a target 不屈 redeployed takes none and stays standing), `test/content/kits_t5.test.js` (乌尔比安 S3 while he blocks: the anchor on his own tile, no move, no marker; a flyer over his tile, not blocked: the anchor ahead and the move, as in 0.1.1).
|
||||||
|
|
||||||
|
|
||||||
### 22.4 活性源石 stays on an enemy after it leaves the tile (#33 item 6)
|
### 22.4 活性源石 stays on an enemy after it leaves the tile (#33 item 6) — `server/sim/content/devices.js` (`touchInfection`, `enterTerrain`, `terrainFor`, `tickMire`, `deepWaterDamage`), `content/enemies.js` (`rebirthCleanse`, `kitDeepsea`), `tools/build-data.mjs` (`special.mire`)
|
||||||
|
|
||||||
TBD-22.4
|
- **Seen** (public issue #33, other part 3: 「红地板图敌方单位经过后,红地板附带的效果会消失」): on 战场#04 an enemy lost the red floor's effect the moment it walked off it. Measured on v0.1.1 (the real stage): an enemy on (10,6) from 15.67 s to 19.67 s lost 4 × 70 HP; 12 s later it still had 99720 / 100000 HP and no ATK / ASPD bonus.
|
||||||
|
- **Official**: the red floor is 活性源石 `tile_infection`, the only one in the season's data — 战场#04 (`act1autochess_m04`) at (10,6), (11,6), (10,14), (11,14) and on the boss rows at (3,6), (4,6), (3,14), (4,14); level blackboard `damage` 70, `atk` 0.2, `attack_speed` 20, `duration` 300. PRTS 特殊地形 tile template (`特殊地形/trapper/tilesformat.json`): "部署于其上的我军和经过的敌军在{duration}s内每秒受到{damage}真实伤害,攻击力提升{atk×100}%,攻击速度增加{attack_speed}" (main-story stage pages: "在五分钟内"). The crisis-contract tag `global_tile_infection_1` 「目标:可控感染」 (PRTS 切尔诺伯格 6区废墟/历史合约): "踏过活性源石地块的敌人不再持续损失生命值" — without it an enemy that has walked over the tile goes on losing HP. Same-name effects do not stack (PRTS 作战机制: "同名buff的默认叠加策略buff只能表现出一个"). Its damage is 环境伤害 (PRTS 自然环境 lists 活性源石). A 重生 clears every buff but a whitelist (PRTS 特殊机制 §重生 "清空自身身上除白名单外所有Buff"; its 非首次标记 row: "如无特殊说明,也默认同常规Buff一样可被重生清除").
|
||||||
|
- **Cause**: `devices.js` read `duration` as a lifetime of the tiles (`terrainFor` switched them off after 300 s of battle time) and tied the effect to standing on the tile (`enterTerrain` removed it as soon as the unit's tile changed).
|
||||||
|
- **Now**: every tick a unit is on the tile (allies by their tile, ground enemies by their rounded position; flyers, hovering / levitated and hidden units never touch it, as before), `touchInfection` gives it the timed effect `terrain:infection` for `duration` s — 70 true damage per second, ATK +20 %, ASPD +20 — or, when it already carries it, gives it back its full time. Leaving the tile no longer removes it: an enemy that crossed it drains 70/s with ATK +20 % / ASPD +20 for 300 s from its last contact — longer than any own-field or 联防 battle lasts (≤ 230 s of battle time). All 12 ground legs of 战场#04 (own field, 联防 field, boss field) cross one of these tiles, so every ground enemy there carries it once past the tile. One effect per unit: a second pass or the next tile of a pair restarts its time and never adds a second effect (ATK +20 % once, 70/s, no extra tick). The tiles never switch off — the old lifetime only ever ran out on the boss / hidden fields (no time limit), at 300 s. An operator deployed on it is always in contact, so it drains until it is knocked out or withdrawn, past 300 s too (this follows from the [ASSUMED] refresh rule; v0.1.1 drained it the same way within the first 300 s). An operator moved off the tile (`Battle.relocate`: 乌尔比安's S3 move and return, 夕's 小自在) keeps the effect for its time — 乌尔比安 landing on (10,6) carries it home for 300 s (measured by the review); leaving the field (knock-out, withdrawal, 突袭's jump) drops it with every other buff. A 重生 (`rebirthCleanse`: 逐火 embers, 假想敌:再生's 傀儡, 锏, 守墓石像 …) clears it like any buff, and contact gives it again while the enemy stands on the tile. Kept through the 重生 (the first version of this fix), its 1 s ticks — each a hit of a husk's 特殊生命值 — killed a 深池逐火战士 ember that had crossed (11,6) at ~6 s instead of letting it stand up at 1 + 10 s (护卫's 火灰 at ~11 s, 傀儡 at ~16 s; found by the review). The damage itself is unchanged: true damage no unit deals (无来源), tagged `terrain` (环境伤害), a damage instance, not a 流失.
|
||||||
|
- [ASSUMED]: the time counts from the last contact (it is refreshed every tick on the tile) and the per-second rhythm runs on through a refresh — hence an operator on the tile drains past 300 s; contact gives the effect again to an enemy reborn on the tile; not 持续伤害 (no `dot` tag: PRTS 伤害分类's list of BUFF damage names 深水区, 源石污染区 and 活性源石风暴 but not 活性源石, so 锡人's 凋敝魂灵 does not raise it).
|
||||||
|
- **Checked**: no other terrain reads a `duration` — 沼泽 (layers, cleared on leaving), 深水区 (`sea_drown[enemy]` has none), 烟雾 and the blowers; 沼泽's interval and 深水区's damage kind were wrong, though (below). Nothing relied on the old lifetime: the client draws the tile glow from the stage map (it never went out), terrain effects have no status icon, the enemy abilities that care about the tile (萨卡兹枯朽前锋's arts attacks, “灵幛”, 孽罪奇美拉) read the tile they stand on (`onTerrain`), and 联防 carries no effects (HP, SP and a running skill only). 纠缠藤蔓 (`enemy_2052_smgia`, a 悬赏 enemy: "受到来自自然环境的伤害时,自身在短时间内获得高额脆弱"; content/enemies.js reads the `terrain` tag) that has crossed the tile stays ×2 fragile for 300 s and the tile's tick hits it for 140/s — official, PRTS 自然环境 listing 活性源石 as 环境伤害.
|
||||||
|
- **Other terrain found while fixing #33 item 6** (the review):
|
||||||
|
- **沼泽, official** (PRTS 沼泽控制 备注; skill_table `sktok_mire`): a unit in the mire "每秒将会触发一次" its 【陷入沼泽】 (the skill charges in `maxChargeTime` 1 s): an enemy gains 1 layer of ASPD −5 % and move speed −5 %, "若其重量大于等于3,改为获得2层"; any other unit 1 layer of ASPD −5 % only; at most 10 layers (`max_stack_cnt`); "上述减益于单位不再位于沼泽之中时解除". The skill's `value` 3 is that 重量. **Cause**: build-data and the devices.js fallback read `value` as the interval (3 s): 10 layers after 27 s for light and heavy enemies alike, and operators got the move part too. **Now**: `special.mire` has `intervalSec` 1 (the skill's charge time) and `heavyWeight` 3 (`value`); `tickMire` triggers on entering and then every second — 1 layer, 2 for an enemy of 重量 ≥ 3 (`s.massLevel` at the trigger), operators the ASPD part only, at most 10, cleared on leaving: 10 layers after 9 s (重量 ≥ 3: 4 s). [ASSUMED] the first trigger comes on entering (as before). A layer stays −5 ASPD (the remake's reading of the fraction, `aspdOf`).
|
||||||
|
- **深水区, official** (PRTS 涨潮控制 技能3 深水: "【水蚀】的敌方单位每秒受到40点无来源真实持续伤害(不属于环境伤害,不会触发受击回复)"; PRTS 自然环境: 涨潮's damage to enemies "并非环境伤害"; PRTS 伤害分类 lists 深水区/涨潮水蚀 as BUFF damage). **Cause**: the deep-water tick shared the `terrain` tag with 活性源石, so 纠缠藤蔓 turned ×2 fragile in deep water (360 HP lost in 5 s instead of 200) and 锡人's 凋敝魂灵 ("受到的持续伤害提高") never raised it. **Now**: `deepWaterDamage` — 无来源 true, `canDodge: false`, `sourceless`, `noSp`, tags `dot` / `periodic` / `deepsea`, no `terrain`. The 免疫水蚀 swimmers (潜水员) cancel only `deepsea` / `drown` damage, not 环境伤害.
|
||||||
|
- Tests: `test/content/tokens_devices.test.js` — 活性源石 (the operator and the enemy standing on it), an enemy keeps the effect after leaving the tile (20 s later: 70/s, ATK, ASPD, the time left), one effect per unit (two passes give one effect that ends `duration` s after the last contact, 70/s while it lasted; an operator drains for 3 × `duration`, keeps the effect after `Battle.relocate` and loses it `duration` s later), a 重生 clears it (a 深池逐火战士 that crossed the tile stands up from its ember after 1 + 10 s), 沼泽 (the data; operators ASPD only; 重量 1 vs 3: 1 vs 2 layers a second, at most 10; cleared on leaving), 深水 (the tick's tags; 纠缠藤蔓 not fragile); `test/content/enemies_bosses.test.js` (潜水员: the deep-water tick is cancelled, 环境伤害 is not).
|
||||||
|
|
||||||
|
|
||||||
### 22.5 Emotes and guide pages without a local client; deployment docs (#42); the English title (#38) — `tools/assets/plan.mjs UI_EXTRAS / GUIDE_PAGES`, data/assets.json `ui['emoticon/<dir>/<picId>']` / `ui['guide/<key>']`, `data.js artUrls / nextArtUrl`, `ui/emotes.js emoteArtUrls / EmoteArt`, `ui/guide.js guidePages / GuideHost`, `tools/setup.mjs LOCAL_ART_FALLBACK / LOCAL_ART_COPY_HINT`, `tools/doctor.mjs`, README, docs/DEPLOY.md §6, `server/index.js` banner, package.json `name`
|
### 22.5 Emotes and guide pages without a local client; deployment docs (#42); the English title (#38) — `tools/assets/plan.mjs UI_EXTRAS / GUIDE_PAGES`, data/assets.json `ui['emoticon/<dir>/<picId>']` / `ui['guide/<key>']`, `data.js artUrls / nextArtUrl`, `ui/emotes.js emoteArtUrls / EmoteArt`, `ui/guide.js guidePages / GuideHost`, `tools/setup.mjs LOCAL_ART_FALLBACK / LOCAL_ART_COPY_HINT`, `tools/doctor.mjs`, README, docs/DEPLOY.md §6, `server/index.js` banner, package.json `name`
|
||||||
|
|||||||
+10
-4
@@ -296,9 +296,12 @@ operators put on the enemy — the buffs with an ally source and source-less cat
|
|||||||
"清空自身身上除白名单外所有Buff"; `rebirthCleanse`), restarts its skills from their initial cooldown when the 重生 ends
|
"清空自身身上除白名单外所有Buff"; `rebirthCleanse`), restarts its skills from their initial cooldown when the 重生 ends
|
||||||
("重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"; `rebirthCooldowns`, since 0.1.1) — and keeps [ASSUMED: the
|
("重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"; `rebirthCooldowns`, since 0.1.1) — and keeps [ASSUMED: the
|
||||||
whitelist] its `persist` talents, what it or
|
whitelist] its `persist` talents, what it or
|
||||||
another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (terrain, airflow, element burst
|
another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (on-tile terrain, airflow, element
|
||||||
locks): a 逐火 knocked out while feared is no unblockable ember — nor one feared by the knocking-out hit itself (the
|
burst locks). 活性源石's lasting effect (`terrain:infection`, a timed buff that outlives the tile) is no field state: the
|
||||||
重生's 无敌 + 无法选中 refuses it, `Battle.applyStatus`). 重生 / form changes: `reborn()` (first knock-out → second
|
重生 clears it (PRTS 特殊机制 非首次标记 "如无特殊说明,也默认同常规Buff一样可被重生清除"), and contact gives it again on the
|
||||||
|
tile [ASSUMED] — kept, its ticks broke a 逐火 ember's hits (DESIGN §22.4). A 逐火 knocked out while feared is no
|
||||||
|
unblockable ember — nor one feared by the knocking-out hit itself (the 重生's 无敌 + 无法选中 refuses it,
|
||||||
|
`Battle.applyStatus`). 重生 / form changes: `reborn()` (first knock-out → second
|
||||||
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
|
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
|
||||||
immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent
|
immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent
|
||||||
Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that
|
Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that
|
||||||
@@ -640,7 +643,10 @@ and 扎罗's 溶血骇惧; "受到N真实伤害" over time is damage (PRTS 伤
|
|||||||
`periodic_damage` template (源石溶剂: PRTS 盟约记录 修正 "并非流失", "造成无来源真实持续环境伤害"; 狂暴宿主 "自身每秒受到N无来源
|
`periodic_damage` template (源石溶剂: PRTS 盟约记录 修正 "并非流失", "造成无来源真实持续环境伤害"; 狂暴宿主 "自身每秒受到N无来源
|
||||||
真实伤害"), 码头水手's drowning ("每秒受到1000点无来源真实伤害"), 弧光锋卫's 失衡 bleed (修正 "失衡移动时持续受到真实伤害") and
|
真实伤害"), 码头水手's drowning ("每秒受到1000点无来源真实伤害"), 弧光锋卫's 失衡 bleed (修正 "失衡移动时持续受到真实伤害") and
|
||||||
孽罪奇美拉's aura are `dealDamage(credit, target, damage.js periodicDamage(n))`: 无来源 true, `canDodge: false`, tags
|
孽罪奇美拉's aura are `dealDamage(credit, target, damage.js periodicDamage(n))`: 无来源 true, `canDodge: false`, tags
|
||||||
`'dot'` / `'periodic'` (player report D1). The 源石溶剂 drain also ticks on every 敌人类我方单位 of the field (炎佑, a
|
`'dot'` / `'periodic'` (player report D1). Terrain ticks (`content/devices.js`): 深水区's 【水蚀】 is the same kind (PRTS 涨潮控制
|
||||||
|
深水 "无来源真实持续伤害(不属于环境伤害,不会触发受击回复)": `noSp`, tags `'dot'` / `'periodic'` / `'deepsea'`); 活性源石's is
|
||||||
|
无来源 true damage tagged `'terrain'` — 环境伤害 (PRTS 自然环境), what "受到来自自然环境的伤害" content (纠缠藤蔓) reads — and
|
||||||
|
not `'dot'` [ASSUMED] (DESIGN §22.4). The 源石溶剂 drain also ticks on every 敌人类我方单位 of the field (炎佑, a
|
||||||
partner's too: PRTS 备注 "全场范围内的所有敌人类我方单位也会获得此装备的…效果…无视目标可选性"), credited to nobody, once per
|
partner's too: PRTS 备注 "全场范围内的所有敌人类我方单位也会获得此装备的…效果…无视目标可选性"), credited to nobody, once per
|
||||||
second however many carriers (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"), while a carrier is on the field
|
second however many carriers (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"), while a carrier is on the field
|
||||||
[ASSUMED]. 奥术法阵 has the same 备注 for its rider: while a carrier is on the field, every damage instance of such a unit
|
[ASSUMED]. 奥术法阵 has the same 备注 for its rider: while a carrier is on the field, every damage instance of such a unit
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ Boss board:
|
|||||||
| `b` | fence_bound | LOW | ALL | no (fly only) | deployable, but ground enemies path around it |
|
| `b` | fence_bound | LOW | ALL | no (fly only) | deployable, but ground enemies path around it |
|
||||||
| `a` / `A` | achand | HIGH | – | – | bench slot / non-bench hand tile |
|
| `a` / `A` | achand | HIGH | – | – | bench slot / non-bench hand tile |
|
||||||
| `S` `E` `I` `O` | start / end / telin / telout | LOW | NONE | yes | |
|
| `S` `E` `I` `O` | start / end / telin / telout | LOW | NONE | yes | |
|
||||||
| `m` | mire | LOW | ALL | yes | +1 stack every 3 s: atk-speed −5, move −5 %; max 10; cleared on leaving |
|
| `m` | mire | LOW | ALL | yes | a trigger every 1 s on it (PRTS 沼泽控制; the first on entering): enemies +1 layer of atk-speed −5 / move −5 % (重量 ≥ 3, the skill's `value`: +2 layers), operators +1 layer of atk-speed −5 only; max 10; cleared on leaving |
|
||||||
| `g` | smog | LOW | ALL | yes | an operator here cannot be targeted by enemy **ranged** attacks |
|
| `g` | smog | LOW | ALL | yes | an operator here cannot be targeted by enemy **ranged** attacks |
|
||||||
| `d` | deepsea | LOW | ALL | yes | enemies take 40 dmg/s, atk-speed −60, move ×0.6 |
|
| `d` | deepsea | LOW | ALL | yes | enemies take 40 dmg/s (无来源 true 持续伤害, not 环境伤害), atk-speed −60, move ×0.6 |
|
||||||
| `i` | infection | LOW | ALL | yes | 70 true dmg/s to allies on it and enemies crossing it; +20 % ATK, +20 ASPD; 300 s |
|
| `i` | infection | LOW | ALL | yes | allies on it and enemies crossing it get 70 true dmg/s, +20 % ATK, +20 ASPD for 300 s from their last contact (an enemy keeps it after leaving the tile; the tiles never switch off) |
|
||||||
|
|
||||||
### 2.4 Stage pool (`stageDatasDict`)
|
### 2.4 Stage pool (`stageDatasDict`)
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
// RIGHT|DOWN|LEFT) equals the blower's dir / is its opposite / is perpendicular get ATK +
|
// RIGHT|DOWN|LEFT) equals the blower's dir / is its opposite / is perpendicular get ATK +
|
||||||
// blower_s_character[equal|opposite|vertical].atk (the m01 blowers blow DOWN); enemies moving with /
|
// blower_s_character[equal|opposite|vertical].atk (the m01 blowers blow DOWN); enemies moving with /
|
||||||
// against the flow get move speed × (1 + blower_s_enemy[equal|opposite].move_speed)
|
// against the flow get move speed × (1 + blower_s_enemy[equal|opposite].move_speed)
|
||||||
// 沼泽 m +1 stack on entering and every intervalSec s on the tile (max maxStacks): ASPD aspdPerStack (fractions
|
// 沼泽 m a unit on it (allies and ground enemies) triggers 【陷入沼泽】 on entering and every intervalSec (1) s
|
||||||
// are ×100 ASPD), move speed × (1 + moveMulPerStack × stacks); cleared on leaving (allies and ground enemies)
|
// there: an enemy gains 1 layer — 2 at 重量 ≥ heavyWeight (the device's `value`, 3) — of ASPD aspdPerStack
|
||||||
|
// (fractions are ×100 ASPD) and move speed × (1 + moveMulPerStack × layers), an operator 1 layer of the
|
||||||
|
// ASPD part only; at most maxStacks layers; cleared on leaving
|
||||||
// 烟雾 g operators on it cannot be targeted by enemy ranged attacks (stealth flag: blocked enemies still hit them)
|
// 烟雾 g operators on it cannot be targeted by enemy ranged attacks (stealth flag: blocked enemies still hit them)
|
||||||
// 深水 d ground enemies on it: sea_drown[enemy].damage true dmg/s, ASPD attack_speed (×100), move × move_speed
|
// 深水 d ground enemies on it: sea_drown[enemy].damage dmg/s (无来源 true 持续伤害, not 环境伤害: tags dot /
|
||||||
// 活性源石 i units on it (allies and ground enemies): damage true dmg/s, ATK + atk, ASPD + attack_speed, for
|
// periodic / deepsea), ASPD attack_speed (×100), move × move_speed
|
||||||
// `duration` s of battle time
|
// 活性源石 i a unit on it (allies and ground enemies) gets a timed effect: damage true dmg/s, ATK + atk, ASPD +
|
||||||
|
// attack_speed for `duration` s from its last contact — an enemy keeps it after walking off; one effect
|
||||||
|
// per unit, its time starts again while the unit is on the tile; the tiles never switch off
|
||||||
// “双眼皮” turrets (`trap_1104_aclasert`, band 机械援助 / `deviceOverrides` alias on): ranged arts shooter on its data
|
// “双眼皮” turrets (`trap_1104_aclasert`, band 机械援助 / `deviceOverrides` alias on): ranged arts shooter on its data
|
||||||
// range; ASPD + attack_speed_per_stack × L (≤ max_attack_speed), hits apply fragile 1 + damage_scale_per_stack
|
// range; ASPD + attack_speed_per_stack × L (≤ max_attack_speed), hits apply fragile 1 + damage_scale_per_stack
|
||||||
// × L (≤ max_damage_scale) for the text's duration; L = the owner's highest bond layers (live)
|
// × L (≤ max_damage_scale) for the text's duration; L = the owner's highest bond layers (live)
|
||||||
@@ -45,7 +49,7 @@ const CRATE_KEY = 'trap_1105_accrate';
|
|||||||
const TURRET_KEY = 'trap_1104_aclasert';
|
const TURRET_KEY = 'trap_1104_aclasert';
|
||||||
/** Research 05 §2.3 values, used only when a stage has no `special` block / device blackboard. */
|
/** Research 05 §2.3 values, used only when a stage has no `special` block / device blackboard. */
|
||||||
const RESEARCH = Object.freeze({
|
const RESEARCH = Object.freeze({
|
||||||
mire: { intervalSec: 3, aspdPerStack: -0.05, moveMulPerStack: -0.05, maxStacks: 10 },
|
mire: { intervalSec: 1, aspdPerStack: -0.05, moveMulPerStack: -0.05, maxStacks: 10, heavyWeight: 3 },
|
||||||
deepsea: { damage: 40, attack_speed: -0.6, move_speed: 0.6 },
|
deepsea: { damage: 40, attack_speed: -0.6, move_speed: 0.6 },
|
||||||
infection: { damage: 70, atk: 0.2, attack_speed: 20, duration: 300 },
|
infection: { damage: 70, atk: 0.2, attack_speed: 20, duration: 300 },
|
||||||
});
|
});
|
||||||
@@ -287,11 +291,14 @@ function buildTerrain(battle, st) {
|
|||||||
// mire
|
// mire
|
||||||
const m = sp.mire || {};
|
const m = sp.mire || {};
|
||||||
const mdev = devSkill('mireController') || {};
|
const mdev = devSkill('mireController') || {};
|
||||||
|
// the device skill's `value` (3) is the 重量 from which an enemy gains 2 layers, not an interval (PRTS 沼泽控制)
|
||||||
|
const mInterval = num(m.intervalSec, RESEARCH.mire.intervalSec);
|
||||||
st.mire = {
|
st.mire = {
|
||||||
interval: num(m.intervalSec ?? mdev.value, RESEARCH.mire.intervalSec),
|
interval: mInterval > 0 ? mInterval : RESEARCH.mire.intervalSec,
|
||||||
aspdPer: aspdOf(num(m.aspdPerStack ?? mdev.attack_speed, RESEARCH.mire.aspdPerStack)),
|
aspdPer: aspdOf(num(m.aspdPerStack ?? mdev.attack_speed, RESEARCH.mire.aspdPerStack)),
|
||||||
movePer: num(m.moveMulPerStack ?? mdev.move_speed, RESEARCH.mire.moveMulPerStack),
|
movePer: num(m.moveMulPerStack ?? mdev.move_speed, RESEARCH.mire.moveMulPerStack),
|
||||||
max: Math.max(1, Math.floor(num(m.maxStacks ?? mdev.max_stack_cnt, RESEARCH.mire.maxStacks))),
|
max: Math.max(1, Math.floor(num(m.maxStacks ?? mdev.max_stack_cnt, RESEARCH.mire.maxStacks))),
|
||||||
|
heavyWeight: num(m.heavyWeight ?? mdev.value, RESEARCH.mire.heavyWeight),
|
||||||
};
|
};
|
||||||
// deep sea
|
// deep sea
|
||||||
const ds = sp.deepsea?.bb || devSkill('tideController') || {};
|
const ds = sp.deepsea?.bb || devSkill('tideController') || {};
|
||||||
@@ -300,13 +307,15 @@ function buildTerrain(battle, st) {
|
|||||||
aspd: aspdOf(num(ds['sea_drown[enemy].attack_speed'], RESEARCH.deepsea.attack_speed)),
|
aspd: aspdOf(num(ds['sea_drown[enemy].attack_speed'], RESEARCH.deepsea.attack_speed)),
|
||||||
moveMul: num(ds['sea_drown[enemy].move_speed'], RESEARCH.deepsea.move_speed),
|
moveMul: num(ds['sea_drown[enemy].move_speed'], RESEARCH.deepsea.move_speed),
|
||||||
};
|
};
|
||||||
// active originium
|
// active originium: `duration` is how long the effect lasts on a unit (PRTS tile template "部署于其上的我军和经过的
|
||||||
|
// 敌军在{duration}s内…"), not a lifetime of the tiles
|
||||||
const inf = sp.infection?.bb || {};
|
const inf = sp.infection?.bb || {};
|
||||||
|
const infDur = num(inf.duration, RESEARCH.infection.duration);
|
||||||
st.infection = {
|
st.infection = {
|
||||||
damage: num(inf.damage, RESEARCH.infection.damage),
|
damage: num(inf.damage, RESEARCH.infection.damage),
|
||||||
atk: num(inf.atk, RESEARCH.infection.atk),
|
atk: num(inf.atk, RESEARCH.infection.atk),
|
||||||
aspd: aspdOf(num(inf.attack_speed, RESEARCH.infection.attack_speed)),
|
aspd: aspdOf(num(inf.attack_speed, RESEARCH.infection.attack_speed)),
|
||||||
until: num(inf.duration, RESEARCH.infection.duration),
|
duration: infDur > 0 ? infDur : RESEARCH.infection.duration,
|
||||||
};
|
};
|
||||||
// blowers
|
// blowers
|
||||||
for (const d of battle.stage?.devices || []) {
|
for (const d of battle.stage?.devices || []) {
|
||||||
@@ -325,35 +334,80 @@ function buildTerrain(battle, st) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const terrainDamage = (battle, amount) => (ctx) => {
|
/** 活性源石's tick: true damage no unit deals (无来源), tagged 'terrain' = 环境伤害 ("受到来自自然环境的伤害" content reads it). */
|
||||||
|
const infectionDamage = (battle, amount) => (ctx) => {
|
||||||
if (amount > 0 && ctx.unit.alive) battle.dealDamage(null, ctx.unit, { amount, type: 'true', canDodge: false, tags: ['terrain'] });
|
if (amount > 0 && ctx.unit.alive) battle.dealDamage(null, ctx.unit, { amount, type: 'true', canDodge: false, tags: ['terrain'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深水区's 【水蚀】 tick (PRTS 涨潮控制 技能3 深水: "每秒受到40点无来源真实持续伤害(不属于环境伤害,不会触发受击回复)"; PRTS 伤害分类
|
||||||
|
* lists 深水区/涨潮水蚀 as BUFF damage): 无来源 true 持续伤害 — tags 'dot' (锡人's 凋敝魂灵 raises it), 'periodic' and
|
||||||
|
* 'deepsea' (the 免疫水蚀 swimmers cancel it), no 'terrain' (not 环境伤害), no 受击回复 (noSp).
|
||||||
|
*/
|
||||||
|
const deepWaterDamage = (battle, amount) => (ctx) => {
|
||||||
|
if (amount > 0 && ctx.unit.alive) battle.dealDamage(null, ctx.unit, { amount, type: 'true', canDodge: false, sourceless: true, noSp: true, tags: ['dot', 'periodic', 'deepsea'] });
|
||||||
|
};
|
||||||
|
|
||||||
function enterTerrain(battle, st, u, code) {
|
function enterTerrain(battle, st, u, code) {
|
||||||
const m = u.mem;
|
const m = u.mem;
|
||||||
if (m.terrain === code) return;
|
if (m.terrain === code) return;
|
||||||
if (m.terrain) battle.removeBuff(u, BUFF[m.terrain]);
|
// leaving a tile ends its effect — except 活性源石's, which runs out on its own (touchInfection)
|
||||||
|
if (m.terrain && m.terrain !== TERRAIN.infection) battle.removeBuff(u, BUFF[m.terrain]);
|
||||||
m.terrain = code;
|
m.terrain = code;
|
||||||
m.terrainSince = battle.time;
|
m.terrainSince = battle.time;
|
||||||
m.mireStacks = 0;
|
m.mireStacks = 0;
|
||||||
|
m.mireTriggers = 0;
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
if (code === TERRAIN.smog) battle.addBuff(u, { key: BUFF[code], flags: { stealth: true } });
|
if (code === TERRAIN.smog) battle.addBuff(u, { key: BUFF[code], flags: { stealth: true } });
|
||||||
else if (code === TERRAIN.deepsea) {
|
else if (code === TERRAIN.deepsea) {
|
||||||
const D = st.deepsea;
|
const D = st.deepsea;
|
||||||
battle.addBuff(u, { key: BUFF[code], mods: { aspd: D.aspd, moveMul: D.moveMul }, interval: 1, onTick: terrainDamage(battle, D.damage) });
|
battle.addBuff(u, { key: BUFF[code], mods: { aspd: D.aspd, moveMul: D.moveMul }, interval: 1, onTick: deepWaterDamage(battle, D.damage) });
|
||||||
} else if (code === TERRAIN.infection) {
|
|
||||||
const I = st.infection;
|
|
||||||
battle.addBuff(u, { key: BUFF[code], mods: { atkPct: I.atk, aspd: I.aspd }, interval: 1, onTick: terrainDamage(battle, I.damage) });
|
|
||||||
}
|
}
|
||||||
// mire stacks are applied by tickMire
|
// mire layers are applied by tickMire, 活性源石 by touchInfection (every tick on the tile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 活性源石 contact (every tick on the tile). The tile gives a timed effect — damage true dmg/s, ATK + atk, ASPD +
|
||||||
|
* attack_speed for `duration` s (PRTS 特殊地形 tile template: "部署于其上的我军和经过的敌军在{duration}s内每秒受到{damage}
|
||||||
|
* 真实伤害,攻击力提升…,攻击速度增加…") — that stays on an enemy after it walks off: PRTS 危机合约 tag
|
||||||
|
* global_tile_infection_1 「目标:可控感染」 "踏过活性源石地块的敌人不再持续损失生命值" switches the lasting HP loss off,
|
||||||
|
* so without it the loss continues. One effect per unit (PRTS 作战机制: "同名buff的默认叠加策略buff只能表现出一个"): a
|
||||||
|
* unit that already carries it gets its full `duration` back and keeps its per-second rhythm — no second effect, no
|
||||||
|
* extra tick [ASSUMED: the time counts from the last contact — so an operator deployed on it, always in contact, drains
|
||||||
|
* past `duration`]. An operator moved off the tile (Battle.relocate: 乌尔比安 S3, 夕's 小自在 …) keeps it for its time;
|
||||||
|
* leaving the field drops it with every buff; a 重生 clears it (enemies.js rebirthCleanse: PRTS 特殊机制 §重生 "清空自身
|
||||||
|
* 身上除白名单外所有Buff") and contact gives it again while the unit is on the tile [ASSUMED]. The tick (infectionDamage)
|
||||||
|
* is true damage no unit deals (无来源), tagged 'terrain' = 环境伤害 (PRTS 自然环境 lists 活性源石), not 'dot' [ASSUMED:
|
||||||
|
* PRTS 伤害分类's list of BUFF damage does not name it].
|
||||||
|
*/
|
||||||
|
function touchInfection(battle, st, u) {
|
||||||
|
const I = st.infection;
|
||||||
|
const key = BUFF[TERRAIN.infection];
|
||||||
|
const b = u.findBuff(key);
|
||||||
|
if (b) { if (b.timeLeft < I.duration) b.timeLeft = I.duration; return; }
|
||||||
|
battle.addBuff(u, { key, duration: I.duration, mods: { atkPct: I.atk, aspd: I.aspd }, interval: 1, onTick: infectionDamage(battle, I.damage) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layers an enemy gains per 【陷入沼泽】 trigger at 重量 ≥ heavyWeight (PRTS 沼泽控制: "若其重量大于等于3,改为获得2层"). */
|
||||||
|
const MIRE_HEAVY_LAYERS = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 沼泽 (PRTS 沼泽控制 备注): a unit in the mire triggers its 【陷入沼泽】 "每秒…一次" (the device skill charges in 1 s) — an
|
||||||
|
* enemy gains 1 layer of ASPD −5 % and move speed −5 % (2 layers at 重量 ≥ heavyWeight, the skill's `value` 3), any other
|
||||||
|
* unit 1 layer of ASPD −5 % only; at most 10 layers; "上述减益于单位不再位于沼泽之中时解除" (enterTerrain).
|
||||||
|
* [ASSUMED] the first trigger comes on entering, then one every second.
|
||||||
|
*/
|
||||||
function tickMire(battle, st, u) {
|
function tickMire(battle, st, u) {
|
||||||
const M = st.mire;
|
const M = st.mire, m = u.mem;
|
||||||
const n = Math.min(M.max, 1 + Math.floor((battle.time - u.mem.terrainSince + 1e-9) / M.interval));
|
const due = 1 + Math.floor((battle.time - m.terrainSince + 1e-9) / M.interval);
|
||||||
if (n === u.mem.mireStacks) return;
|
if (due <= m.mireTriggers) return;
|
||||||
u.mem.mireStacks = n;
|
const enemy = u.side === 'enemy';
|
||||||
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } });
|
const per = enemy && num(u.s.massLevel, 0) >= M.heavyWeight ? MIRE_HEAVY_LAYERS : 1;
|
||||||
|
const n = Math.min(M.max, m.mireStacks + per * (due - m.mireTriggers));
|
||||||
|
m.mireTriggers = due;
|
||||||
|
if (n === m.mireStacks) return;
|
||||||
|
m.mireStacks = n;
|
||||||
|
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: enemy ? { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } : { aspd: M.aspdPer * n } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -372,7 +426,6 @@ function terrainFor(battle, st, u) {
|
|||||||
let code = st.terrain[k];
|
let code = st.terrain[k];
|
||||||
if (code === TERRAIN.smog && u.side !== 'ally') code = 0;
|
if (code === TERRAIN.smog && u.side !== 'ally') code = 0;
|
||||||
if (code === TERRAIN.deepsea && u.side === 'ally') code = 0;
|
if (code === TERRAIN.deepsea && u.side === 'ally') code = 0;
|
||||||
if (code === TERRAIN.infection && battle.time >= st.infection.until) code = 0;
|
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +473,7 @@ function refreshUnit(battle, st, u) {
|
|||||||
if (st.hasTerrain) {
|
if (st.hasTerrain) {
|
||||||
enterTerrain(battle, st, u, terrainFor(battle, st, u));
|
enterTerrain(battle, st, u, terrainFor(battle, st, u));
|
||||||
if (u.mem.terrain === TERRAIN.mire) tickMire(battle, st, u);
|
if (u.mem.terrain === TERRAIN.mire) tickMire(battle, st, u);
|
||||||
|
else if (u.mem.terrain === TERRAIN.infection) touchInfection(battle, st, u);
|
||||||
}
|
}
|
||||||
if (st.flow.size) { if (u.side === 'ally') tickAirflowAlly(battle, st, u); else tickAirflowEnemy(battle, st, u); }
|
if (st.flow.size) { if (u.side === 'ally') tickAirflowAlly(battle, st, u); else tickAirflowEnemy(battle, st, u); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -749,9 +749,12 @@ function freeAllPrisoners(b) {
|
|||||||
* buff an operator / summon / device put on it and every source-less catalogue status (晕眩, 减速, 恐惧, 脆弱, 诱导 … —
|
* buff an operator / summon / device put on it and every source-less catalogue status (晕眩, 减速, 恐惧, 脆弱, 诱导 … —
|
||||||
* buffs.js STATUS). Kept [ASSUMED: the whitelist]: its talents and traits (`persist` buffs, and what it or another enemy
|
* buffs.js STATUS). Kept [ASSUMED: the whitelist]: its talents and traits (`persist` buffs, and what it or another enemy
|
||||||
* gave it — 锏's 抵抗 is a self-applied status, enemy auras refresh every few tenths of a second anyway) and the field's
|
* gave it — 锏's 抵抗 is a self-applied status, enemy auras refresh every few tenths of a second anyway) and the field's
|
||||||
* state buffs without a source (terrain, airflow — re-applied by position — and element burst locks). Without it a 逐火
|
* state buffs without a source (on-tile terrain, airflow — re-applied by position — and element burst locks). Without it
|
||||||
* knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable — as an
|
* a 逐火 knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable —
|
||||||
* ember until the fear ran out.
|
* as an ember until the fear ran out. 活性源石's lasting effect (devices.js touchInfection) is no field state but a timed
|
||||||
|
* buff that outlives the tile, so it is cleared like any buff (PRTS 特殊机制 非首次标记: "如无特殊说明,也默认同常规Buff一样
|
||||||
|
* 可被重生清除"); contact gives it again while the enemy stands on the tile. Kept, its 1 s ticks — each a hit of a husk's
|
||||||
|
* 特殊生命值 — killed a 逐火 ember that had crossed the tile long before it could stand up (review of GitHub #33 item 6).
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* The end of a 重生 (PRTS 特殊机制 §重生 "重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"): every ability with a
|
* The end of a 重生 (PRTS 特殊机制 §重生 "重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"): every ability with a
|
||||||
@@ -761,10 +764,13 @@ function rebirthCooldowns(e) {
|
|||||||
for (const s of (e.mem.ab && e.mem.ab.list) || []) if (s.cd != null) s.left = num(s.icd, 0);
|
for (const s of (e.mem.ab && e.mem.ab.list) || []) if (s.cd != null) s.left = num(s.icd, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** devices.js' lasting 活性源石 effect: a timed buff, not field state — cleared at a 重生 (rebirthCleanse). */
|
||||||
|
const INFECTION_BUFF = 'terrain:infection';
|
||||||
|
|
||||||
function rebirthCleanse(b, e) {
|
function rebirthCleanse(b, e) {
|
||||||
for (const x of e.buffs.slice()) {
|
for (const x of e.buffs.slice()) {
|
||||||
if (x.persist || (x.source && x.source.side === 'enemy')) continue;
|
if (x.persist || (x.source && x.source.side === 'enemy')) continue;
|
||||||
if (x.source || x.status) b.removeBuff(e, x);
|
if (x.source || x.status || x.key === INFECTION_BUFF) b.removeBuff(e, x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,7 +1122,8 @@ function kitDeepsea(ab, { swim = false, drown = false }) {
|
|||||||
if (drown && wet) { const v = T(ab, 'Drown.damage') ?? 0; if (v > 0) b.dealDamage(null, e, { ...periodicDamage(v * dt), tags: ['dot', 'periodic', 'drown'] }); }
|
if (drown && wet) { const v = T(ab, 'Drown.damage') ?? 0; if (v > 0) b.dealDamage(null, e, { ...periodicDamage(v * dt), tags: ['dot', 'periodic', 'drown'] }); }
|
||||||
a.wet = wet;
|
a.wet = wet;
|
||||||
},
|
},
|
||||||
hitIn(c, b, e, a) { if (swim && a.wet && c.dmg.tags && c.dmg.tags.some((t) => t === 'terrain' || t === 'deepsea' || t === 'drown')) c.dmg.cancel = true; },
|
// 免疫水蚀: the deep-water tick (devices.js, tag 'deepsea') and drowning — not 环境伤害 ('terrain', e.g. 活性源石)
|
||||||
|
hitIn(c, b, e, a) { if (swim && a.wet && c.dmg.tags && c.dmg.tags.some((t) => t === 'deepsea' || t === 'drown')) c.dmg.cancel = true; },
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -397,8 +397,11 @@ test(`${nm('enemy_1158_divman')}: in deep water ATK +Swim.atk and stealth; immun
|
|||||||
h.run(0.5);
|
h.run(0.5);
|
||||||
approx(e.s.atk, dry.s.atk * (1 + tb('enemy_1158_divman', 'Swim.atk')));
|
approx(e.s.atk, dry.s.atk * (1 + tb('enemy_1158_divman', 'Swim.atk')));
|
||||||
assert.ok(e.s.flags.stealth && !dry.s.flags.stealth);
|
assert.ok(e.s.flags.stealth && !dry.s.flags.stealth);
|
||||||
h.b.dealDamage(null, e, { amount: 40, type: 'true', tags: ['terrain'] });
|
// 免疫水蚀: devices.js' deep-water tick (tag 'deepsea'); 环境伤害 ('terrain', e.g. 活性源石) still lands
|
||||||
|
h.b.dealDamage(null, e, { amount: 40, type: 'true', canDodge: false, sourceless: true, noSp: true, tags: ['dot', 'periodic', 'deepsea'] });
|
||||||
assert.equal(e.hp, e.s.maxHp);
|
assert.equal(e.hp, e.s.maxHp);
|
||||||
|
h.b.dealDamage(null, e, { amount: 40, type: 'true', tags: ['terrain'] });
|
||||||
|
assert.equal(e.hp, e.s.maxHp - 40);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const key of ['enemy_1160_hvyslr', 'enemy_1160_hvyslr_2']) {
|
for (const key of ['enemy_1160_hvyslr', 'enemy_1160_hvyslr_2']) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { hasGeneratedData, getDefaultSource } from '../../server/sim/simdata.js'
|
|||||||
import { genericKit } from '../../server/sim/content/generic.js';
|
import { genericKit } from '../../server/sim/content/generic.js';
|
||||||
import { spawnYanyou, spawnMapChar, TOKEN_IDS, wolfShadows, tileFree, findSummonTile, summonToken } from '../../server/sim/content/tokens.js';
|
import { spawnYanyou, spawnMapChar, TOKEN_IDS, wolfShadows, tileFree, findSummonTile, summonToken } from '../../server/sim/content/tokens.js';
|
||||||
import { startColdWind, kjeragColdWind, activateTurrets, terrainAt, deviceOverridesOf } from '../../server/sim/content/devices.js';
|
import { startColdWind, kjeragColdWind, activateTurrets, terrainAt, deviceOverridesOf } from '../../server/sim/content/devices.js';
|
||||||
|
import { HUSK_REBIRTH } from '../../server/sim/content/enemies.js';
|
||||||
|
|
||||||
const REAL = { skip: !hasGeneratedData() };
|
const REAL = { skip: !hasGeneratedData() };
|
||||||
const ds = getDefaultSource();
|
const ds = getDefaultSource();
|
||||||
@@ -607,17 +608,40 @@ test('气流 (act2 m01 blowers): enemies moving with the flow ×(1+equal), again
|
|||||||
assert.ok(up.has(Math.round((1 + bb['blower_s_enemy[opposite].move_speed']) * 100) / 100), `against the flow ${[...up]}`);
|
assert.ok(up.has(Math.round((1 + bb['blower_s_enemy[opposite].move_speed']) * 100) / 100), `against the flow ${[...up]}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('沼泽 (act2 m02): +1 stack on entering and every intervalSec (ASPD −5, move −5 % each), max stacks, cleared on leaving', REAL, () => {
|
test('沼泽 (act2 m02): a trigger on entering and every second — 1 layer (an enemy of 重量 ≥ 3: 2) of ASPD −5 / move −5 %, at most 10; operators ASPD only; cleared on leaving', REAL, () => {
|
||||||
const mire = ds.getStage('act2autochess_m02').special.mire;
|
const mire = ds.getStage('act2autochess_m02').special.mire;
|
||||||
|
// PRTS 沼泽控制: "每秒将会触发一次" (sktok_mire spData maxChargeTime 1); "若其重量大于等于3,改为获得2层" (the skill's value 3)
|
||||||
|
assert.equal(mire.intervalSec, 1);
|
||||||
|
assert.equal(mire.heavyWeight, 3);
|
||||||
const h = makeBattle({ stageId: 'act2autochess_m02', defs: { chess: { test_guard: guard() } }, units: [{ chessId: 'test_guard', row: 11, col: 7 }], autoFinish: false, timeLimit: 60 });
|
const h = makeBattle({ stageId: 'act2autochess_m02', defs: { chess: { test_guard: guard() } }, units: [{ chessId: 'test_guard', row: 11, col: 7 }], autoFinish: false, timeLimit: 60 });
|
||||||
h.step(2);
|
h.step(2);
|
||||||
assert.equal(terrainAt(h.b, 11, 7), 'mire');
|
assert.equal(terrainAt(h.b, 11, 7), 'mire');
|
||||||
const g = h.unit('test_guard');
|
const g = h.unit('test_guard');
|
||||||
approx(g.s.aspd, 100 + mire.aspdPerStack * 100, 1e-9, '1 stack');
|
approx(g.s.aspd, 100 + mire.aspdPerStack * 100, 1e-9, '1 layer');
|
||||||
|
assert.equal(g.findBuff('terrain:mire').mods.moveMul, undefined, 'an operator gets the ASPD part only');
|
||||||
h.run(mire.intervalSec);
|
h.run(mire.intervalSec);
|
||||||
approx(g.s.aspd, 100 + 2 * mire.aspdPerStack * 100, 1e-9, '2 stacks');
|
approx(g.s.aspd, 100 + 2 * mire.aspdPerStack * 100, 1e-9, '2 layers');
|
||||||
h.run(mire.intervalSec * mire.maxStacks);
|
h.run(mire.intervalSec * mire.maxStacks);
|
||||||
approx(g.s.aspd, 100 + mire.maxStacks * mire.aspdPerStack * 100, 1e-9, 'max stacks');
|
approx(g.s.aspd, 100 + mire.maxStacks * mire.aspdPerStack * 100, 1e-9, 'max layers');
|
||||||
|
// enemies standing in the mire: 重量 1 gains 1 layer a second, 重量 3 two (heavyWeight), both capped at maxStacks
|
||||||
|
const layersOver = (mass) => {
|
||||||
|
const key = `enemy_mass${mass}`;
|
||||||
|
const h3 = makeBattle({ stageId: 'act2autochess_m02', defs: { enemies: { [key]: walker({ key, mass }) } }, enemies: [{ key, pos: [10, 7], route: { motion: 'WALK', start: [10, 7], end: [10, 7], checkpoints: [{ type: 'WAIT', time: 99 }] } }], autoFinish: false, timeLimit: 60 });
|
||||||
|
h3.step();
|
||||||
|
const e = h3.enemy(key);
|
||||||
|
assert.equal(terrainAt(h3.b, 10, 7), 'mire');
|
||||||
|
const t0 = e.mem.terrainSince;
|
||||||
|
const out = [0.5, 1.5, 2.5, 4.5, 9.5, 20].map((t) => {
|
||||||
|
h3.run(t0 + t - h3.b.time);
|
||||||
|
const n = Math.round((e.s.aspd - 100) / (mire.aspdPerStack * 100));
|
||||||
|
approx(e.s.moveSpeed, 1 + n * mire.moveMulPerStack, 1e-9, `move ×(1 − 5 % × ${n})`);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
checkInvariants(h3.b);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
assert.deepEqual(layersOver(1), [1, 2, 3, 5, 10, 10], 'weight 1: a layer per second');
|
||||||
|
assert.deepEqual(layersOver(3), [2, 4, 6, 10, 10, 10], 'weight 3: two layers per second');
|
||||||
// an enemy walking down col 7 (mire) is slowed there and recovers once it leaves
|
// an enemy walking down col 7 (mire) is slowed there and recovers once it leaves
|
||||||
const h2 = makeBattle({ stageId: 'act2autochess_m02', defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route: { motion: 'WALK', start: [12, 7], end: [9, 2], checkpoints: [] } }], autoFinish: false, timeLimit: 60 });
|
const h2 = makeBattle({ stageId: 'act2autochess_m02', defs: { enemies: { enemy_walker: walker() } }, enemies: [{ key: 'enemy_walker', route: { motion: 'WALK', start: [12, 7], end: [9, 2], checkpoints: [] } }], autoFinish: false, timeLimit: 60 });
|
||||||
let slowed = false;
|
let slowed = false;
|
||||||
@@ -658,6 +682,32 @@ test('深水 (act2 m04): ground enemies in deep water take damage/s, ASPD −60,
|
|||||||
checkInvariants(h.b);
|
checkInvariants(h.b);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PRTS 涨潮控制 技能3 深水: "【水蚀】的敌方单位每秒受到40点无来源真实持续伤害(不属于环境伤害,不会触发受击回复)"
|
||||||
|
test('深水: the 【水蚀】 tick is 无来源 true 持续伤害, not 环境伤害 (tags dot / periodic / deepsea, no terrain, no 受击回复) — 纠缠藤蔓 does not turn fragile in it', REAL, () => {
|
||||||
|
const bb = ds.getStage('act2autochess_m04').special.deepsea.bb;
|
||||||
|
const dmg = bb['sea_drown[enemy].damage'];
|
||||||
|
const wait = (pos) => ({ motion: 'WALK', start: pos, end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] });
|
||||||
|
const ticks = [];
|
||||||
|
const h = makeBattle({ stageId: 'act2autochess_m04', defs: { enemies: { enemy_dummy: dummy({ speed: 1 }) } },
|
||||||
|
enemies: [{ key: 'enemy_dummy', pos: [11, 6], route: wait([11, 6]) }, { key: 'enemy_2052_smgia', pos: [10, 6], route: wait([10, 6]) }], autoFinish: false, timeLimit: 30,
|
||||||
|
setup: (b) => b.on('hit', ({ source, target, dmg: d }) => { if (target.defId === 'enemy_dummy') ticks.push({ source, type: d.type, tags: [...d.tags], sourceless: d.sourceless, noSp: d.noSp }); }) });
|
||||||
|
h.step();
|
||||||
|
h.run(5);
|
||||||
|
const e = h.enemy('enemy_dummy'), vine = h.enemy('enemy_2052_smgia');
|
||||||
|
assert.deepEqual([terrainAt(h.b, 11, 6), terrainAt(h.b, 10, 6)], ['deepsea', 'deepsea']);
|
||||||
|
assert.equal(ticks.length, 5);
|
||||||
|
for (const t of ticks) {
|
||||||
|
assert.equal(t.source, null);
|
||||||
|
assert.equal(t.type, 'true');
|
||||||
|
assert.deepEqual(t.tags, ['dot', 'periodic', 'deepsea']);
|
||||||
|
assert.ok(t.sourceless && t.noSp);
|
||||||
|
}
|
||||||
|
approx(1e7 - e.hp, 5 * dmg, 1e-9);
|
||||||
|
assert.equal(vine.findBuff('ab:natureWeak'), null, '受到来自自然环境的伤害 does not fire');
|
||||||
|
approx(vine.s.maxHp - vine.hp, 5 * dmg, 1e-9, '40/s, not ×2');
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
|
||||||
test('活性源石 (act1 m04): units on it take damage/s and gain ATK/ASPD (allies and ground enemies)', REAL, () => {
|
test('活性源石 (act1 m04): units on it take damage/s and gain ATK/ASPD (allies and ground enemies)', REAL, () => {
|
||||||
const bb = ds.getStage('act1autochess_m04').special.infection.bb;
|
const bb = ds.getStage('act1autochess_m04').special.infection.bb;
|
||||||
const g = guard({ stats: { atk: 1000, maxHp: 1e5 } });
|
const g = guard({ stats: { atk: 1000, maxHp: 1e5 } });
|
||||||
@@ -674,6 +724,112 @@ test('活性源石 (act1 m04): units on it take damage/s and gain ATK/ASPD (alli
|
|||||||
checkInvariants(h.b);
|
checkInvariants(h.b);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GitHub #33 item 6: the effect is timed (PRTS tile template "经过的敌军在{duration}s内…"), not tied to standing on the tile
|
||||||
|
test('活性源石: an enemy keeps the effect after leaving the tile — damage/s, ATK and ASPD go on for `duration` s', REAL, () => {
|
||||||
|
const bb = ds.getStage('act1autochess_m04').special.infection.bb;
|
||||||
|
// a harmless walker crosses (11,6) going left, then waits on (11,4), off the tile
|
||||||
|
const route = { motion: 'WALK', start: [11, 8], end: [9, 2], checkpoints: [{ type: 'MOVE', pos: [11, 4] }, { type: 'WAIT', time: 99 }] };
|
||||||
|
const h = makeBattle({ stageId: 'act1autochess_m04', defs: { enemies: { enemy_walker: walker({ atk: 500 }) } }, enemies: [{ key: 'enemy_walker', route }], autoFinish: false, timeLimit: 120 });
|
||||||
|
h.step();
|
||||||
|
const e = h.enemy('enemy_walker');
|
||||||
|
const onTile = () => Math.round(e.y) === 11 && Math.round(e.x) === 6;
|
||||||
|
assert.ok(h.runUntil(onTile, 10), 'steps on (11,6)');
|
||||||
|
assert.ok(h.runUntil(() => !onTile(), 10), 'walks off');
|
||||||
|
assert.ok(e.findBuff('terrain:infection'), 'still carries the effect after leaving');
|
||||||
|
const hp0 = e.hp;
|
||||||
|
h.run(20);
|
||||||
|
assert.deepEqual([Math.round(e.y), Math.round(e.x)], [11, 4], 'waits off the tile');
|
||||||
|
const loss = hp0 - e.hp;
|
||||||
|
assert.ok(Math.abs(loss - 20 * bb.damage) <= bb.damage + 1e-6, `20 s after leaving: lost ${loss}, ≈ 20 × ${bb.damage}`);
|
||||||
|
approx(e.s.atk, 500 * (1 + bb.atk), 1e-9);
|
||||||
|
approx(e.s.aspd, 100 + bb.attack_speed, 1e-9);
|
||||||
|
const b = e.findBuff('terrain:infection');
|
||||||
|
assert.ok(Math.abs(b.timeLeft - (bb.duration - 20)) <= 3 * h.TICK, `${b.timeLeft} s left of ${bb.duration}`);
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('活性源石: one effect per unit — contact restarts its duration, it ends `duration` s after the last contact; the tiles never switch off', REAL, () => {
|
||||||
|
const stage = structuredClone(ds.getStage('act1autochess_m04'));
|
||||||
|
const bb = stage.special.infection.bb;
|
||||||
|
bb.duration = 10; // short, so that the end is inside the test
|
||||||
|
// the walker crosses (11,6) to (11,4), walks straight back over it to (11,8) and waits there
|
||||||
|
const route = { motion: 'WALK', start: [11, 8], end: [9, 2], checkpoints: [{ type: 'MOVE', pos: [11, 4] }, { type: 'MOVE', pos: [11, 8] }, { type: 'WAIT', time: 99 }] };
|
||||||
|
const h = makeBattle({ stage, defs: { enemies: { enemy_walker: walker({ atk: 500 }) } }, enemies: [{ key: 'enemy_walker', route }], autoFinish: false, timeLimit: 120 });
|
||||||
|
h.step();
|
||||||
|
const e = h.enemy('enemy_walker');
|
||||||
|
const onTile = () => Math.round(e.y) === 11 && Math.round(e.x) === 6;
|
||||||
|
const hp0 = e.hp;
|
||||||
|
let first = null, lastOn = null, end = null, contacts = 0, was = false, most = 0, atkMax = 0;
|
||||||
|
while (h.b.time < 40 && end == null) {
|
||||||
|
h.step();
|
||||||
|
const on = onTile();
|
||||||
|
if (on && !was) contacts++;
|
||||||
|
was = on;
|
||||||
|
const n = e.buffs.filter((x) => x.key === 'terrain:infection').length;
|
||||||
|
most = Math.max(most, n);
|
||||||
|
atkMax = Math.max(atkMax, e.s.atk);
|
||||||
|
if (on) { lastOn = h.b.time; first ??= h.b.time; }
|
||||||
|
if (first != null && n === 0) end = h.b.time;
|
||||||
|
}
|
||||||
|
assert.equal(contacts, 2, 'walked over the tile twice');
|
||||||
|
assert.equal(most, 1, 'never a second effect');
|
||||||
|
approx(atkMax, 500 * (1 + bb.atk), 1e-9, 'ATK + atk once');
|
||||||
|
assert.ok(end != null && end > first + bb.duration, `outlived its first ${bb.duration} s: the second contact restarted it (${first} → ${end})`);
|
||||||
|
assert.ok(Math.abs(end - (lastOn + bb.duration)) <= 2 * h.TICK, `ends ${bb.duration} s after the last contact (${lastOn} → ${end})`);
|
||||||
|
const loss = hp0 - e.hp;
|
||||||
|
assert.ok(Math.abs(loss - bb.damage * (end - first)) <= bb.damage + 1e-6, `${bb.damage}/s while it lasted: lost ${loss} in ${end - first} s`);
|
||||||
|
h.run(3);
|
||||||
|
assert.equal(e.hp, hp0 - loss, 'nothing after the end');
|
||||||
|
approx(e.s.atk, 500, 1e-9);
|
||||||
|
// an operator deployed on it is always in contact: it keeps draining past `duration` (no lifetime of the tiles; 3 ×
|
||||||
|
// `duration`, so that a shut-off at `duration` followed by the effect's own `duration` would still show)
|
||||||
|
const g = guard({ stats: { atk: 1000, maxHp: 1e5 } });
|
||||||
|
const h2 = makeBattle({ stage, defs: { chess: { test_guard: g } }, units: [{ chessId: 'test_guard', row: 11, col: 6 }], autoFinish: false, timeLimit: 120 });
|
||||||
|
h2.step();
|
||||||
|
h2.run(3 * bb.duration);
|
||||||
|
const u = h2.unit('test_guard');
|
||||||
|
approx(u.stats.taken, 3 * bb.duration * bb.damage, 1e-9, 'ally damage');
|
||||||
|
approx(u.s.atk, 1000 * (1 + bb.atk), 1e-9);
|
||||||
|
// moved off the tile (Battle.relocate, as 乌尔比安 S3 does), it keeps the effect for its time, then it ends
|
||||||
|
assert.ok(h2.b.relocate(u, 11, 7));
|
||||||
|
const taken0 = u.stats.taken;
|
||||||
|
h2.run(3);
|
||||||
|
assert.ok(u.findBuff('terrain:infection'), 'kept after the move');
|
||||||
|
approx(u.stats.taken - taken0, 3 * bb.damage, 1e-9, 'still draining');
|
||||||
|
h2.run(bb.duration);
|
||||||
|
assert.equal(u.findBuff('terrain:infection'), null, `gone ${bb.duration} s after the move`);
|
||||||
|
approx(u.s.atk, 1000, 1e-9);
|
||||||
|
checkInvariants(h.b);
|
||||||
|
checkInvariants(h2.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('活性源石: a 重生 clears the lasting effect — a 深池逐火战士 that crossed the tile, knocked out off it, stands up from its ember after 1 + 10 s', REAL, () => {
|
||||||
|
const key = 'enemy_1288_duskls';
|
||||||
|
const delay = ds.getEnemy(key).talent['Revive[Trigger].interval'];
|
||||||
|
// it crosses (11,6) going left, then waits on (11,4), off the tile
|
||||||
|
const route = { motion: 'WALK', start: [11, 8], end: [9, 2], checkpoints: [{ type: 'MOVE', pos: [11, 4] }, { type: 'WAIT', time: 99 }] };
|
||||||
|
const h = makeBattle({ stageId: 'act1autochess_m04', enemies: [{ key, route }], autoFinish: false, timeLimit: 120 });
|
||||||
|
h.step();
|
||||||
|
const e = h.enemy(key);
|
||||||
|
const onTile = () => Math.round(e.y) === 11 && Math.round(e.x) === 6;
|
||||||
|
assert.ok(h.runUntil(onTile, 20), 'steps on (11,6)');
|
||||||
|
assert.ok(h.runUntil(() => Math.round(e.x) === 4, 20), 'waits on (11,4)');
|
||||||
|
assert.ok(e.findBuff('terrain:infection'), 'carries the effect off the tile');
|
||||||
|
const max = e.s.maxHp;
|
||||||
|
h.b.dealDamage(null, e, { amount: e.hp + 1e6, type: 'true', canDodge: false });
|
||||||
|
assert.ok(e.alive, 'knocked out into its ember');
|
||||||
|
assert.equal(e.findBuff('terrain:infection'), null, 'the 重生 cleared it');
|
||||||
|
h.run(HUSK_REBIRTH + delay - 0.5);
|
||||||
|
assert.ok(e.alive && e.s.maxHp < max, 'still the ember');
|
||||||
|
assert.equal(e.hp, e.s.maxHp, 'no tick took one of its hits');
|
||||||
|
h.run(1);
|
||||||
|
assert.ok(e.alive);
|
||||||
|
assert.equal(e.s.maxHp, max, `stood up ${HUSK_REBIRTH} + ${delay} s after the knock-out`);
|
||||||
|
assert.equal(e.hp, max);
|
||||||
|
assert.equal(e.findBuff('terrain:infection'), null);
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
|
||||||
test('盟约寒风: every interval all enemies turn cold; a gust on cold enemies freezes them; kjeragColdWind uses live layers', REAL, () => {
|
test('盟约寒风: every interval all enemies turn cold; a gust on cold enemies freezes them; kjeragColdWind uses live layers', REAL, () => {
|
||||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [11, 8] }], autoFinish: false, timeLimit: 60 });
|
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [11, 8] }], autoFinish: false, timeLimit: 60 });
|
||||||
h.step();
|
h.step();
|
||||||
|
|||||||
@@ -2237,7 +2237,12 @@ function buildStages(ctx, modesById) {
|
|||||||
const byKey = (k) => devices.find((d) => d.key === k);
|
const byKey = (k) => devices.find((d) => d.key === k);
|
||||||
if (rows.some((l) => l.includes('m'))) {
|
if (rows.some((l) => l.includes('m'))) {
|
||||||
const d = byKey('trap_098_mire');
|
const d = byKey('trap_098_mire');
|
||||||
special.mire = { source: d ? d.key : null, intervalSec: d?.skill?.bb?.value ?? 3, aspdPerStack: d?.skill?.bb?.attack_speed ?? -0.05, moveMulPerStack: d?.skill?.bb?.move_speed ?? -0.05, maxStacks: d?.skill?.bb?.max_stack_cnt ?? 10, clearedOnLeave: true };
|
// PRTS 沼泽控制: a unit in the mire triggers 【陷入沼泽】 "每秒…一次" (the device skill's charge time, spData
|
||||||
|
// maxChargeTime 1) and an enemy "若其重量大于等于3,改为获得2层" — the skill's `value` (3) is that 重量, not an
|
||||||
|
// interval; "上述减益于单位不再位于沼泽之中时解除" (clearedOnLeave)
|
||||||
|
const mireSk = d?.skill ? ctx.skillTable[d.skill.skillId]?.levels?.[Math.max(0, (d.skill.level || 1) - 1)] : null;
|
||||||
|
const charge = mireSk?.spData?.maxChargeTime;
|
||||||
|
special.mire = { source: d ? d.key : null, intervalSec: typeof charge === 'number' && charge > 0 ? charge : 1, aspdPerStack: d?.skill?.bb?.attack_speed ?? -0.05, moveMulPerStack: d?.skill?.bb?.move_speed ?? -0.05, maxStacks: d?.skill?.bb?.max_stack_cnt ?? 10, heavyWeight: d?.skill?.bb?.value ?? 3, clearedOnLeave: true };
|
||||||
}
|
}
|
||||||
if (rows.some((l) => l.includes('d'))) {
|
if (rows.some((l) => l.includes('d'))) {
|
||||||
const d = byKey('trap_042_tidectrl');
|
const d = byKey('trap_042_tidectrl');
|
||||||
|
|||||||
Reference in new issue
Block a user