feedback2: merge WB — 突袭 lands only where it can attack a ground enemy, on both triggers (GitHub #51)

This commit is contained in:
sganggs committed 2026-10-03 23:41:35 +08:00
commit 4298815bf2
6 files changed
+160 -45

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated
{"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated
+9 -2
View File
@@ -1478,9 +1478,16 @@ TBD-22-intro
- Tests: `test/render/issue25-downback.test.js` (fake PIXI with the real manifest and asset helpers: the manifest counts; the five operators of the report lie down with their Front model's Die; an UP knock-out mid-attack — the Back model holds still, then the Front model's Die is held under the ring, greyed, every acquire paired with a release; RIGHT unchanged; the redeploy back to the Back model with the deploy clip carried over; the snapshot's DIE anim and a snapshot revive; a death and a redeploy in one frame load nothing; a view built for an operator already down and the 联防 entry lie on the held end; a withdrawn one fades like a RIGHT one; 薄绿 keeps its Back Die clip; the SpineActor safety net; enemies and summons unchanged); `downring`, `feedback1f-down-tile`, `unitview`, `forms`, `test/ui/unitFacing.test.js` unchanged.
### 22.2 突袭's idle redeploy lands where it can attack (#51)
### 22.2 突袭's idle redeploy lands where it can attack (#51) — `content/bonds/addon/battle.js raidPoll / raidTile / raidReach / raidTargets`, research 02 §3.18
TBD-22.2
- **Seen** (GitHub issue #51, on 0.1.0): a 突袭 member idle for 10 s was redeployed even when no landing tile let it attack an enemy, so it hopped every 10 s between useless tiles; and the idle trigger always went for the most advanced enemy, even when another ground enemy could have been reached. **Real** (reproduced on 0.1.1): with one speed-0 enemy that no landing tile reaches (it stands on plain floor, and a RIGHT-facing melee member reaches it only from that tile or the floor tile behind it), the member jumped at 10 / 20 / 30 / 40 s between (10,9) and (10,8) beside it and never attacked; a second, reachable enemy was ignored. On the five real runs of the F5 test (`test/sim/feedback1f-downed-tile.test.js`) 26 of 60 jumps landed with no enemy in range — e.g. three members jumping to one enemy whose two reachable tiles the first two took.
- **Official**: the bond text (activity_table bondInfoDict / effectInfoDataDict; PRTS 盟约记录, no note under it) "【突袭】干员10秒内未进行攻击或技能就绪时,若范围内没有敌人,则保留技力立即再部署至一名地面敌人周围" names no tile rule; whether an official jump can land out of range is not known (the reporter: the text "按字面允许落在不能立即攻击的位置").
- **Cause**: the covers-the-target requirement of `raidPoll` bound only the 技能就绪 trigger (`if (!idleOk && !tile[2]) continue;`), and `raidTile` scored covering tiles first but still returned a non-covering one when none covered — so the idle trigger jumped to its first candidate whatever the tile.
- **Now** (the owner's decision — the reporter's proposal and our reply on #51 「改成优先落在能打到敌人的位置」): both triggers jump only to a landing tile from which the member's attack range (its own grid + 攻击距离 rangeExtend, turned by its direction; a huge enemy counts on any tile it occupies) covers the chosen ground enemy. The poll walks the candidates in the existing order — its own field's ground enemies first, the most advanced (least remaining path) first, at most the 8 most advanced — and takes the first one that has such a tile (the nearest within 2 tiles, the facing-RIGHT tie-break as before). When none has one the member stays where it is: its idle time keeps counting and the 0.25 s poll looks again, so it jumps as soon as an enemy can be reached. After a jump the idle time restarts from the landing (then from each attack). Unchanged: never onto a knocked-out operator's tile (§21.24), never into the 深水区 (§21.3, `grid.canStand`), SP kept, a real redeployment (部署时 effects), the ATK / HP bonus.
- **Cost**: a search that finds nothing now repeats every 0.25 s, so it is cheap: `raidTile` looks only at the tiles a range offset leads back to from the enemy's body (before: a 25-tile scan building a range per tile), and a player's members share one sorted candidate list per poll (rebuilt after a jump). On the heavy 2-player boss field of `test/sim/robustness.test.js` (≈ 130 enemies, 72 on the ground) with 18 members that never reach anything — every poll searches — the bond costs +5.8 µs per tick, as much as 0.1.1's +5.5 µs with its jumps every 10 s (the requirement alone on the old scan: +29.6 µs). Whenever a covering tile existed the landing tile is the old one (200 000 random cases: grids, 攻击距离, directions, huge bodies).
- **Players notice**: a member with nothing it can reach waits on its tile instead of hopping; it passes over an unreachable leading enemy for one it can hit; when several members go for one enemy and its reachable tiles are taken, the rest wait. The five F5 runs: 35 jumps instead of 60, none out of range.
- [ASSUMED]: "never a useless landing" (the text says only 再部署至一名地面敌人周围; issue #51); the cap of 8 candidates (a member waits when the 8 most advanced ground enemies are all out of reach — the 技能就绪 path's bound since 0.1.0, kept for the cost); the target order and the 2-tile search (research 02 §3.18, now written as above). Issue #49 (a passive skill never counts as 技能就绪) is unchanged and separate: it decides when a member may jump, this section where it lands.
- Tests: `test/content/bonds_addon.test.js` "突袭 #51" ×2 (no reachable landing: neither trigger jumps in 45 s, then a reachable enemy → both jump within one poll; an unreachable leading enemy is passed over for the second, which it attacks, and the next jump comes the idle time after its last attack); the other 突袭 tests (`bonds_addon`, `feedback1-water`, `feedback1f-downed-tile`, `facing-invariance`) unchanged.
### 22.3 阿戈尔's devour skips members it has already knocked out (#33 item 1)
+1 -1
View File
@@ -8373,7 +8373,7 @@
},
"layerGain": "史尔特尔 部署时 +8 (<=50/battle); 休谟斯 each 2 kills +1; 瑕光 +4/deploy; 斯卡蒂; 伊内丝 +5; 机变 \"斯卡蒂的盟誓\" +8, \"德克萨斯的盟誓\" +10.",
"assumed": [
"target enemy = the ground enemy closest to the player's objective; landing tile = nearest free deployable tile adjacent to it",
"target enemy = the ground enemy closest to the player's objective (its own field's first) that the member can reach; landing tile = the nearest free deployable tile within 2 tiles of it from which the member's attack range covers it. No such tile for any of the 8 most advanced -> no jump; the poll looks again every 0.25 s (GitHub issue #51 - up to 0.1.1 the idle trigger landed out of reach and hopped every 10 s; DESIGN §22.2)",
"the buff lasts until the operator leaves the field"
]
},
+1 -1
View File
@@ -924,7 +924,7 @@ Members (5 chess, 4 in current shop pool; by tier in shop: {'2': 1, '3': 1, '5':
- **[L >= 50]** ALL operators ASPD +50.
- Formulas: `atkHpMultiplier = 1.25 + 0.01*L`; `idleSec = 10`; `aspdAll(L>=50) = 50`
- How layers are gained: 史尔特尔 部署时 +8 (<=50/battle); 休谟斯 each 2 kills +1; 瑕光 +4/deploy; 斯卡蒂; 伊内丝 +5; 机变 "斯卡蒂的盟誓" +8, "德克萨斯的盟誓" +10.
- [ASSUMED] target enemy = the ground enemy closest to the player's objective; landing tile = nearest free deployable tile adjacent to it
- [ASSUMED] target enemy = the ground enemy closest to the player's objective (its own field's first) that the member can reach; landing tile = the nearest free deployable tile within 2 tiles of it from which the member's attack range covers it. No such tile for any of the 8 most advanced → no jump; the poll looks again every 0.25 s (GitHub issue #51 — up to 0.1.1 the idle trigger landed out of reach and hopped every 10 s; DESIGN §22.2)
- [ASSUMED] the buff lasts until the operator leaves the field
- Bond item (with 变形同构体 grants this bond): 突袭手雷 `chess_item_3_11_e` (1 gold: 每次部署后的10秒内,攻击时使目标晕眩2秒)
- Garrisons that explicitly add layers to this bond: `garrison_38`, `garrison_64`, `garrison_74`, `garrison_77`, `garrison_107` (see section 4; plus the generic ones)
+77 -39
View File
@@ -31,14 +31,16 @@
// + 脆弱 ×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, 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; Battle.isReservedTile: never a tile a knocked-out operator
// lies on — player report F5, members landed on a fallen teammate): 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; knocked out after a jump it lies where it fell and
// comes back there (the engine's rest tile, Battle._layBody — PRTS 卫戍协议/帮助 "原地留下一个
// next to the most advanced ground enemy it can reach: on a free tile its position may be deployed
// on from which its range covers that enemy (GitHub issue #51 [ASSUMED]: the first of the 8 most
// advanced that has such a tile; none → it stays and the next poll looks again, never a jump that
// hits nothing); grid.canStand: never the 深水区 (player report #3 after 0.1.0, members dropped
// into 战场#08's pool after an enemy wading in it); Battle.isReservedTile: never a tile a knocked-out
// operator lies on (player report F5, members landed on a fallen teammate). 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; knocked out after a jump it lies where it fell
// and comes back there (the engine's rest tile, Battle._layBody — PRTS 卫戍协议/帮助 "原地留下一个
// “倒地干员”…自动部署至该位置"; its own home when it fell on another board piece's home);
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy where it lies
@@ -52,12 +54,13 @@
// Hooks registered only when a bond needs them. Priorities: `hit` −20 (坚守 redirect, after other modifiers had their
// say), `death` 10 (不屈, the bond slot of the revive/redeploy convention); everything else 0.
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { localOrder, localBefore } from '../../../dir.js';
import { canTargetEnemy, extendedGrid } from '../../../targeting.js';
import { normDir, rotateOffset, localOrder, localBefore } from '../../../dir.js';
import { bodyKeys } from '../../../body.js';
import { isHpLoss } from '../../../damage.js';
import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
fxOn, N4, N8, bodyInKeys, directMods,
fxOn, N4, N8, directMods, COLS,
} from '../../support/index.js';
export const ID = Object.freeze({
@@ -75,6 +78,7 @@ const STEAD_CD = 'bond:steadShip:cd';
const AURA_POLL = 0.25;
const RAID_POLL = 0.25;
const RAID_SEARCH = 2; // landing tiles within this Chebyshev distance of the target enemy
const RAID_TARGETS = 8; // candidate enemies a poll tries per member, the most advanced first (raidTargets)
/** 奥术 vulnerability mods for a multiplier (battle.applyStrongest). */
const arcaneMods = (v) => ({ artsTakenMul: v });
@@ -235,29 +239,59 @@ function updateAura(battle, st) {
// =====================================================================================================================
// 突袭 relocation
//
// Either trigger (技能就绪, or no attack for no_attack_duration s) jumps only to a landing tile from which the member's
// attack range covers the enemy it jumps to: the first candidate (raidTargets order, the RAID_TARGETS most advanced)
// that has one; when none has, the member stays where it is and the next poll looks again (its idle time keeps
// counting, so it jumps as soon as one can be reached). The official text only says "再部署至一名地面敌人周围" —
// "never a useless landing" is [ASSUMED] after GitHub issue #51 (up to 0.1.1 the idle trigger landed where nothing was
// in range, hopped between such tiles every 10 s and ignored a second enemy it could have reached; DESIGN §22.2).
function raidTile(battle, u, e) {
const er = Math.round(e.y), ec = Math.round(e.x);
const ranged = u.def?.position === 'RANGED';
const grid = u.rangeGrid || [[0, 0]];
const ext = num(u.s.rangeExtend);
let best = null, bs = null;
for (let dr = -RAID_SEARCH; dr <= RAID_SEARCH; dr++) {
for (let dc = -RAID_SEARCH; dc <= RAID_SEARCH; dc++) {
const r = er + dr, c = ec + dc;
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue;
if (!battle.grid.canStand(r, c, { ranged })) continue;
const covers = bodyInKeys(e, absoluteRangeKeys(grid, r, c, u.dir, ext)) ? 0 : 1;
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
// last tie-break: the offset in the unit's facing-RIGHT frame (sim/dir.js localOrder; for a RIGHT-facing unit the
// plain tile-key order), so the landing tile turns with its direction
const s0 = covers, s1 = d, s2 = localOrder(dr, dc, u.dir);
if (!bs || s0 < bs[0] || (s0 === bs[0] && (s1 < bs[1] - 1e-9 || (Math.abs(s1 - bs[1]) <= 1e-9 && localBefore(s2, bs[2]))))) { best = [r, c, covers === 0]; bs = [s0, s1, s2]; }
}
}
return best; // [row, col, targetInRange]
/**
* The member's attack range as offsets from its tile: its own grid + rangeExtend (targeting.js extendedGrid, the
* relative form of absoluteRangeKeys) turned by its direction — flat [dRow, dCol, dRow, dCol, …].
*/
function raidReach(u) {
const d = normDir(u.dir);
const out = [];
for (const [dr, dc] of extendedGrid(u.rangeGrid || [[0, 0]], num(u.s.rangeExtend))) out.push(...rotateOffset(dr, dc, d));
return out;
}
/**
* Landing tile [row, col] of a jump to enemy `e`, or null: a tile from which the member's range (`reach`, raidReach)
* covers the enemy's body (a huge enemy: any tile it occupies — body.js), within RAID_SEARCH tiles (Chebyshev) of the
* enemy, inside the field rect, that the member's position may be deployed on (grid.canStand: never the 深水区 —
* player report #3 after 0.1.0) and that is free (Battle.isReservedTile: no living unit, no knocked-out operator's
* body — player report F5 —, no waiting piece's tile). The nearest first (Chebyshev, then 0.01·Manhattan); last
* tie-break the offset in the member's facing-RIGHT frame (sim/dir.js localOrder; for a RIGHT-facing unit the plain
* tile-key order), so the landing tile turns with its direction. Only the tiles a range offset leads back to from a
* body tile are looked at, so a search that finds nothing stays cheap.
*/
function raidTile(battle, u, e, reach) {
const er = Math.round(e.y), ec = Math.round(e.x);
const ranged = u.def?.position === 'RANGED';
let best = null, bd = Infinity, bo = null;
for (const k of bodyKeys(e)) {
const br = Math.floor(k / COLS), bc = k - br * COLS;
for (let i = 0; i < reach.length; i += 2) {
const r = br - reach[i], c = bc - reach[i + 1], dr = r - er, dc = c - ec;
if (Math.abs(dr) > RAID_SEARCH || Math.abs(dc) > RAID_SEARCH) continue;
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged }) || battle.isReservedTile(r, c)) continue;
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
const o = localOrder(dr, dc, u.dir);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && localBefore(o, bo))) { best = [r, c]; bd = d; bo = o; }
}
}
return best;
}
/**
* Jump candidates of player `pid`, in priority order: the ground enemies an operator may target (not flying,
* canTargetEnemy) — those of the player's own field (`ownerId`), the others only when it has none —, the most advanced
* first (least remaining path distance, then the earliest spawned) [ASSUMED, research 02 §3.18]. The same list for
* every member of the player (canTargetEnemy reads the enemy, not the attacker).
*/
function raidTargets(battle, u, pid) {
const own = [], other = [];
for (const e of battle.enemies) {
@@ -273,6 +307,7 @@ function raidTargets(battle, u, pid) {
function raidPoll(battle, st) {
const bb = st.bb[ID.raid];
const idle = num(bb.no_attack_duration, 10);
let targets = null; // the player's candidates (raidTargets), shared by its members until a jump changes the field
for (const u of st.members[ID.raid]) {
if (!onField(u) || !u.canAct) continue;
const since = Math.max(u.lastAttackAt ?? -Infinity, u.deployedAt ?? -Infinity, u.mem[KEY.raid] ?? -Infinity);
@@ -280,19 +315,22 @@ function raidPoll(battle, st) {
const idleOk = battle.time - since >= idle - 1e-9;
if (!(ready || idleOk)) continue;
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length) continue;
const targets = raidTargets(battle, u, st.pid);
for (let i = 0; i < targets.length && i < 8; i++) {
const tile = raidTile(battle, u, targets[i]);
const list = (targets ??= raidTargets(battle, u, st.pid));
if (!list.length) continue;
// either trigger: raidTile only offers tiles with the target in range (without that a ready skill that finds no
// target would redeploy — firing every 部署时 effect — at every poll; the idle trigger, which lacked it up to
// 0.1.1, hopped every 10 s: issue #51)
const reach = raidReach(u);
for (let i = 0; i < list.length && i < RAID_TARGETS; i++) {
const tile = raidTile(battle, u, list[i], reach);
if (!tile) continue;
// the "skill ready" path has no idle gate: only jump where the target is in range, else a ready skill that never
// finds a target would redeploy (and fire every 部署时 effect) every poll
if (!idleOk && !tile[2]) continue;
targets = null; // the retreat / redeploy handlers (部署时 effects) may change the enemies: the next member re-sorts
const res = raidRedeploy(battle, u, tile[0], tile[1]);
if (!res) continue;
u.mem[KEY.raid] = battle.time;
u.mem[KEY.raid] = battle.time; // with deployedAt: the idle time starts again from the landing
if (res === 'raid') {
battle.addBuff(u, { key: KEY.raid, mods: st.raidMods });
fxOn(battle, 'blink', u, KEY.raid, 'relocate', { target: targets[i].id });
fxOn(battle, 'blink', u, KEY.raid, 'relocate', { target: list[i].id });
}
break;
}
+71 -1
View File
@@ -2,7 +2,7 @@
// 绝技 and the prep side of 助力 远见 奇迹 投资人 调和 (numbers from data/bonds.json, research 02 §3.9–§3.23).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeBattle, chessRec, enemyRec, checkInvariants, flatStage } from '../helpers/battleHarness.js';
import { gainLayers, inRange } from '../../server/sim/content/support/index.js';
import { bondBb, procChance } from '../../server/sim/content/bonds/addon/battle.js';
import { registerMeta } from '../../server/sim/content/bonds/addon.js';
@@ -342,6 +342,76 @@ test('突袭: the jump is a redeployment — deploy fires (部署时 effects), f
checkInvariants(h.b);
});
// GitHub issue #51 (reported on 0.1.0, still so in 0.1.1): the 10 s idle jump landed where no enemy was in range and
// then hopped between such tiles every 10 s, and it ignored a second enemy it could have reached. Either trigger now jumps only to a landing tile
// with its target in range — the first candidate that has one — else the member stays and the next poll looks again
// [ASSUMED: the text says only "再部署至一名地面敌人周围"]. Row 9 here: cols 8–9 plain floor (not deployable); a
// RIGHT-facing melee member (its own tile + the tile in front) reaches a speed-0 enemy on (9,9) only from (9,9) or
// (9,8) — neither deployable.
const RAID51_STAGE = flatStage({ rows: { 9: '##Errrrrff' + 'S' + 'rrrrrrr' + 'S##' } });
const RAID51_ENEMY = { enemy_raid51: enemyRec({ key: 'enemy_raid51', hp: 1e7, speed: 0 }) };
const raidJumps = (h) => h.hooksOf('deploy').filter((c) => c.unit.kind === 'op' && !c.initial); // (an enemy spawn is a deploy too)
test('突袭 #51: no landing tile reaches the enemy → neither trigger jumps (no hopping); once one can, the jump comes at once', () => {
const defs = {
chess: { r_m: op('r_m', ['raidShip']), r_s: chessRec({ id: 'r_s', bonds: ['raidShip'], skill: { spCost: 10, initSp: 10 } }) },
enemies: RAID51_ENEMY,
};
const h = makeBattle({
stage: RAID51_STAGE, defs, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_raid51', pos: [9, 9] }],
units: [{ chessId: 'r_m', row: 12, col: 3 }, { chessId: 'r_s', row: 11, col: 3 }], hooks: ['deploy', 'death'],
autoFinish: false, timeLimit: 120,
});
const m = h.unit('r_m'), s = h.unit('r_s');
h.run(45);
assert.equal(m.lastAttackAt, -Infinity, 'r_m idle the whole time (the 10 s trigger, four times over)');
assert.ok(s.skill.ready, 'r_s: its skill stays ready the whole time (the 技能就绪 trigger)');
assert.deepEqual(raidJumps(h), [], 'no jump: no landing tile has the enemy in range');
assert.deepEqual(h.hooksOf('death').filter((c) => c.reason === 'raid'), [], 'no 突袭 retreat');
assert.deepEqual([m.tileR, m.tileC, s.tileR, s.tileC], [12, 3, 11, 3], 'both stay where they are');
// a ground enemy that can be reached: both jump at the next poll (the idle time kept counting) and land with it in range
const t0 = h.b.time;
const e2 = h.spawn('enemy_raid51', { pos: [12, 7] });
h.run(0.3);
assert.equal(raidJumps(h).length, 2, 'both members jumped');
for (const c of raidJumps(h)) assert.ok(c.t - t0 <= 0.25 + 1e-6, `within one poll (${(c.t - t0).toFixed(2)} s)`);
for (const u of [m, s]) {
assert.ok(inRange(u, e2), `${u.defId}: the new enemy in range after the jump (${u.tileR},${u.tileC})`);
assert.ok(Math.max(Math.abs(u.tileR - 12), Math.abs(u.tileC - 7)) <= 2, `${u.defId}: next to it`);
}
checkInvariants(h.b);
});
test('突袭 #51: the most advanced enemy out of reach → the jump goes to the next one it can reach and fights there; idle time restarts', () => {
const defs = { chess: { r_m: op('r_m', ['raidShip']) }, enemies: RAID51_ENEMY };
const idle = bondBb('raidShip').no_attack_duration;
const h = makeBattle({
stage: RAID51_STAGE, defs, bonds: { raidShip: bond(1, 10) }, hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 120,
enemies: [{ key: 'enemy_raid51', pos: [9, 9] }, { key: 'enemy_raid51', pos: [12, 9] }], units: [{ chessId: 'r_m', row: 12, col: 3 }],
});
h.step();
const [e1, e2] = h.enemies();
assert.deepEqual([e1.y, e1.x, e2.y, e2.x], [9, 9, 12, 9]);
assert.ok(h.b.remainingDistance(e1) < h.b.remainingDistance(e2), 'the one out of reach is the more advanced (first candidate)');
const u = h.unit('r_m');
assert.ok(h.runUntil(() => raidJumps(h).length > 0, idle + 1), 'jumped after the idle time');
const t1 = h.b.time;
assert.ok(inRange(u, e2) && !inRange(u, e1), `landed on ${u.tileR},${u.tileC} with the second enemy in range`);
h.run(5);
assert.ok(u.lastAttackAt > t1 && e2.hp < e2.s.maxHp, 'it attacks the enemy it jumped to');
assert.equal(raidJumps(h).length, 1, 'busy there: no further jump');
// its enemy gone, a reachable one elsewhere: the next jump comes the idle time after its last attack, not at once
h.b.dealDamage(null, e2, { amount: 1e9, type: 'true' });
const last = u.lastAttackAt;
const e3 = h.spawn('enemy_raid51', { pos: [10, 5] });
assert.ok(!inRange(u, e3));
assert.ok(h.runUntil(() => raidJumps(h).length === 2, idle + 1), 'jumped again');
const dt = h.b.time - last;
assert.ok(dt >= idle - 1e-6 && dt <= idle + 0.25 + 1e-6, `${dt.toFixed(2)} s after its last attack`);
assert.ok(inRange(u, e3), 'the new enemy in range');
checkInvariants(h.b);
});
test('不屈: knocked-out ground operator redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / elevated → no', () => {
const bb = bondBb('indomShip');
const sk = { spCost: 50, initSp: 0 };