diff --git a/server/sim/content/enemies/invisible.js b/server/sim/content/enemies/invisible.js index 9e5fc7b5..2b751a94 100644 --- a/server/sim/content/enemies/invisible.js +++ b/server/sim/content/enemies/invisible.js @@ -2,6 +2,7 @@ // content/enemies.js). import { canTargetAlly, enemyStealthed } from '../../targeting.js'; +import { ALLY_COLLIDER_RADIUS } from '../../constants.js'; import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js'; import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js'; import { hypot } from '../../detmath.js'; @@ -9,7 +10,8 @@ import { hypot } from '../../detmath.js'; // --------------------------------------------------------------------------------------------------------------- // constants (numbers that exist nowhere in the data) -/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */ +/** 假想敌:骨刺's targets at once while its 隐匿 holds: 3 — the level's own description 「隐匿;隐匿状态下同时攻击3个目标。」 + * (enemy_database; the handbook line says 「数个目标」) and PRTS 天赋 「隐匿期间可同时攻击3个目标」 (GitHub #365). */ const ACBUNN_TARGETS = 3; /** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后 @@ -114,11 +116,24 @@ function kitShadowBlade(ab) { }]; } +/** + * 假想敌:骨刺 — PRTS 天赋 (级别0): 「自身普通攻击索敌不受阻挡影响」 — profile `blockFree` (as 自制投石机): blocked, and so out of its + * 隐匿, it still picks by priority among the allies in reach, not its blocker first, and a 隐匿 / 迷彩 blocker is no target + * (targeting.js canTargetAlly); 「不会攻击飞行单位」 — profile `canTarget`; 「隐匿(解除阻挡0秒后恢复)」 — stealth(); 「隐匿期间 + * 可同时攻击3个目标」: while its 隐匿 holds, the first ACBUNN_TARGETS by priority among the allies its normal attack could hit + * (ai.js attackTargets: the same reach — rangeRadius + the ally collider — and the same rules); one otherwise. Until + * 0.2.2 (PR #365 by @Sukvii, the PRTS lines checked for this port): blocker first, flyers hit, the 3 picked within the bare + * rangeRadius. + */ function kitBoneSpike() { return [stealth(), { + spawn(b, e) { + e.profile.blockFree = true; + e.profile.canTarget = (u) => !u.isFlying; + }, before(c, b, e) { - if (!enemyStealthed(e)) return; // 隐匿状态下同时攻击数个目标 (back 0 s after a block) - const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius)); + if (!enemyStealthed(e)) return; + const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius + ALLY_COLLIDER_RADIUS).filter(e.profile.canTarget)); if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS); }, }]; diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index 4e32d67a..aa945336 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -186,6 +186,67 @@ test(`${nm('enemy_9008_acbunn')}: attacks several targets at once while stealthe assert.ok(atk.targets.length >= 2, `targets ${atk.targets.length}`); }); +// 骨刺 — PRTS 天赋 (级别0) 「自身普通攻击索敌不受阻挡影响 / 不会攻击飞行单位,隐匿(解除阻挡0秒后恢复) / 隐匿期间可同时攻击3个目标」 +// and the level description 「隐匿;隐匿状态下同时攻击3个目标。」 (PR #365, the targeting part checked against PRTS). +function boneArena(o = {}) { + const h = arena({ + units: ['t_wall', 't_wall2', 't_wall3', 't_wall4'].map((chessId, i) => ({ chessId, row: 9 + (i % 2), col: 6 + Math.floor(i / 2) })), + captureNoisy: true, hooks: ['attack'], ...o, + }); + h.step(); + for (const u of h.allies()) h.b.addBuff(u, { key: 'test:noBlock', persist: true, flags: { noBlock: true } }); + const e = put(h, 'enemy_9008_acbunn', [9, 6]); + return { h, e }; +} +/** The targets of `e`'s next attack. */ +function boneNext(h, e) { + const n = h.hooksOf('attack').filter((c) => c.attacker === e).length; + assert.ok(h.runUntil(() => h.hooksOf('attack').filter((c) => c.attacker === e).length > n, 8), 'a new attack'); + return h.hooksOf('attack').filter((c) => c.attacker === e)[n].targets; +} + +test(`${nm('enemy_9008_acbunn')}: 3 targets while hidden, 1 once blocked or revealed — by priority, not its blocker first; 3 again when hidden`, () => { + for (const cause of ['block', 'reveal']) { + const { h, e } = boneArena(); + const wall = h.unit('t_wall'), taunter = h.unit('t_wall4'); + assert.equal(new Set(boneNext(h, e)).size, 3, `${cause}: hidden — three distinct targets of the four in reach`); + h.b.addBuff(taunter, { key: 'test:taunt', mods: { taunt: 5 } }); + if (cause === 'block') { h.b.removeBuff(wall, 'test:noBlock'); h.step(2); assert.equal(e.blockedBy, wall); } + else h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } }); + assert.deepEqual(boneNext(h, e), [taunter], `${cause}: one target, the taunt-5 one (索敌不受阻挡影响)`); + if (cause === 'block') { h.b.addBuff(wall, { key: 'test:noBlock', persist: true, flags: { noBlock: true } }); h.b.releaseBlocked(wall); } + else h.b.removeBuff(e, 'test:reveal'); + assert.equal(boneNext(h, e).length, 3, `${cause}: hidden again (0 s after the block) — three`); + checkInvariants(h.b); + } +}); + +test(`${nm('enemy_9008_acbunn')}: never targets a flying unit (炎佑, taunt 5), hidden or revealed`, () => { + for (const revealed of [false, true]) { + const { h, e } = boneArena({ content: 'full', extraContent: [] }); + const [air] = spawnYanyou(h.b, 'p1', { count: 1, hp: 50000 }); + assert.ok(air?.isFlying); + air.x = 6.5; air.y = 9; + h.b.applyStatus(air, 'stun', { duration: 30, force: true }); + h.b.addBuff(air, { key: 'test:taunt', mods: { taunt: 5 } }); + if (revealed) h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } }); + const t = boneNext(h, e); + assert.equal(t.length, revealed ? 1 : 3); + assert.ok(!t.includes(air), `${revealed ? 'revealed' : 'hidden'}: the flyer is no target`); + } +}); + +test(`${nm('enemy_9008_acbunn')}: its 3 hidden targets come from its normal attack's reach (2 + the 0.25 ally collider)`, () => { + const { h, e } = boneArena(); + const edge = h.unit('t_wall3'), outside = h.unit('t_wall4'); + edge.x = e.x + 2.1; edge.y = e.y; + outside.x = e.x + 2.3; outside.y = e.y; + h.b.addBuff(edge, { key: 'test:taunt', mods: { taunt: 5 } }); + const t = boneNext(h, e); + assert.ok(t.includes(edge), 'the ally 2.1 away is one of the three'); + assert.ok(!t.includes(outside), 'the one 2.3 away is out of reach'); +}); + test(`${nm('enemy_1175_dushdo_2')}: stealth; next to 深池伙友卫队精英 its attack interval drops by traitAbility.base_attack_time`, () => { const h = arena(); h.step(); diff --git a/test/golden/roster.json b/test/golden/roster.json index 022b5487..0b606e69 100644 --- a/test/golden/roster.json +++ b/test/golden/roster.json @@ -1165,7 +1165,7 @@ "coins": 12, "dmg": 1560928, "bossDmg": 0, - "heal": 29179, + "heal": 29516, "deaths": 1, "layerGains": {"lateranoShip":12,"preciShip":12,"sargonShip":26} } @@ -1174,11 +1174,11 @@ ["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4558.4,1,3,0,0,0,0,0,0], ["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3620.85,1,6,0,0,0,0,0,0], ["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3820.43,1,9,0,0,0,0,0,0], - ["p1",4,"op","chess_char_6_20_a",2,null,1523.98,1,4,0,0,0,20204,39,2], + ["p1",4,"op","chess_char_6_20_a",2,null,1523.98,1,4,0,0,0,20541,39,2], ["p1",5,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1811.04,1,7,12,169697,0,0,16,5], ["p1",6,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",4314.46,1,10,5,53428,1556,486,8,2], ["p1",7,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1968.74,1,13,4,29961,8646,173,0,4], - ["p1",8,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",0,0,15,16,204487,8432,2713,36,7], + ["p1",8,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",0,0,15,16,204487,8769,2713,36,7], ["p1",9,"op","chess_char_6_08_b",2,"uniequip_002_reed2",2054.98,1,5,3,63902,0,1900,8,1], ["p1",10,"op","chess_char_6_09_b",0,"uniequip_002_cello",1628.7,1,8,0,14472,0,0,5,5], ["p1",11,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2332.18,1,11,20,138355,1500,50,136,4], @@ -1212,12 +1212,12 @@ "enemy_1367_dseed": [2,2,0,0,9953], "enemy_1439_dslntf": [1,1,0,0,49766], "enemy_2002_bearmi_2": [1,1,0,0,18248], - "enemy_9008_acbunn": [1,1,0,2434,19907] + "enemy_9008_acbunn": [1,1,0,2771,19907] }, - "hooks": [1,145,5849,507,507,1318,1468,3393,124,130,35,31,37,24779,382,5,122,0,1,124,11,6,0], - "events": {"atk":596,"bounty":6,"deploy":21,"die":130,"dmg":1388,"engage":12,"fx":256,"heal":179,"layer":11,"skill":66,"spawn":145,"status":387}, + "hooks": [1,145,5849,507,507,1319,1469,3395,124,130,35,31,37,24779,382,5,122,0,1,124,11,6,0], + "events": {"atk":597,"bounty":6,"deploy":21,"die":130,"dmg":1389,"engage":12,"fx":256,"heal":181,"layer":11,"skill":66,"spawn":145,"status":387}, "fx": {"beam":4,"bombard":31,"bombardShell":31,"burst":6,"camouflage":2,"copy":1,"dp":4,"explode":1,"garrison":8,"lightning":85,"link":1,"lock":37,"lure":2,"scorchBurst":15,"snow":7,"split":4,"storm":7,"summon":2,"talent":1,"tide":4,"wanted":3}, - "snaps": "19eb7843 2593e6ad ced96303 58bf6c6a 57b56210 fb902b8c 2edac827 24d6e4e1 4fd0b828 109f10aa 67cb9d6c ce16ff8f a012eb0e 55ce4c3e 7158e61d d736ac42 c7169d95 af7956c1 aa3be1fe fc315fa7" + "snaps": "19eb7843 2593e6ad ced96303 58bf6c6a 57b56210 fb902b8c 2edac827 dc1e40f6 4919a6eb 503315c1 d3bf6a2b 9ca37124 7959a1b9 ab8cf469 6e9a303e 47d433a9 d5193bce 9f52967e 7a198ce1 eab6362c" }, "roster-019": { "about": "pass 0 band 7: chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_5_05_b chess_char_6_17_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b chess_char_5_02_b chess_char_5_03_b",