From d1a2696e44bac60e4e47521a90618a56d67fb641 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 18:14:12 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WB:=20a=20=E6=BA=B6=E8=A1=80?= =?UTF-8?q?=E9=AA=87=E6=83=A7=20ends=20when=20its=20=E6=89=8E=E7=BD=97=20l?= =?UTF-8?q?eaks=20or=20leaves=20the=20field=20(no=20more=20drain),=20?= =?UTF-8?q?=E7=83=B9=E6=B3=89=20/=20=E6=B2=8F=E8=99=B9's=20attack=20splash?= =?UTF-8?q?=20can=20be=20dodged=20(=E6=B3=95=E6=9C=AF=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E4=BC=A4=E5=AE=B3),=20=E9=9B=86=E5=9B=A2=E5=86=9B=E9=87=8D?= =?UTF-8?q?=E5=9E=8B=E7=81=AB=E7=82=AE's=20=E7=87=83=E7=83=A7=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F=20spares=20flying=20allies=20(=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E5=AF=B9=E7=A9=BA)=20=E2=80=94=20fixes=20from=20the=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sim/content/enemies.js | 32 +++++++++++------ test/content/enemies_bosses.test.js | 56 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index d88131af..ee62250f 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -715,9 +715,11 @@ const hitAllInRadius = (r) => ({ before(c, b, e) { const l = areaAllies(b, e, e. * `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). + * dragon — is skipped; without it "可溅射飞行单位"). `dodge`: the splash hits may be dodged (烹泉 / 沏虹, whose PRTS 天赋 calls + * them 法术普通伤害 — a normal attack's damage); a 溅射伤害 splash cannot. `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 } = {}) { +function splashAttack({ scale = 1, meleeScale = 1, unblocked = true, tiles = 0, radius = 0, noAir = false, dodge = 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) { @@ -727,7 +729,7 @@ function splashAttack({ scale = 1, meleeScale = 1, unblocked = true, tiles = 0, 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'] }); + hurt(b, e, u, e.s.atk * scale, c.dmg.type, { tags: ['splash'], canDodge: dodge }); if (onEach && u.alive) onEach(b, e, u); } }, @@ -1193,10 +1195,11 @@ function pollution(b, src, x, y, r, life, low, high) { * its area selection takes (areaAllies: no unblocking 隐匿, untargetable or sleeping ally; no airborne 起飞 one for a ground * enemy — PRTS 集团军重型火炮 【燃烧区域】 "碰撞不受迷彩制约,不可对空": 迷彩 only). A sourceless zone (`src` null: 假想敌:蚀裂's * 毒雾, an enemy's 死亡爆炸 with no 无视可选性 note on PRTS) selects as an effect with no selecting enemy: 隐匿 kept out, an - * airborne 起飞 ally reached (§21.22; until 0.1.2 it took everyone inside). + * airborne 起飞 ally reached (§21.22; until 0.1.2 it took everyone inside). `noAir`: "不可对空" — a flying ally (the 炎佑 + * dragon) is skipped too (集团军重型火炮's 【燃烧区域】, since 0.1.3). */ -function dmgZone(b, src, x, y, r, life, iv, amount, type = 'arts', kind = 'zone', el = null, elAmount = 0) { - zone(b, { x, y, r, life, iv, kind, pick: (zx, zy, zr) => areaAllies(b, src, zx, zy, zr), tick(units) { +function dmgZone(b, src, x, y, r, life, iv, amount, type = 'arts', kind = 'zone', el = null, elAmount = 0, { noAir = false } = {}) { + zone(b, { x, y, r, life, iv, kind, pick: (zx, zy, zr) => areaAllies(b, src, zx, zy, zr).filter((u) => !(noAir && u.isFlying)), tick(units) { for (const u of units) { hurt(b, src, u, amount, type, { tags: [kind] }); if (el && elAmount > 0) elem(b, src, u, el, elAmount, { tags: [kind] }); @@ -1969,10 +1972,11 @@ function kitDekght(ab) { * (无视迷彩,不可对空)" — 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]. + * radius (2) and left a 15 s steam zone re-applying the ASPD cut to whoever stood in it [ASSUMED]. The attack splash, a 法术普通 + * 伤害, can be dodged like the attack itself; the blast (法术溅射伤害) cannot. */ function kitTeapot(ab) { - return kitDeathSpawn([splashAttack({ unblocked: false, radius: TEA_SPLASH_RADIUS, noAir: true, fxKind: 'artsSplash' }), { + return kitDeathSpawn([splashAttack({ unblocked: false, radius: TEA_SPLASH_RADIUS, noAir: true, dodge: true, fxKind: 'artsSplash' }), { sil: true, death(c, b, e) { if (c.reason !== 'killed') return; @@ -2511,7 +2515,14 @@ function kitWolfLord(ab) { b.fx('beam', { x: e.x, y: e.y, from: e.id, to: u.id, kind: 'fearCage' }); 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) / ramp); battle.loseHp(unit, unit.s.maxHp * peak * k, { source: e }); } }); + onTick: ({ battle, unit, buff }) => { + // its 扎罗 gone from the field without a 重生 (leaked, removed): the effect ends, no loss this tick [ASSUMED — no + // official text; the 重生's onKo cure covers its knock-out]. Until 0.1.3 a leak left it draining for good. + const src = buff.data.src; + if (!src || !src.alive || src.removed) { battle.removeBuff(unit, buff); return; } + 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 && !caught(b) && b.time >= P.waitUntil && allTargets(b, e).some((u) => u.kind === 'op') }), doubleHit(() => P.form2), // 攻击变为远程二连击 @@ -2750,7 +2761,8 @@ export const KITS = Object.freeze({ // 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) * UACANN_ZONE_SCALE, T(ab, 'ProjectileBoomRange.attack@projectile_life_time') ?? 0, 1, T(ab, 'ProjectileBoomRange.attack@value') ?? 0, 'arts', 'burning'); + // 【燃烧区域】 "不可对空": a flying ally (the 炎佑 dragon) standing in it takes nothing (until 0.1.3 it burned) + 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', null, 0, { noAir: true }); }, }], 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 1a7e3bfb..5f8494de 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -10,6 +10,7 @@ import fs from 'node:fs'; import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; import * as enemiesMod from '../../server/sim/content/enemies.js'; import * as bossesMod from '../../server/sim/content/bosses.js'; +import { spawnYanyou } from '../../server/sim/content/tokens.js'; const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8')); const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8')); @@ -2803,3 +2804,58 @@ test(`${nm('enemy_1512_mcmstr')}: unblocked, a ranged attack (radius 2.5, ground // 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']); }); + +test(`${nm('enemy_1535_wlfmster')}: leaking in its first form ends every 溶血骇惧 it cast — no drain afterwards`, () => { + // [ASSUMED] (no official text): the buff ends once its 扎罗 is off the field without a 重生 (a leak, a removal); until + // 0.1.3 the drain went on ramping after a leak and knocked the units out (Grok review of fb3-enemies-kits) + 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 }] }); + h.step(); + const e = put(h, 'enemy_1535_wlfmster', [10, 7]); + e.profile.noAttack = true; + const fc = skb('enemy_1535_wlfmster', 'FearCage'); + h.run(fc.spCost + 3.1); + const caged = h.allies().filter((u) => u.findBuff('ab:fearCage')); + assert.equal(caged.length, fc.bb.max_target); + h.b.leak(e); + assert.ok(!e.alive); + const hp = caged.map((u) => u.hp); + h.run(3); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, 'every 溶血骇惧 ended'); + assert.deepEqual(caged.map((u) => u.hp), hp, 'no HP lost after the leak'); +}); + +test(`${nm('enemy_1203_sfhu')}: its attack splash (法术普通伤害) can be dodged; its death blast (法术溅射伤害) cannot`, () => { + // PRTS 烹泉 天赋: "普通攻击对目标对及目标周围半径1.0范围内的所有我方单位造成法术普通伤害", "死亡爆炸(…造成攻击力100%法术溅射伤害…)" + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }, { chessId: 't_wall', row: 11, col: 6 }, { chessId: 't_wall2', row: 10, col: 5 }], hooks: ['damaged', 'dodge'], captureNoisy: true }); + h.step(); + const g = h.unit('t_gun'), w = h.unit('t_wall'), dodger = h.unit('t_wall2'); + h.b.addBuff(dodger, { key: 'test:dodge', persist: true, mods: { dodgeArts: 1 } }); + const e = put(h, 'enemy_1203_sfhu', [10, 7]); + h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === e && c.dmg.isAttack && c.target === g), 10); + h.run(0.3); + approx(w.stats.taken, e.s.atk, 1e-6, 'the plain neighbour: splashed'); + assert.equal(dodger.stats.taken, 0, 'the arts-dodge neighbour dodges the splash'); + assert.ok(h.hooksOf('dodge').some((c) => c.target === dodger && c.source === e)); + h.b.addBuff(g, { key: 'test:dodge', persist: true, mods: { dodgeArts: 1 } }); + const t0 = g.stats.taken, atk = e.s.atk; + killed(h, e, null); + approx(g.stats.taken - t0, atk, 1e-6, 'the death blast is not dodged'); +}); + +test(`${nm('enemy_10122_uacann_2')}: its 燃烧区域 (不可对空) spares a flying ally standing in it`, () => { + // PRTS 集团军重型火炮: 【燃烧区域】 "…(可叠加,碰撞不受迷彩制约,不可对空)" — until 0.1.3 the 炎佑 dragon in it burned + const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 120, defs: { chess: { t_wall: WALL('t_wall') } }, kits: { t_wall: NOATK }, + units: [{ chessId: 't_wall', row: 10, col: 2 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 }); + assert.ok(y && y.isFlying, 'a flying ally'); + const e = h.spawn('enemy_10122_uacann_2', { pos: [10, 9], routeIndex: 0, mods: { speedMul: 0 } }); + const w = h.unit('t_wall'); + assert.ok(Math.hypot(y.x - w.x, y.y - w.y) <= 1.5, 'the dragon stands inside the zone around the wall'); + h.runUntil(() => e.stats.attacks >= 1, 15); + h.run(3.5); + const burns = h.hooksOf('damaged').filter((c) => c.source === e && (c.dmg.tags || []).includes('burning')); + assert.ok(burns.some((c) => c.target === w), 'the wall burns'); + assert.ok(!burns.some((c) => c.target === y), 'the dragon does not'); + assert.ok(!h.hooksOf('damaged').some((c) => c.source === e && c.target === y), 'nor is it shot or splashed'); +});