diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index b782968b..d88131af 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -92,7 +92,7 @@ // hidden tabs (shared/protocol.js fxForm). // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. -import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../constants.js'; +import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS, COLS } from '../constants.js'; import { canTargetAlly, sortAllyTargets, aggroCmp, enemyStealthed, areaSelectable, auraSelectable } from '../targeting.js'; import { mitigate, periodicDamage } from '../damage.js'; @@ -141,8 +141,9 @@ const STEALTH_RESTORE_BY_KEY = Object.freeze({ }); /** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */ const ACPUPP_AURA_RADIUS = 1.8; -/** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */ -const CROSS_REACH = 6; +/** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后 + * 向目标方向发射1支弩箭…※技能持续2.5s"). */ +const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4; /** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */ const BOMB_REACH = 1; /** 暴鸰 投弹: the bomb leaves on the OnAttack event of the cast's Attack clip (the official battle prefab's Boomb ability: @@ -175,8 +176,32 @@ const BUGS_PER_CAST = 3; const TORTURER_RADIUS = 1; /** Barrel zone radius of 咸鳞汁推荐者 [ASSUMED]. */ const BARREL_RADIUS = 1.5; -/** "短时间" DEF-down of 碎骨's grenade [ASSUMED]. */ -const SKULSR_DEFDOWN_DUR = 3; +/** 碎骨's grenade (PRTS 碎骨 天赋 "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力26%的物理伤害,并令其在5秒内 + * 防御力下降50%"; the blackboard has only defdown.def −0.5): share of ATK on every unit hit, DEF-down duration (s). + * Until 0.1.3: the full attack on the target, 100 % on the 4 neighbours, 3 s [ASSUMED]. */ +const SKULSR_GRENADE_SCALE = 0.26, SKULSR_DEFDOWN_DUR = 5; +/** Splash radius (中点判定 around the target) of 烹泉 / 沏虹's attack (PRTS 天赋 "普通攻击对目标对及目标周围半径1.0范围内的所有 + * 我方单位造成法术普通伤害(无视迷彩,不可对空)") and of 集团军重型火炮's shell (PRTS "对目标半径1.0范围内的所有我方单位造成攻击力 + * 100%的物理伤害(此弹道会强制击中主目标,碰撞无视迷彩,不可对空)"). */ +const TEA_SPLASH_RADIUS = 1, SHELL_SPLASH_RADIUS = 1; +/** 集团军重型火炮's 【燃烧区域】: PRTS "碰撞半径1.5" with the source note "1.5倍可变半径" — ×1.5 on the blackboard's + * ProjectileBoomRange.attack@projectile_range (1). Until 0.1.3 the zone took the blackboard's 1. */ +const UACANN_ZONE_SCALE = 1.5; +/** 烹泉 / 沏虹 死亡爆炸 radius (PRTS 天赋 "死亡爆炸(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】…)"; until + * 0.1.3 the attack range, 2). */ +const TEA_BOOM_RADIUS = 1.25; +/** “庞贝”'s self-blast while blocked (PRTS 天赋 "对<固定半径>半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可 + * 对空)"; the blackboard has only the damage and the 10 s; until 0.1.3: radius 1 [ASSUMED]). */ +const POMPEII_BLAST_RADIUS = 1.4; +/** “巨大的丑东西” first form: its unblocked ranged attack (PRTS 天赋 "未被阻挡时,可对半径2.5范围内的1名非飞行的我方单位进行远程攻击, + * 对目标及其周围8格内的所有我方单位造成攻击力100%的物理伤害" — the data's attack radius is 0, so it had no ranged attack until + * 0.1.3) and its self-destruct ("首次被击倒后进行持续10s的重生…:重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力 + * 150%的物理伤害和16s晕眩"; until 0.1.3: radius 2.5 at once [ASSUMED]). */ +const MCM_RANGE = 2.5, MCM_BOMB_DELAY = 2.17; +/** “复仇者” 【冲锋】 (PRTS 技能 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含 + * 自身当前所在地块)的距离自身最近的我方单位…"): the search radius. The blackboard's range_radius is 1.5; PRTS's figure is the + * one used. Until 0.1.3: any unit within 1.5, 隐匿 / 迷彩 ones included. */ +const RUSH_RADIUS = 3; /** W's C4 fuse and blast radius [ASSUMED]. */ const C4_FUSE = 1, C4_RADIUS = 1; /** Every Nth attack for "数次攻击后" abilities without an SP cost in the data [ASSUMED]; with a cost N = spCost + 1 @@ -185,13 +210,14 @@ const NTH_ATTACK = 3; export const nthOf = (s) => (s && s.sp > 0 ? s.sp + 1 : NTH_ATTACK); /** 鼠王 【唱沙】 cross reach and 【沙狱】 "及其周围" radius [ASSUMED]. */ const DRIFT_REACH = 1, SANDSTORM_RADIUS = 1; -/** 巨大的丑东西 self-destruct radius ("大范围晕眩") [ASSUMED]. */ -const MCM_BOMB_RADIUS = 2.5; +/** “巨大的丑东西” self-destruct radius (PRTS 天赋 "对半径3.0范围内所有我方单位…"; 2.5 [ASSUMED] until 0.1.3). */ +const MCM_BOMB_RADIUS = 3; /** 自在 【纬地经天】: tiles covered along the row and the column of the centre ("十字型") [ASSUMED]. */ const XI_CROSS_REACH = 2; -/** 扎罗: form-2 "远程" attack radius, 【远古威慑】 aura radius / ASPD (no numbers in the data), 溶血骇惧 HP loss per second - * reached at the end of duration_bleed (share of max HP, rising linearly — "逐渐加速失去生命值") [ASSUMED]. */ -const WOLF_RANGE = 2.5, WOLF_AWE_RADIUS = 2.5, WOLF_AWE_ASPD = -30, WOLF_BLEED_PEAK = 0.05; +/** 扎罗 (PRTS 扎罗,“狼之主” 天赋; no numbers in the data): form-2 attack radius ("进行远程攻击,普通攻击为2连击,攻击范围半径1.25"), + * 【远古威慑】 aura radius / ASPD ("自身1.5半径范围内的我方单位攻击速度-50(指定状况下生效)" — during the 重生 and in the second form). + * Until 0.1.3: 2.5 / 2.5 / −30, the aura only during the 重生 [ASSUMED]. */ +const WOLF_RANGE = 1.25, WOLF_AWE_RADIUS = 1.5, WOLF_AWE_ASPD = -50; /** 失衡 movement speed that turns 弧光锋卫's per-interval bleed into HP per tile moved [ASSUMED]. */ const UNBALANCE_SPEED = 5; /** 乌顶巨角卢鲁 【角力对决】: the operator cannot be pushed (fixed tiles) ⇒ "更多伤害" multiplier [ASSUMED]. */ @@ -680,6 +706,53 @@ const onHitStatus = (key, dur, value) => ({ dealt(c, b, e) { if (dur > 0) b.appl * 受到…伤害": areaAllies — a 迷彩 ally is hit, an unblocking 隐匿 one not; the attack itself still needs a target). */ const hitAllInRadius = (r) => ({ before(c, b, e) { const l = areaAllies(b, e, e.x, e.y, r); if (l.length) c.targets = byPriority(e, l); } }); +/** + * A normal attack that splashes around its target (PRTS: "对主目标造成…普通伤害,对溅射目标造成…溅射伤害"): 碎骨's grenade, + * “巨大的丑东西” / “墓碑”'s unblocked ranged attack, 烹泉 / 沏虹's attack, 集团军重型火炮's shell. Its mode is fixed when the + * attack starts (`before`): `unblocked` = only an attack begun while not blocked splashes (the others are the plain melee + * hit at `meleeScale`). The main target takes the engine's attack at `scale` × ATK (e.profile.atkScale, a normal attack: + * on-hit effects apply); when it lands (the main hit's 'hit', before its dodge) the others around the target take + * `scale` × ATK as a splash — an area selection (areaAllies / areaAlliesInTiles: no 隐匿 ally that does not block the + * enemy, no airborne 起飞 one for a ground enemy; 迷彩 is not checked — DESIGN §22.12). `tiles` 1 = the target's tile and + * its 8 neighbours (格子判定), else `radius` around the target (中点判定). `noAir`: "不可对空" (a flying ally — the 炎佑 + * dragon — is skipped; without it "可溅射飞行单位"). `onEach(b, e, u)` runs on every unit hit (the target after its damage). + */ +function splashAttack({ scale = 1, meleeScale = 1, unblocked = true, tiles = 0, radius = 0, noAir = false, fxKind = 'splash', onEach = null } = {}) { + return { + before(c, b, e, a) { a.splash = !unblocked || !e.blockedBy; e.profile.atkScale = a.splash ? scale : meleeScale; }, + hitOut(c, b, e, a) { + const t = c.target; + if (!a.splash || !c.dmg.isAttack || !t) return; + b.fx('explode', { x: t.x, y: t.y, r: tiles > 0 ? tiles + 0.5 : radius, kind: fxKind, ...(tiles > 0 ? { tiles: 'box' } : {}) }); + const area = tiles > 0 ? areaAlliesInTiles(b, e, t.tileR, t.tileC, 'box', tiles) : areaAllies(b, e, t.x, t.y, radius); + for (const u of area) { + if (u === t || (noAir && u.isFlying)) continue; + hurt(b, e, u, e.s.atk * scale, c.dmg.type, { tags: ['splash'] }); + if (onEach && u.alive) onEach(b, e, u); + } + }, + dealt(c, b, e, a) { if (a.splash && onEach && c.target.alive) onEach(b, e, c.target); }, + }; +} +/** + * Tile keys of an enemy's path up to its next checkpoint, its own tile included (PRTS “复仇者” 冲锋 "位于自身下个检查点前的后续 + * 路径上(包含自身当前所在地块)"): the tiles its current move leg crosses — the straight segments through the leg's smoothed + * waypoints (ai.js planLeg; the last one is the leg's checkpoint), sampled every quarter tile. + */ +function pathKeysAhead(e) { + const keys = new Set([Math.round(e.y) * COLS + Math.round(e.x)]); + const R = e.route; + if (!R || !Array.isArray(R.pts)) return keys; + let x = e.x, y = e.y; + for (let i = R.ptIdx ?? 0; i < R.pts.length; i++) { + const p = R.pts[i], n = Math.max(1, Math.ceil(Math.hypot(p.x - x, p.y - y) * 4)); + for (let k = 1; k <= n; k++) keys.add(Math.round(y + ((p.y - y) * k) / n) * COLS + Math.round(x + ((p.x - x) * k) / n)); + x = p.x; y = p.y; + } + return keys; +} +/** "不会攻击飞行单位" (an engine candidate filter, ai.js enemyAttack: the 炎佑 dragon is a flying ally). */ +const noAirTargets = () => ({ spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; } }); /** * 抵抗 (ba.buffres, the engine `resist` status: control durations halved, 麻痹 loses 1 stack every 5 s) + optional * immunities. Value from the talent's `Buff.one_minus_status_resistance` when present (−0.5 ⇒ half). @@ -1425,7 +1498,9 @@ function kitInvisShield(ab, e) { const s = ab.sk.InvisibleShield; const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2; return [...kitDeathSpawn()(ab), skill(s, (b, e2) => { - const dur = s.bb.duration ?? T(ab, 'InvisibleShield.duration') ?? 0; + // 辉光照耀 "使【范围隐匿】生效3s" (PRTS 清明 / 堂皇) = the talent's InvisibleShield.duration (3 for both); 清明's skill + // blackboard says 5 — until 0.1.3 that 5 was used + const dur = T(ab, 'InvisibleShield.duration') ?? s.bb.duration ?? 0; b.fx('telegraph', { x: e2.x, y: e2.y, r, kind: 'invisShield', id: e2.id }); // "获得隐匿(解除阻挡0秒后恢复)" (PRTS 清明 / 堂皇 天赋): the veil's 隐匿 is back as soon as a block ends for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && dur > 0) b.addBuff(o, { key: 'ab:veiled', duration: dur, flags: { stealth: true }, visible: true, data: { stealthRestore: 0 } }); @@ -1435,16 +1510,29 @@ function kitInvisShield(ab, e) { function kitCrossbow(ab) { const s = ab.sk.CrossAttack; const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && Math.hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH); + const nearest = (b, e, l) => l.sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0]; + // PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100% + // 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still + // in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release + // cancels the shot [ASSUMED]. The blackboard's duration (2) is not the skill's length PRTS gives. Until 0.1.3: revealed + // 2 s, the bolt at once. return [stealth(), skill(s, (b, e) => { - const t = aligned(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0]; + const t = nearest(b, e, aligned(b, e)); if (!t) return; const bb = s.bb; - if (bb.duration > 0) b.addBuff(e, { key: 'ab:revealed', duration: bb.duration, flags: { reveal: true } }); - b.addProjectile({ from: e, target: t, speed: 12, visual: 'enemy', source: e, onHit: (c) => { - if (!c.target || !c.target.alive) return; - hurt(b, e, c.target, e.s.atk * (bb.atk_scale ?? 1), 'arts'); - if (bb.stun > 0 && c.target.alive) b.applyStatus(c.target, 'stun', { duration: bb.stun, source: e }); - } }); + const dx = Math.sign(Math.round(t.x - e.x)), dy = Math.sign(Math.round(t.y - e.y)); + b.addBuff(e, { key: 'ab:revealed', duration: CROSS_SKILL, flags: { reveal: true } }); + b.addBuff(e, { key: 'ab:aim', duration: CROSS_SKILL, flags: { noMove: true, disarm: true } }); + b.after(CROSS_CHARGE, () => { + if (!e.alive || e.hidden || e.s.flags.stun || e.s.flags.silence) return; + const tt = nearest(b, e, aligned(b, e).filter((u) => Math.sign(Math.round(u.x - e.x)) === dx && Math.sign(Math.round(u.y - e.y)) === dy)); + if (!tt) return; + b.addProjectile({ from: e, target: tt, speed: 12, visual: 'enemy', source: e, onHit: (c) => { + if (!c.target || !c.target.alive) return; + hurt(b, e, c.target, e.s.atk * (bb.atk_scale ?? 1), 'arts'); + if (bb.stun > 0 && c.target.alive) b.applyStatus(c.target, 'stun', { duration: bb.stun, source: e }); + } }); + }, { owner: e }); }, { sil: true, cond: (b, e) => aligned(b, e).length > 0 })]; } @@ -1876,24 +1964,27 @@ function kitDekght(ab) { return partnerRage; } +/** + * 烹泉 / 沏虹 (PRTS 天赋): every normal attack hits its target and every unit within TEA_SPLASH_RADIUS of it ("法术普通伤害 + * (无视迷彩,不可对空)" — splashAttack, blocked or not); 死亡爆炸 "(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】, + * 无视迷彩,不可对空)", 【烹泉减益】 "攻击速度-40,且固定每3秒额外-0(可被抵抗,可叠加,每层持续时间和效果独立计算)" — DeadBoom. + * attack_speed / duration; one layer per blast, each its own buff. Until 0.1.3: no attack splash; the blast took the attack + * radius (2) and left a 15 s steam zone re-applying the ASPD cut to whoever stood in it [ASSUMED]. + */ function kitTeapot(ab) { - return kitDeathSpawn([{ + return kitDeathSpawn([splashAttack({ unblocked: false, radius: TEA_SPLASH_RADIUS, noAir: true, fxKind: 'artsSplash' }), { sil: true, death(c, b, e) { if (c.reason !== 'killed') return; - const aspd = T(ab, 'DeadBoom.attack_speed') ?? 0, dur = T(ab, 'DeadBoom.duration') ?? 0, iv = T(ab, 'DeadBoom.interval') ?? 1; - const r = e.base.rangeRadius || 2, atk = e.s.atk, x = e.x, y = e.y; - // an area selection (PRTS 烹泉 "死亡爆炸(…无视迷彩,不可对空)", no 无视无法选择): an airborne 起飞 ally and an - // unblocking 隐匿 one are skipped, the steam's ticks too (the blast's debuff) - const pick = (zx, zy, zr) => areaAllies(b, e, zx, zy, zr); - // 被击倒后爆炸造成范围法术伤害 (one blast of ATK ×1 [ASSUMED scale]) … + const aspd = T(ab, 'DeadBoom.attack_speed') ?? 0, dur = T(ab, 'DeadBoom.duration') ?? 0; + const r = TEA_BOOM_RADIUS, atk = e.s.atk, x = e.x, y = e.y; + // an area selection (no 无视无法选择 note): an airborne 起飞 ally and an unblocking 隐匿 one are skipped, flyers too b.fx('explode', { x, y, r, kind: 'teaBoom', id: e.id }); - for (const u of pick(x, y, r)) hurt(b, null, u, atk, 'arts', { tags: ['teaBoom'] }); - // … 使我方攻击速度大幅降低(可被抵抗): the steam lingers `duration` s; allies inside keep the ASPD cut (re-applied - // every `interval` s) - const slow = (units) => { for (const u of units) b.addBuff(u, { key: 'ab:teaBoom', duration: iv + 0.1, refresh: 'extend', mods: { aspd }, visible: true }); }; - slow(pick(x, y, r)); - if (dur > 0) zone(b, { x, y, r, life: dur, iv, kind: 'teaSteam', tick: slow, pick }); + for (const u of areaAllies(b, e, x, y, r)) { + if (u.isFlying) continue; + hurt(b, null, u, atk, 'arts', { tags: ['teaBoom'] }); + if (u.alive && dur > 0 && aspd) b.addBuff(u, { key: `ab:teaBoom:${e.id}`, duration: dur, refresh: 'replace', mods: { aspd }, visible: true }); + } }, }])(ab); } @@ -1918,25 +2009,32 @@ function kitLeaderMisc(key, ab, e) { onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: dot.dmg, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility'] }) }); }, }, { - iv: T(ab, 'rangedamage.interval') ?? 10, - tick(b, e2) { - if (!e2.blockedBy) return; // 被阻挡时会进行自爆造成高额伤害 - b.fx('explode', { x: e2.x, y: e2.y, r: 1, kind: 'selfBlast' }); - for (const u of areaAllies(b, e2, e2.x, e2.y, 1)) hurt(b, e2, u, T(ab, 'rangedamage.attack@damage') ?? 0, 'arts'); + // 被阻挡时会进行自爆 — PRTS “庞贝” 天赋: "被阻挡时,每10秒(受晕眩/无法行动/沉睡/冻结/浮空影响时暂停计时,解除阻挡时重置计时)对 + // 半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可对空)": the clock runs only while it is blocked and can + // act, and restarts at 0 on every new block (until 0.1.3: a fixed 10 s clock from its spawn, radius 1, flyers hit) + iv: 0, + tick(b, e2, a, dt) { + if (!e2.blockedBy) { a.held = 0; return; } + if (!e2.canAct) return; + a.held = (a.held || 0) + dt; + if (a.held + 1e-9 < (T(ab, 'rangedamage.interval') ?? 10)) return; + a.held = 0; + b.fx('explode', { x: e2.x, y: e2.y, r: POMPEII_BLAST_RADIUS, kind: 'selfBlast' }); + for (const u of areaAllies(b, e2, e2.x, e2.y, POMPEII_BLAST_RADIUS)) if (!u.isFlying) hurt(b, e2, u, T(ab, 'rangedamage.attack@damage') ?? 0, 'arts', { tags: ['splash'] }); }, }]; } - case 'enemy_1500_skulsr': - return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 }), { - dealt(c, b, e2) { - if (e2.blockedBy) return; // 未被阻挡时会发射榴弹 - // the grenade's splash: an area selection — a 隐匿 ally next to the target is spared (GitHub issue #32 item 6) - for (const u of targetAndArea(c.target, areaAllies(b, e2, c.target.x, c.target.y, 1))) { - if (u !== c.target) hurt(b, e2, u, e2.s.atk, 'phys'); - b.applyStatus(u, 'defDown', { duration: SKULSR_DEFDOWN_DUR, source: e2, value: -(T(ab, 'defdown.def') ?? 0) }); - } - }, - }]; + case 'enemy_1500_skulsr': { + // PRTS 碎骨 天赋: "不会攻击飞行单位"; below half HP ATK +50 %; "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力 + // 26%的物理伤害,并令其在5秒内防御力下降50%" ("榴弹对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,可溅射飞行单位") — the + // 26 % / 5 s / 3×3 are PRTS's, the −50 % is the blackboard's defdown.def. A blocked attack is its plain melee hit. The + // splash is an area selection: a 隐匿 operator next to the target that does not block it is spared (GitHub issue #32 + // item 6, DESIGN §22.12). Community report #14 (0.1.2): the grenade used to be a full attack + 100 % on 4 neighbours. + const dd = -(T(ab, 'defdown.def') ?? 0); + return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 }), noAirTargets(), + splashAttack({ scale: SKULSR_GRENADE_SCALE, tiles: 1, fxKind: 'grenade', + onEach: (b, e2, u) => { if (dd) b.applyStatus(u, 'defDown', { duration: SKULSR_DEFDOWN_DUR, source: e2, value: dd }); } })]; + } case 'enemy_1502_crowns': { const s = ab.sk.blink; return [skill(s, (b, e2) => blinkForward(b, e2, s.bb.dist ?? 1.5), { sil: true, cond: (b, e2) => !!e2.blockedBy })]; @@ -2029,9 +2127,17 @@ function kitLeaderMisc(key, ab, e) { } case 'enemy_1539_reid': { // 生命值降至一半以下时攻击力大幅度提升; 首次被击倒后重生 (Reborn.duration s), 恢复一半生命值; - // 【冲锋】 a unit within range_radius ahead ⇒ move speed ×(1+move_speed) for `duration` s + // 【冲锋】 (PRTS 技能 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含自身当前 + // 所在地块)的距离自身最近的我方单位,将其所在地块中心设置为自身下一检查点;技能使用成功后,自身移动速度+200%,持续4.5s"): a target + // it may select (targetsNear: no 隐匿 / 迷彩 / 起飞 / untargetable operator) within RUSH_RADIUS standing on its own path to + // the next checkpoint (its tile included) ⇒ move speed ×(1+move_speed) for `duration` s. It walks that path anyway, so + // the re-aim at the target's tile is not a separate move. Until 0.1.3: any unit within the blackboard's 1.5, 隐匿 / 迷彩 + // ones included, off its path too. const rush = ab.sk.Rush; - const prey = (b, e2) => targetsNear(b, e2, rush.bb.range_radius ?? 1.5, { ranged: false }).filter((u) => u.ground); + const prey = (b, e2) => { + const keys = pathKeysAhead(e2); + return targetsNear(b, e2, RUSH_RADIUS).filter((u) => u.ground && keys.has(u.tileR * COLS + u.tileC)); + }; return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'AtkUp.atk', 'atkup.atk') ?? 0 }), reborn({ dur: T(ab, 'Reborn.duration') ?? 0, hpRatio: T(ab, 'Reborn.hp_ratio') ?? 0.5, invincible: T(ab, 'Reborn.invincible') ?? 0 }), skill(rush, (b, e2) => { @@ -2082,7 +2188,10 @@ function kitLeaderMisc(key, ab, e) { // 部署费用回复速度减半,再部署时间加倍; 周期性地添加正比于自身最大生命值的伤害防护屏障. The halving is a map effect // (PRTS “墓碑” "※文字描述中的削弱实际属于地图效果,不属于敌人本身的能力"): every ally, 隐匿 / airborne ones included const s = ab.sk.refreshshield; - return [{ + // its attack — PRTS “墓碑” 天赋: "自身造成的远程途径伤害的攻击倍率降低至40%" (the blackboard's atkdown.atk_scale), "未被阻挡时会 + // 进行远程攻击,对目标及其周围八格内的所有我方单位造成物理伤害,不会攻击飞行单位" ("可溅射飞行单位"); blocked, a melee hit at + // 100 %. Until 0.1.3: a single-target hit at 100 % either way. + return [noAirTargets(), splashAttack({ scale: T(ab, 'atkdown.atk_scale') ?? 1, tiles: 1 }), { iv: 0.5, tick(b) { for (const u of b.allies()) { @@ -2184,15 +2293,23 @@ function kitUglyThing(ab) { const run = { spd: T(ab, 'bird_run.move_speed') ?? 0, def: T(ab, 'bird_run.def'), res: T(ab, 'bird_run.magic_resistance'), dmg: T(ab, 'bird_run.damage') ?? 0 }; const P = { pilot: false }; return [ - { hitOut(c, b, e) { if (!P.pilot && c.dmg.isAttack && c.target === e.blockedBy) c.dmg.amount *= rage; } }, // 近战攻击造成更高伤害 + // 远程攻击造成溅射伤害,近战攻击造成更高伤害 — PRTS 天赋: unblocked, a ranged attack on 1 non-flying unit within 2.5 hitting it + // and its 8 surrounding tiles for 100 % ATK ("可溅射飞行单位"); blocked, a melee hit at mcmstr_rage_attack.atk_scale + { spawn(b, e) { e.base.rangeRadius = Math.max(e.base.rangeRadius || 0, MCM_RANGE); e.profile.canTarget = (u) => !u.isFlying; } }, + splashAttack({ scale: 1, meleeScale: rage, tiles: 1 }), reborn({ dur: T(ab, 'reborn.duration') ?? 0, - onKo(b, e) { // 生命值降至0后自爆,造成大范围晕眩 (an area selection) - b.fx('explode', { x: e.x, y: e.y, r: MCM_BOMB_RADIUS, kind: 'mechBomb', id: e.id }); - for (const u of areaAllies(b, e, e.x, e.y, MCM_BOMB_RADIUS)) { - hurt(b, e, u, e.s.atk * (bomb.atk_scale ?? 0), 'phys'); - if (u.alive && bomb.stun > 0) b.applyStatus(u, 'stun', { duration: bomb.stun, source: e }); - } + onKo(b, e) { // 生命值降至0后自爆,造成大范围晕眩 (an area selection), 2.17 s into the 重生 + const x = e.x, y = e.y; + b.fx('telegraph', { x, y, r: MCM_BOMB_RADIUS, dur: MCM_BOMB_DELAY, kind: 'mechBomb', id: e.id }); + b.after(MCM_BOMB_DELAY, () => { + if (!e.alive || e.removed) return; + b.fx('explode', { x, y, r: MCM_BOMB_RADIUS, kind: 'mechBomb', id: e.id }); + for (const u of areaAllies(b, e, x, y, MCM_BOMB_RADIUS)) { + hurt(b, e, u, e.s.atk * (bomb.atk_scale ?? 0), 'phys'); + if (u.alive && bomb.stun > 0) b.applyStatus(u, 'stun', { duration: bomb.stun, source: e }); + } + }, { owner: e }); }, onReborn(b, e) { // 大祭司形态: 不进行攻击,无法被阻挡,每秒受到真实伤害 P.pilot = true; @@ -2342,38 +2459,61 @@ function kitMace(ab) { })]; } -/** 扎罗,“狼之主” · first form: damage taken −30 %, not stunnable, 【溶血骇惧】 (3 units: ASPD −70, accelerating HP loss, - * cured once 扎罗 loses hp_ratio of its HP); KO ⇒ 【远古威慑】 rebirth (Reborn.duration s: ASPD down around it while its HP - * refills) ⇒ second form: ranged double hits, ATK/ASPD up, invincible at first. 血债账款 / 【狂暴怒嗥】 absent. */ +/** 扎罗,“狼之主” · first form: damage taken −30 %, not stunnable, 【溶血骇惧】; KO ⇒ a Reborn.duration s 重生 (its HP refills, + * 【远古威慑】 slows the units around it) ⇒ second form: ranged double hits within WOLF_RANGE, ATK/ASPD up, invincible at first, + * 【远古威慑】 still on. 【溶血骇惧】 (PRTS 技能 "使场上最多3名我方单位攻击速度-70,获得无法撤退,逐渐流失生命(从0/秒开始线性递增,在 + * 40秒后达到最大流失速度30%最大生命值/秒),持续时间无限 … 场上存在被此技能影响的单位时狼之主获得静默 … 释放此技能时狼之主记录自身当前 + * 生命值,累计损失20%生命值后解除场上全部我方单位的技能效果"): the FearCage blackboard's attack_speed, hp_ratio (the loss rate + * reached at duration_bleed s), hp_ratio_offset (the cure); its SP (the cast's cooldown, spCost s at +1/s [ASSUMED]) stands + * still while a unit is caught (静默), each ended effect gives back sp, and the last one starts duration_wait s more of 静默 + * ("每结束1个技能效果狼之主回复5SP,场上技能效果全部结束时狼之主获得7s静默"). Until 0.1.3 the loss peaked at 5 % [ASSUMED], the buff + * ended after 40 s, hp_ratio (30 %) was read as the cure and the cast came every spCost s whatever was caught. Not modelled: + * 血债账款 / 【狂暴怒嗥】. */ function kitWolfLord(ab) { const fc = ab.sk.FearCage; - const P = { form2: false }; + const P = { form2: false, caught: 0, waitUntil: -Infinity, cast: null }; + const caught = (b) => b.allies().some((u) => u.findBuff('ab:fearCage')); const cure = (b) => { for (const u of b.allies()) { const d = u.findBuff('ab:fearCage'); if (d) b.removeBuff(u, d); } }; + // 【远古威慑】 (a buff aura, auraAllies): no 隐匿 operator that does not block it, an airborne 起飞 one still — as 深池伙友卫队's + const awe = (b, e) => { for (const u of auraAllies(b, e, e.x, e.y, WOLF_AWE_RADIUS)) auraBuff(b, u, 'ab:ancientAwe', 0.2, { aspd: WOLF_AWE_ASPD }, null, true); }; return [{ spawn(b, e, a, ab2) { ab2.immune = new Set([...(ab2.immune || []), 'stun']); // 第一形态: 不可被晕眩 b.addBuff(e, { key: 'ab:wolfGuard', persist: true, visible: true, mods: { dmgTakenMul: 1 - (T(ab, 'Passive.damage_resistance') ?? 0) } }); }, - iv: 0.25, - tick(b, e) { // afflicted units recover once 扎罗 lost hp_ratio since the cast + iv: 0.1, + tick(b, e, a, dt) { + if (P.form2) { awe(b, e); return; } // 第二形态: 【远古威慑】生效 if (!fc) return; + // afflicted units recover once 扎罗 lost hp_ratio_offset of its HP since the cast + const cut = Math.abs(fc.bb.hp_ratio_offset ?? 0.2); + let n = 0; for (const u of b.allies()) { const d = u.findBuff('ab:fearCage'); - if (d && d.data.src === e && e.hpRatio <= d.data.hp0 - (fc.bb.hp_ratio ?? 0) + 1e-9) b.removeBuff(u, d); + if (!d || d.data.src !== e) continue; + if (e.hpRatio <= d.data.hp0 - cut + 1e-9) b.removeBuff(u, d); else n++; } + if (P.cast) { + if (n < P.caught) { // ended effects (cured, knocked out): +sp each + P.cast.left -= (fc.bb.sp ?? 0) * (P.caught - n); + if (n === 0) P.waitUntil = b.time + (fc.bb.duration_wait ?? 0); + } + if (n > 0 || b.time < P.waitUntil) P.cast.left += dt; // 静默: its SP stands still + } + P.caught = n; }, }, - // 【溶血骇惧】 every spCost s (enemy SP +1/s [ASSUMED]), first form only - skill(fc, (b, e) => { + // 【溶血骇惧】 every spCost s (enemy SP +1/s [ASSUMED]), first form only, never during its 静默 + P.cast = skill(fc, (b, e) => { const l = b.rng.shuffle(allTargets(b, e).filter((u) => u.kind === 'op' && !u.findBuff('ab:fearCage'))).slice(0, fc.bb.max_target ?? 3); - const dur = fc.bb.duration_bleed ?? 0; + const ramp = Math.max(1, fc.bb.duration_bleed ?? 40), peak = fc.bb.hp_ratio ?? 0; for (const u of l) { b.fx('beam', { x: e.x, y: e.y, from: e.id, to: u.id, kind: 'fearCage' }); - b.addBuff(u, { key: 'ab:fearCage', duration: dur, refresh: 'replace', visible: true, interval: 1, data: { src: e, hp0: e.hpRatio, t0: b.time }, + b.addBuff(u, { key: 'ab:fearCage', refresh: 'replace', visible: true, interval: 1, data: { src: e, hp0: e.hpRatio, t0: b.time }, mods: { aspd: fc.bb.attack_speed ?? 0 }, - onTick: ({ battle, unit, buff }) => { const k = Math.min(1, (battle.time - buff.data.t0) / Math.max(1, dur)); battle.loseHp(unit, unit.s.maxHp * WOLF_BLEED_PEAK * k, { source: e }); } }); + onTick: ({ battle, unit, buff }) => { const k = Math.min(1, (battle.time - buff.data.t0) / ramp); battle.loseHp(unit, unit.s.maxHp * peak * k, { source: e }); } }); } - }, { cd: fc ? fc.sp || fc.cd : null, icd: fc ? fc.sp || fc.icd : null, cond: (b, e) => !P.form2 && allTargets(b, e).some((u) => u.kind === 'op') }), + }, { cd: fc ? fc.sp || fc.cd : null, icd: fc ? fc.sp || fc.icd : null, cond: (b, e) => !P.form2 && !caught(b) && b.time >= P.waitUntil && allTargets(b, e).some((u) => u.kind === 'op') }), doubleHit(() => P.form2), // 攻击变为远程二连击 reborn({ dur: T(ab, 'Reborn.duration') ?? 0, invincible: T(ab, 'Passive2.invincible_time') ?? 0, @@ -2382,11 +2522,10 @@ function kitWolfLord(ab) { b.removeBuff(e, 'ab:wolfGuard'); if (e.mem.ab.immune) e.mem.ab.immune.delete('stun'); }, - during(b, e, a, t) { // 【远古威慑】: nearby operators slowed; its HP refills slowly + during(b, e, a, t) { // 重生期间【远古威慑】生效; its HP refills slowly const dur = T(ab, 'Reborn.duration') ?? 1; e.hp = Math.max(1, e.s.maxHp * Math.min(1, t / Math.max(TICK, dur))); - // a buff aura (auraAllies): no 隐匿 operator that does not block it, an airborne 起飞 one still — as 深池伙友卫队's - for (const u of auraAllies(b, e, e.x, e.y, WOLF_AWE_RADIUS)) auraBuff(b, u, 'ab:ancientAwe', 0.2, { aspd: WOLF_AWE_ASPD }, null, true); + awe(b, e); }, onReborn(b, e) { P.form2 = true; @@ -2606,9 +2745,12 @@ export const KITS = Object.freeze({ else cloud(e.x, e.y); }, }], - enemy_10122_uacann_2: (ab) => [{ // 集团军重型火炮 · each hit leaves a burning zone + // 集团军重型火炮 · PRTS 天赋: "不会攻击飞行单位"; the shell hits every unit within 1.0 of its target for 100 % ATK ("此弹道会强制 + // 击中主目标,碰撞无视迷彩,不可对空"; no splash until 0.1.3) and each hit on the main target leaves a 3 s 【燃烧区域】 of radius + // projectile_range × UACANN_ZONE_SCALE + enemy_10122_uacann_2: (ab) => [noAirTargets(), splashAttack({ unblocked: false, radius: SHELL_SPLASH_RADIUS, noAir: true, fxKind: 'shell' }), { dealt(c, b, e) { - dmgZone(b, e, c.target.x, c.target.y, T(ab, 'ProjectileBoomRange.attack@projectile_range') ?? 1, T(ab, 'ProjectileBoomRange.attack@projectile_life_time') ?? 0, 1, T(ab, 'ProjectileBoomRange.attack@value') ?? 0, 'arts', 'burning'); + dmgZone(b, e, c.target.x, c.target.y, (T(ab, 'ProjectileBoomRange.attack@projectile_range') ?? 1) * UACANN_ZONE_SCALE, T(ab, 'ProjectileBoomRange.attack@projectile_life_time') ?? 0, 1, T(ab, 'ProjectileBoomRange.attack@value') ?? 0, 'arts', 'burning'); }, }], enemy_10054_cjhot: (ab, e) => [immuneTo('sluggish'), { // 鼎沸 · pulses arts + burn around itself; immune 停顿 diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index c50ce1ad..1a7e3bfb 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -116,16 +116,33 @@ for (const key of ['enemy_1299_ymkilr', 'enemy_1299_ymkilr_2']) { }); } -test(`${nm('enemy_1404_msnip')}: 直击 — a unit in line is shot (arts ATK×atk_scale) and stunned; the shooter reveals itself`, () => { +// PRTS 重弩突袭者 直击: "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100%的法术伤害与5s晕眩 ※技能持续2.5s"; 天赋 +// "隐匿(被阻挡,主动攻击期间均可解除)" (0.1.3: the bolt left at once and the reveal lasted the blackboard's 2 s) +test(`${nm('enemy_1404_msnip')}: 直击 — 1.4 s after the cast a unit in line is shot (arts ATK×atk_scale) and stunned; revealed and standing for the skill's 2.5 s`, () => { const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 4 }], hooks: ['statusApplied'] }); h.step(); const e = put(h, 'enemy_1404_msnip', [9, 8]); const s = skb('enemy_1404_msnip', 'CrossAttack'); - h.run(s.initCooldown + 1.5); const w = h.unit('t_wall'); + h.run(s.initCooldown + 0.1); + assert.ok(e.findBuff('ab:revealed') && e.s.flags.noMove, 'cast: revealed, standing'); + h.run(1.2); // 1.3 s into the skill: still charging + assert.equal(w.stats.taken, 0, 'no bolt before the 1.4 s charge'); + h.run(0.8); // 2.1 s in: the bolt (4 tiles at 12/s) has landed assert.equal(statuses(h, w.id, 'stun').length, 1); + approx(statuses(h, w.id, 'stun')[0].duration, s.bb.stun); approx(w.stats.taken, e.s.atk * s.bb.atk_scale * (1 - 0 / 100)); - assert.ok(e.findBuff('ab:revealed') || h.b.time > s.initCooldown + s.bb.duration); + assert.ok(e.findBuff('ab:revealed'), 'still revealed at 2.1 s'); + h.run(0.5); // 2.6 s in: the skill is over + assert.ok(!e.findBuff('ab:revealed') && !e.s.flags.noMove, 'skill over after 2.5 s'); + // stunned during the charge: no bolt + const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 4 }] }); + h2.step(); + const e2 = put(h2, 'enemy_1404_msnip', [9, 8]); + h2.run(s.initCooldown + 0.5); + h2.b.applyStatus(e2, 'stun', { duration: 1.5 }); + h2.run(2); + assert.equal(h2.unit('t_wall').stats.taken, 0, 'a stun during the charge cancels the shot'); }); test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked it counter-attacks and burns its locked target`, () => { @@ -280,30 +297,55 @@ for (const key of ['enemy_1207_sfji', 'enemy_1207_sfji_2']) { } for (const key of ['enemy_1209_sfden', 'enemy_1209_sfden_2']) { - test(`${nm(key)}: InvisibleShield veils nearby enemies (stealth) for the skill duration`, () => { + // PRTS 清明 / 堂皇 辉光照耀 "使【范围隐匿】生效3s" = the talent's InvisibleShield.duration (清明's skill blackboard says 5 — used + // until 0.1.3) + test(`${nm(key)}: InvisibleShield veils nearby enemies (stealth) for the talent's ${tb(key, 'InvisibleShield.duration')} s`, () => { const h = arena(); h.step(); put(h, key, [10, 7]); const o = put(h, 'enemy_1007_slime', [10, 6]); const s = skb(key, 'InvisibleShield'); + assert.equal(tb(key, 'InvisibleShield.duration'), 3); h.run(s.initCooldown + 0.1); assert.ok(o.s.flags.stealth); - h.run(s.bb.duration + 0.2); - assert.ok(!o.s.flags.stealth); + h.run(3 - 0.3); + assert.ok(o.s.flags.stealth, 'still veiled at 2.8 s'); + h.run(0.4); + assert.ok(!o.s.flags.stealth, 'gone after 3 s'); }); } for (const key of ['enemy_1203_sfhu', 'enemy_1203_sfhu_2']) { - test(`${nm(key)}: death spawns 4 茶器 and leaves an ASPD-down arts blast zone`, () => { - const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }] }); + // PRTS 烹泉 / 沏虹 天赋: "死亡爆炸(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】…不可对空)", 【烹泉减益】 "可叠加,每层 + // 持续时间和效果独立计算"; "普通攻击对目标对及目标周围半径1.0范围内的所有我方单位造成法术普通伤害" (0.1.3: the blast took the attack + // radius 2 and left a steam zone; the attack hit its target only) + test(`${nm(key)}: death spawns 4 茶器; the blast (r 1.25, ATK arts) gives each unit hit one ${tb(key, 'DeadBoom.duration')} s layer of ASPD ${tb(key, 'DeadBoom.attack_speed')}; its attacks splash r 1.0`, () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }, { chessId: 't_wall', row: 11, col: 8 }, { chessId: 't_wall2', row: 10, col: 5 }], hooks: ['damaged'], captureNoisy: true }); h.step(); const e = put(h, key, [10, 7]); + h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === e && c.dmg.isAttack), 10); + h.run(0.6); + const tgt = h.hooksOf('damaged').find((c) => c.source === e && c.dmg.isAttack).target; + const near = h.allies().filter((u) => u !== tgt && Math.hypot(u.x - tgt.x, u.y - tgt.y) <= 1 + 1e-6); + const splashed = h.hooksOf('damaged').filter((c) => c.source === e && !c.dmg.isAttack && c.dmg.tags.includes('splash')).map((c) => c.target); + assert.ok(near.length >= 1, 'a unit within 1.0 of the target'); + for (const u of near) assert.ok(splashed.includes(u), `${u.defId} splashed`); + for (const u of splashed) assert.ok(Math.hypot(u.x - tgt.x, u.y - tgt.y) <= 1 + 1e-6, 'only within 1.0'); + approx(h.hooksOf('damaged').find((c) => c.source === e && !c.dmg.isAttack).amount, e.s.atk, 1e-6, 'arts, 100 % (RES 0)'); + const atk = e.s.atk; + const taken0 = new Map(h.allies().map((u) => [u, u.stats.taken])); killed(h, e, null); assert.equal(alive(h, E[key].talents.bbStr['DeadSpawn.enemy_key']).length, tb(key, 'DeadSpawn.cnt')); - h.run(tb(key, 'DeadBoom.interval') + 0.1); - const g = h.unit('t_gun'); - assert.ok(g.stats.taken > 0); + const g = h.unit('t_gun'), w = h.unit('t_wall'), w2 = h.unit('t_wall2'); + approx(g.stats.taken - taken0.get(g), atk, 1e-6, '1.0 away: blasted'); + assert.equal(w2.stats.taken - taken0.get(w2), 0, '2.0 away: outside 1.25'); + assert.equal(w.stats.taken - taken0.get(w), 0, '√2 away: outside 1.25'); approx(g.s.aspd, 100 + tb(key, 'DeadBoom.attack_speed')); + assert.equal(w.s.aspd, 100); + h.run(tb(key, 'DeadBoom.duration') - 0.5); + approx(g.s.aspd, 100 + tb(key, 'DeadBoom.attack_speed'), 1e-6, 'one layer for 15 s'); + h.run(1); + assert.equal(g.s.aspd, 100); }); } @@ -1325,15 +1367,47 @@ test(`${nm('enemy_1050_lslime')}: 4 targets, burning DoT on hit, ASPD up below h assert.ok(h.eventsOf('fx').some((f) => f[1] === 'explode' && f[4].kind === 'selfBlast')); }); -test(`${nm('enemy_1500_skulsr')}: unblocked grenades splash and lower DEF; ATK up below half`, () => { - const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }], hooks: ['statusApplied'] }); +// PRTS 碎骨 天赋: "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力26%的物理伤害,并令其在5秒内防御力下降50%" ("榴弹对主 +// 目标造成物理普通伤害,对溅射目标造成物理溅射伤害") — community report #14: until 0.1.3 the target took a full attack and the 4 +// orthogonal neighbours 100 % ATK (no diagonal), DEF down for 3 s +test(`${nm('enemy_1500_skulsr')}: an unblocked attack is a grenade — 26 % ATK on the target and on its 8 surrounding tiles, DEF −50 % for 5 s; blocked a plain hit; ATK up below half`, () => { + const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 3 }, { chessId: 't_wall2', row: 12, col: 6 }, { chessId: 't_wall3', row: 11, col: 5 }], captureNoisy: true, hooks: ['statusApplied', 'damaged'] }); h.step(); const e = put(h, 'enemy_1500_skulsr', [10, 7]); h.runUntil(() => e.stats.attacks >= 1, 10); - h.run(0.5); - assert.ok(statuses(h, h.unit('t_wall2').id, 'defDown').length >= 1); + h.run(1); + const by = (u) => h.hooksOf('damaged').filter((c) => c.source === e && c.target === u); + const main = h.unit('t_wall3'), diag = h.unit('t_wall2'), far = h.unit('t_wall'); + assert.equal(by(main).length, 1); + assert.ok(by(main)[0].dmg.isAttack, 'the target: the attack itself (普通伤害)'); + approx(by(main)[0].amount, 0.26 * e.s.atk); + assert.equal(by(diag).length, 1, 'a diagonal neighbour is splashed (周围八格)'); + assert.ok(!by(diag)[0].dmg.isAttack && by(diag)[0].dmg.tags.includes('splash')); + approx(by(diag)[0].amount, 0.26 * e.s.atk); + assert.equal(by(far).length, 0, 'two tiles away: outside the 3×3'); + for (const u of [main, diag]) { + const d = statuses(h, u.id, 'defDown'); + assert.equal(d.length, 1); + approx(d[0].duration, 5); + approx(d[0].value, -tb('enemy_1500_skulsr', 'defdown.def')); + } + assert.equal(statuses(h, far.id, 'defDown').length, 0); h.b.dealDamage(null, e, { amount: e.s.maxHp * 0.6, type: 'true' }); approx(e.s.atk, E.enemy_1500_skulsr.stats.atk * (1 + tb('enemy_1500_skulsr', 'atkup.atk'))); + // blocked: its plain melee hit, no splash, no DEF down + const hb = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_wall2', row: 11, col: 7 }], captureNoisy: true, hooks: ['statusApplied', 'damaged'] }); + hb.step(); + const eb = put(hb, 'enemy_1500_skulsr', [10, 7]); + hb.step(); + eb.atkCd = 0; // (its first tick, before the block, threw a grenade) + const n0 = hb.hooksOf('damaged').length, s0 = hb.hooksOf('statusApplied').length; + hb.runUntil(() => hb.hooksOf('damaged').slice(n0).some((c) => c.source === eb && c.dmg.isAttack), 10); + hb.run(0.5); + assert.equal(eb.blockedBy, hb.unit('t_wall')); + const hits = hb.hooksOf('damaged').slice(n0).filter((c) => c.source === eb); + assert.deepEqual(hits.map((c) => [c.target.defId, c.dmg.isAttack]), [['t_wall', true]]); + approx(hits[0].amount, eb.s.atk); + assert.equal(hb.hooksOf('statusApplied').slice(s0).filter((c) => c.status === 'defDown').length, 0); }); test(`${nm('enemy_1502_crowns')}: blinks past its blocker`, () => { @@ -1432,14 +1506,24 @@ test(`${nm('enemy_1539_reid')}: ATK up below half; first KO ⇒ ${tb('enemy_1539 approx(e.s.atk, E.enemy_1539_reid.stats.atk * (1 + tb('enemy_1539_reid', 'AtkUp.atk'))); killed(h, e, null); assert.ok(!e.alive); - // 【冲锋】 - const h2 = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] }); - h2.step(); - const r = put(h2, 'enemy_1539_reid', [10, 7], { move: true }); - h2.step(2); + // 【冲锋】 (PRTS: "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含自身当前所在地块) + // 的距离自身最近的我方单位"; 0.1.3: any unit within the blackboard's 1.5, 隐匿 / 迷彩 ones too, off its path too) const rush = skb('enemy_1539_reid', 'Rush'); - assert.ok(r.findBuff('ab:rush'), 'a unit within range_radius'); + const charge = (units, status = null) => { + const hh = arena({ units }); + hh.step(); + if (status) for (const u of hh.allies()) hh.b.applyStatus(u, status, { duration: 99, source: u }); + const r = put(hh, 'enemy_1539_reid', [9, 8], { move: true }); // route 0 walks row 9 towards (9, 2) + hh.step(2); + return r; + }; + const r = charge([{ chessId: 't_wall', row: 9, col: 5 }]); // on its path, 3 tiles ahead + assert.ok(r.findBuff('ab:rush'), 'a unit on its path within 3.0'); approx(r.s.moveSpeed, E.enemy_1539_reid.stats.moveSpeed * (1 + rush.bb.move_speed)); + assert.ok(!charge([{ chessId: 't_wall', row: 10, col: 7 }]).findBuff('ab:rush'), 'a unit next to it but off its path: no charge'); + assert.ok(!charge([{ chessId: 't_wall', row: 9, col: 4 }]).findBuff('ab:rush'), '4 tiles ahead: beyond 3.0'); + assert.ok(!charge([{ chessId: 't_wall', row: 9, col: 6 }], 'stealth').findBuff('ab:rush'), 'a 隐匿 unit is no selectable target'); + assert.ok(!charge([{ chessId: 't_wall', row: 9, col: 6 }], 'camou').findBuff('ab:rush'), 'nor a 迷彩 one'); }); test(`${nm('enemy_2003_rockman')}: boulder stuns a non-stunned unit for ${skb('enemy_2003_rockman', 'StunAttack').bb.stun} s`, () => { @@ -1733,15 +1817,24 @@ test(`${nm('enemy_1512_mcmstr')}: melee ×${tb('enemy_1512_mcmstr', 'combat.atta h.step(); const e = put(h, 'enemy_1512_mcmstr', [9, 5]); e.bounty = { coins: 4, ownerPlayerId: 'p1' }; - h.runUntil(() => e.stats.attacks >= 1, 10); - approx(h.hooksOf('damaged').find((c) => c.source === e).amount, e.s.atk * tb('enemy_1512_mcmstr', 'combat.attack@mcmstr_rage_attack.atk_scale')); + h.step(); + e.atkCd = 0; // (its very first tick, before the block, was a ranged attack) + const n0 = h.hooksOf('damaged').length; + h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === e && c.dmg.isAttack), 10); + assert.equal(e.blockedBy, h.unit('t_wall')); + approx(h.hooksOf('damaged').slice(n0).find((c) => c.source === e && c.dmg.isAttack).amount, e.s.atk * tb('enemy_1512_mcmstr', 'combat.attack@mcmstr_rage_attack.atk_scale')); killed(h, e, h.unit('t_wall')); assert.ok(e.alive); assert.equal(h.result().perPlayer.p1.coins, 0, 'no bounty for the first form'); + // PRTS: "重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力150%的物理伤害和16s晕眩" (0.1.3: at once, radius 2.5) + assert.equal(statuses(h, h.unit('t_wall').id, 'stun').length, 0, 'not yet: 2.17 s into the 重生'); + h.run(2.1); + assert.equal(statuses(h, h.unit('t_wall').id, 'stun').length, 0); + h.run(0.2); const st = statuses(h, h.unit('t_wall').id, 'stun'); assert.equal(st.length, 1); approx(st[0].duration, skb('enemy_1512_mcmstr', 'bomb[reborning]').bb.stun); - h.run(tb('enemy_1512_mcmstr', 'reborn.duration') + 0.1); + h.run(tb('enemy_1512_mcmstr', 'reborn.duration') - 2.3 + 0.1); assert.ok(e.profile.noAttack && e.s.flags.unblockable); assert.equal(e.s.def, tb('enemy_1512_mcmstr', 'bird_run.def')); assert.equal(e.s.res, tb('enemy_1512_mcmstr', 'bird_run.magic_resistance')); @@ -1864,10 +1957,13 @@ test(`${nm('enemy_1525_blkswb')}: 抵抗; ignores ${tb('enemy_1525_blkswb', 'Def approx(statuses(h2, cs.id, 'disarm')[0].duration, tb('enemy_1525_blkswb', 'ClearSp.duration')); }); -test(`${nm('enemy_1535_wlfmster')}: −${tb('enemy_1535_wlfmster', 'Passive.damage_resistance') * 100} % damage and not stunnable; 溶血骇惧 (3 units, ASPD ${skb('enemy_1535_wlfmster', 'FearCage').bb.attack_speed}, rising HP loss, cured after it loses ${skb('enemy_1535_wlfmster', 'FearCage').bb.hp_ratio * 100} %); KO ⇒ ${tb('enemy_1535_wlfmster', 'Reborn.duration')} s 远古威慑 ⇒ ranged double hits`, () => { +test(`${nm('enemy_1535_wlfmster')}: −${tb('enemy_1535_wlfmster', 'Passive.damage_resistance') * 100} % damage and not stunnable; 溶血骇惧 (3 units, ASPD ${skb('enemy_1535_wlfmster', 'FearCage').bb.attack_speed}, HP loss rising to ${skb('enemy_1535_wlfmster', 'FearCage').bb.hp_ratio * 100} %/s over ${skb('enemy_1535_wlfmster', 'FearCage').bb.duration_bleed} s, no end, cured after it loses ${-skb('enemy_1535_wlfmster', 'FearCage').bb.hp_ratio_offset * 100} %); KO ⇒ ${tb('enemy_1535_wlfmster', 'Reborn.duration')} s 远古威慑 (r 1.5, −50) ⇒ ranged double hits within 1.25, 远古威慑 still on`, () => { + // PRTS 扎罗,“狼之主”: 天赋 "【远古威慑】:自身1.5半径范围内的我方单位攻击速度-50(指定状况下生效)", "重生期间【远古威慑】生效", second form + // "【远古威慑】生效 … 攻击范围半径1.25"; 溶血骇惧 "逐渐流失生命(从0/秒开始线性递增,在40秒后达到最大流失速度30%最大生命值/秒),持续时间 + // 无限 … 累计损失20%生命值后解除" (0.1.3: 2.5 / −30 only while reborning, range 2.5, a 5 % peak for 40 s, cured at 30 %) const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }, { chessId: 't_wall3', row: 12, col: 5 }, { chessId: 't_wall4', row: 9, col: 5 }], hooks: ['statusApplied'] }); h.step(); - const e = put(h, 'enemy_1535_wlfmster', [10, 7]); + const e = put(h, 'enemy_1535_wlfmster', [10, 6]); e.profile.noAttack = true; approx(e.s.dmgTakenMul, 1 - tb('enemy_1535_wlfmster', 'Passive.damage_resistance')); assert.equal(h.b.applyStatus(e, 'stun', { duration: 2 }), false); @@ -1876,18 +1972,35 @@ test(`${nm('enemy_1535_wlfmster')}: −${tb('enemy_1535_wlfmster', 'Passive.dama const caged = h.allies().filter((u) => u.findBuff('ab:fearCage')); assert.equal(caged.length, fc.bb.max_target); assert.equal(caged[0].s.aspd, 100 + fc.bb.attack_speed); - const hp0 = caged[0].hp; + const hp0 = caged[0].hp, mh = caged[0].s.maxHp; h.run(10); - assert.ok(caged[0].hp < hp0, 'losing HP'); - h.b.loseHp(e, e.s.maxHp * (fc.bb.hp_ratio + 0.01)); + // 1 tick per second at maxHp × hp_ratio × t / duration_bleed: 10 ticks ≈ 30 % × (0+1+…+10)/40 of max HP + approx((hp0 - caged[0].hp) / mh, fc.bb.hp_ratio * 55 / fc.bb.duration_bleed, 0.03, 'linear loss rate'); + assert.equal(caged[0].findBuff('ab:fearCage').duration, Infinity, '"持续时间无限"'); + e.mem.ab.list.find((a) => a.fire).left = 0; // its cooldown over: still no cast while units are caught + h.step(3); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, fc.bb.max_target, 'no new cast while units are caught (静默)'); + h.b.loseHp(e, e.s.maxHp * (-fc.bb.hp_ratio_offset - 0.02)); h.run(0.3); - assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, 'cured'); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, fc.bb.max_target, 'not yet cured at 18 %'); + h.b.loseHp(e, e.s.maxHp * 0.03); + h.run(0.3); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, 'cured at 20 %'); + h.run(fc.bb.duration_wait - 0.7); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, '"场上技能效果全部结束时狼之主获得7s静默"'); + h.run(0.6); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, fc.bb.max_target, 'cast again once the 静默 is over'); killed(h, e, null); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, '重生: every 溶血骇惧 ends'); assert.ok(e.alive && e.s.flags.untargetable); h.run(tb('enemy_1535_wlfmster', 'Reborn.duration') / 2); approx(e.hpRatio, 0.5, 0.05, 'HP refills during 远古威慑'); - assert.equal(h.unit('t_wall').s.aspd, 100 - 30, 'aura slows the units around it'); + assert.equal(h.unit('t_wall').s.aspd, 100 - 50, 'aura slows the units within 1.5'); + assert.equal(h.unit('t_wall4').s.aspd, 100 - 50, '√2 away: inside'); + assert.equal(h.unit('t_wall3').s.aspd, 100, '√5 away: outside'); h.run(tb('enemy_1535_wlfmster', 'Reborn.duration') / 2 + 0.1); + assert.equal(h.unit('t_wall').s.aspd, 100 - 50, 'second form: 远古威慑 still on'); + assert.equal(e.base.rangeRadius, 1.25, 'second-form attack radius'); approx(e.hpRatio, 1); assert.equal(e.s.dmgTakenMul, 1, 'no damage reduction any more'); assert.equal(e.profile.melee, false); @@ -2584,3 +2697,109 @@ test('determinism: the same boss round with the same seed produces the same outc assert.deepEqual(a, b); assert.equal(a[3], 0); }); + +// --------------------------------------------------------------------------------------------------------------- +// enemy numbers checked against PRTS in 0.1.3 (community report #14 and the 0.1.2 review list, DESIGN §22.12 "Found") + +/** The first normal attack of `e` that landed, its target and the other units its splash took. */ +function splashOf(h, e) { + const all = h.hooksOf('damaged').filter((c) => c.source === e); + const main = all.find((c) => c.dmg.isAttack); + return { main, target: main && main.target, splash: all.filter((c) => !c.dmg.isAttack && (c.dmg.tags || []).includes('splash')) }; +} + +test(`${nm('enemy_1050_lslime')}: the self-blast while blocked — radius 1.4, every 10 s of block (the clock restarts on a new block, stands still while stunned)`, () => { + // PRTS “庞贝” 天赋: "被阻挡时,每10秒(受晕眩/无法行动/沉睡/冻结/浮空影响时暂停计时,解除阻挡时重置计时)对半径1.4范围内的所有我方单位造成 + // 1000预计算的无途径法术溅射伤害(不可对空)" (0.1.3: radius 1, a fixed 10 s clock from its spawn) + const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_wall2', row: 10, col: 5 }, { chessId: 't_wall3', row: 10, col: 6 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const e = put(h, 'enemy_1050_lslime', [9, 5], { mods: { atkMul: 0 } }); + const blasts = () => h.hooksOf('damaged').filter((c) => c.source === e && !c.dmg.isAttack && c.dmg.tags.includes('splash')); + h.run(5); + h.b.applyStatus(e, 'stun', { duration: 2 }); // 2 s paused + h.run(6.5); // 11.5 s, 9.5 s of it counting + assert.equal(blasts().length, 0, 'the stun paused the clock'); + h.run(0.7); + const hit = blasts().map((c) => c.target.defId).sort(); + assert.deepEqual(hit, ['t_wall', 't_wall2'], 'its blocker and the unit 1.0 away; √2 is outside 1.4'); + approx(blasts()[0].amount, tb('enemy_1050_lslime', 'rangedamage.attack@damage')); + // unblocked for a moment: the clock restarts + const n = blasts().length; + h.run(4); + h.b._unblock(e); + h.unit('t_wall').s.flags.noBlock = true; + h.step(); + delete h.unit('t_wall').s.flags.noBlock; + h.run(8); + assert.equal(blasts().length, n, 'a new block: 10 s again from its start'); + h.run(2.5); + assert.ok(blasts().length > n); +}); + +test(`${nm('enemy_10122_uacann_2')}: the shell hits every unit within 1.0 of its target (100 % ATK, not flyers); the burning zone has radius 1.5`, () => { + // PRTS 集团军重型火炮 天赋: "普通攻击向目标发射一枚炮弹,对目标半径1.0范围内的所有我方单位造成攻击力100%的物理伤害(此弹道会强制击中主目标, + // 碰撞无视迷彩,不可对空)", 【燃烧区域】 "<1.5倍可变半径>碰撞半径1.5" (0.1.3: no splash, zone radius 1) + const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 3 }, { chessId: 't_wall2', row: 11, col: 3 }, { chessId: 't_wall3', row: 10, col: 4 }, { chessId: 't_wall4', row: 12, col: 3 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const e = put(h, 'enemy_10122_uacann_2', [10, 9]); + h.runUntil(() => e.stats.attacks >= 1, 10); + h.run(2); + const { target, splash } = splashOf(h, e); + assert.ok(target); + const near = h.allies().filter((u) => u !== target && Math.hypot(u.x - target.x, u.y - target.y) <= 1 + 1e-6); + assert.ok(near.length >= 1); + assert.deepEqual(splash.map((c) => c.target).filter((u, i, l) => l.indexOf(u) === i).sort((a, b) => a.id - b.id), near.sort((a, b) => a.id - b.id)); + approx(splash[0].amount, e.s.atk); + const zone = h.eventsOf('fx').find((f) => f[1] === 'zone' && f[4].kind === 'burning'); + approx(zone[4].r, tb('enemy_10122_uacann_2', 'ProjectileBoomRange.attack@projectile_range') * 1.5); +}); + +test(`${nm('enemy_2008_flking')}: unblocked, a ranged attack at ${tb('enemy_2008_flking', 'atkdown.atk_scale') * 100} % ATK on the target and its 8 surrounding tiles; blocked, a melee hit at 100 %`, () => { + // PRTS “墓碑” 天赋: "自身造成的远程途径伤害的攻击倍率降低至40%", "未被阻挡时会进行远程攻击,对目标及其周围八格内的所有我方单位造成物理伤害, + // 不会攻击飞行单位" (0.1.3: a single-target hit at 100 %) + const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const e = put(h, 'enemy_2008_flking', [10, 7]); + h.runUntil(() => e.stats.attacks >= 1, 10); + h.run(1); + const { main, target, splash } = splashOf(h, e); + const scale = tb('enemy_2008_flking', 'atkdown.atk_scale'); + approx(main.amount, e.s.atk * scale); // (the walls' DEF is 0, halved by its own aura) + const box = h.allies().filter((u) => u !== target && Math.max(Math.abs(u.tileR - target.tileR), Math.abs(u.tileC - target.tileC)) <= 1); + assert.ok(box.length >= 1); + for (const u of box) assert.ok(splash.some((c) => c.target === u), `${u.defId} in the 3×3`); + assert.ok(splash.every((c) => box.includes(c.target))); + approx(splash[0].amount, e.s.atk * scale); + const hb = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_wall2', row: 11, col: 7 }], hooks: ['damaged'], captureNoisy: true }); + hb.step(); + const eb = put(hb, 'enemy_2008_flking', [10, 7]); + hb.step(); + eb.atkCd = 0; + const n0 = hb.hooksOf('damaged').length; + hb.runUntil(() => hb.hooksOf('damaged').slice(n0).some((c) => c.source === eb && c.dmg.isAttack), 10); + hb.run(1); + const hits = hb.hooksOf('damaged').slice(n0).filter((c) => c.source === eb); + assert.deepEqual(hits.map((c) => [c.target.defId, c.dmg.isAttack]), [['t_wall', true]], 'blocked: its blocker only'); + approx(hits[0].amount, eb.s.atk); +}); + +test(`${nm('enemy_1512_mcmstr')}: unblocked, a ranged attack (radius 2.5, ground units) on the target and its 8 surrounding tiles at 100 %; the self-destruct reaches 3.0`, () => { + // PRTS “巨大的丑东西” 天赋: "未被阻挡时,可对半径2.5范围内的1名非飞行的我方单位进行远程攻击,对目标及其周围8格内的所有我方单位造成攻击力100%的 + // 物理伤害", "重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力150%的物理伤害和16s晕眩" (0.1.3: no ranged attack, radius 2.5) + const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 5 }, { chessId: 't_wall2', row: 12, col: 6 }, { chessId: 't_wall3', row: 9, col: 4 }], hooks: ['damaged', 'statusApplied'], captureNoisy: true }); + h.step(); + const e = put(h, 'enemy_1512_mcmstr', [10, 7]); + h.runUntil(() => e.stats.attacks >= 1, 10); + h.run(1); + const { main, target, splash } = splashOf(h, e); + approx(main.amount, e.s.atk); + assert.ok(Math.hypot(target.x - e.x, target.y - e.y) <= 2.5 + 0.25 + 1e-6); + const box = h.allies().filter((u) => u !== target && Math.max(Math.abs(u.tileR - target.tileR), Math.abs(u.tileC - target.tileC)) <= 1); + for (const u of box) assert.ok(splash.some((c) => c.target === u), `${u.defId} in the 3×3`); + assert.ok(splash.every((c) => box.includes(c.target))); + killed(h, e, null); + h.run(2.3); + const st = h.hooksOf('statusApplied').filter((c) => c.status === 'stun').map((c) => c.target.defId).sort(); + // t_wall (11,5): √5 ≈ 2.24, t_wall2 (12,6): √5, t_wall3 (9,4): √10 ≈ 3.16 — outside 3.0 + assert.deepEqual(st, ['t_wall', 't_wall2']); +}); diff --git a/test/sim/feedback2-stealth-splash.test.js b/test/sim/feedback2-stealth-splash.test.js index 82b662a0..79f7908f 100644 --- a/test/sim/feedback2-stealth-splash.test.js +++ b/test/sim/feedback2-stealth-splash.test.js @@ -95,7 +95,7 @@ test('areaSelectable: no 隐匿 ally unless it blocks the source, no untargetabl done(h); }); -/** 碎骨 walks lane row 9 and grenades 角峰 on (10,6); 古米 stands next to it (distance 1 = the splash radius). */ +/** 碎骨 walks lane row 9 and grenades 角峰 on (10,6); 古米 stands next to it (inside the grenade's 3×3 — 0.1.3, PRTS 碎骨). */ function grenades({ gm = [10, 7], items = [], status = null, seconds = 25 } = {}) { const h = makeBattle({ seed: 5, timeLimit: 120, autoFinish: false,