diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 3a4ac4f0..e85c2726 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -28,7 +28,7 @@ import { Grid } from './grid.js'; import { Unit } from './units.js'; import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js'; import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView, leaderHitCancelled } from './damage.js'; -import { absoluteRangeKeys, canTargetEnemy } from './targeting.js'; +import { absoluteRangeKeys, canTargetEnemy, evadesGround } from './targeting.js'; import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js'; import { normDir, mirrorDir, localOrder, localBefore } from './dir.js'; import { ProjectileSystem } from './projectiles.js'; @@ -1117,7 +1117,8 @@ export class Battle { _blockerFor(u, e, w) { if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false; if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false; - if (!e.isFlying && !u.ground) return false; + // ground enemies: only a unit on a ground tile that has not taken off (起飞 "不阻挡地面敌人": flag `liftoff`) + if (!e.isFlying && (!u.ground || u.s.flags.liftoff)) return false; const cap = u.s.blockCnt; if (cap <= 0) return false; let used = 0; @@ -1273,10 +1274,12 @@ export class Battle { * ([r, c] or {x, y}; default the source's tile — a new application moves the point); 恐惧 (`fear`) stamps where it * was applied and from where (fear.js stampFear: the fan of 恐惧可达地块 its movement uses). A stunned/sleeping operator * releases the enemies it blocks; a feared/levitated/unblockable/attracted enemy is released by its blocker. - * `statusApplied` reports the final duration and `entered` (the target did not carry the status before). + * A ground enemy's status never lands on an airborne 起飞 ally (对地规避: targeting.js evadesGround) unless + * `opts.ignoreSelect`. `statusApplied` reports the final duration and `entered` (the target did not carry the status before). */ applyStatus(target, key, opts = {}) { if (!target || !target.alive) return false; + if (!opts.ignoreSelect && target.s.flags.liftoff && evadesGround(opts.source, target)) return false; const tpl = STATUS[key] || { flags: { [key]: true } }; let duration = opts.duration == null ? Infinity : Number(opts.duration); let value = opts.value; diff --git a/server/sim/buffs.js b/server/sim/buffs.js index ee3b86e5..a27d0e4c 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -28,11 +28,15 @@ export const MUL_KEYS = Object.freeze([ 'trueTakenMul', 'elemTakenMul', 'elementalTakenMul', 'healingDealtMul', 'healingTakenMul', 'spRecoveryMul', 'redeployMul', 'atkScaleMul', 'physDealtMul', 'artsDealtMul', ]); -/** Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. */ +/** + * Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. `liftoff` = 起飞 of an ally (蒂比's skills): blocks + * no ground enemy (Battle._blockerFor), 对地规避 against ground enemies (targeting.js evadesGround); it stays a ground + * unit on its tile (`unit.ground` unchanged). + */ export const FLAG_KEYS = Object.freeze([ 'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear', 'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden', - 'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace', 'isolated', 'camou', + 'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace', 'isolated', 'camou', 'liftoff', ]); /** diff --git a/server/sim/content/bosses.js b/server/sim/content/bosses.js index 9b2a5b37..acaec640 100644 --- a/server/sim/content/bosses.js +++ b/server/sim/content/bosses.js @@ -58,7 +58,7 @@ // 'sword' 'vest' 'blink' 'summon' 'grow' 'phase' 'lpLoss' (x, y + extra {id, r, tiles, kind, tx, ty …}). import { MOVE_SCALE } from '../constants.js'; -import { canTargetAlly, aggroCmp } from '../targeting.js'; +import { canTargetAlly, aggroCmp, evadesGround } from '../targeting.js'; import { compileRoute } from '../ai.js'; import { normalizeRoute } from '../simdata.js'; import { @@ -687,7 +687,7 @@ function kitSpring(ab, e) { b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' }); elem(b, e2, t, 'erosion', e2.s.atk * ratio * Math.pow(SPRING_BOUNCE_FALLOFF, k)); const prev = t; - t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0]; + t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u) && !evadesGround(e2, u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0]; } } else { // 十连击 b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' }); diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 50ddc09d..bc846fb4 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -28,7 +28,10 @@ // keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the // enemy's own target rule (只攻击地面单位 …), applied by the engine to the candidates before its priority order; a // special priority (优先攻击防御力最高的… / 生命上限最高的…) sorts by its key, ties by taunt then latest deployed -// (targeting.js aggroCmp — PRTS 索敌: 特殊优先级 → 仇恨值). +// (targeting.js aggroCmp — PRTS 索敌: 特殊优先级 → 仇恨值). An airborne ally (起飞, flag `liftoff`) is no target of a +// ground enemy (对地规避, targeting.js evadesGround): targetsNear / allTargets skip it through canTargetAlly, the picks +// that bypass it (周围四格 additions, chain and bounce jumps) filter it, and the engine refuses a ground enemy's damage +// and statuses on it; damage zones "无视无法选择" (`ignoreSelect`) still reach it. // // Special types (factions.json): // FLY — engine (FLY motion, ranged-only targeting). Flyer kits below (御4, 护障, 寒霜, 萨科塔之翼/眼, 黑云 …). @@ -38,7 +41,8 @@ // create those units (death spawns, embers, blades, 再生). // ELEMENT — element damage on hit = ATK × ep_damage_ratio into the ally's gauge. 侵蚀 (erosion) is an engine // gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害"). -// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域), +// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域 — +// they ignore 无法选择, PRTS 污染秽蚀 "可对空,无视无法选择": `ignoreSelect`, 起飞 allies included), // bleeding (removed by healing), pulsing auras. // INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed; AoE still hits). // REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced @@ -56,7 +60,7 @@ // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE } from '../constants.js'; -import { canTargetAlly, sortAllyTargets, aggroCmp } from '../targeting.js'; +import { canTargetAlly, sortAllyTargets, aggroCmp, evadesGround } from '../targeting.js'; import { mitigate } from '../damage.js'; // --------------------------------------------------------------------------------------------------------------- @@ -330,16 +334,19 @@ function onTick(b, dt) { // helpers (exported for bosses.js) /** Element damage (erosion mapped onto the engine gauge). */ -export function elem(b, src, tgt, el, amount) { +export function elem(b, src, tgt, el, amount, { ignoreSelect = false } = {}) { if (!tgt || !tgt.alive || !(amount > 0)) return 0; const element = el === 'erosion' ? EROSION : el; - return b.dealDamage(src, tgt, { type: 'element', element, amount, tags: ['enemyAbility'] }); + return b.dealDamage(src, tgt, { type: 'element', element, amount, ignoreSelect, tags: ['enemyAbility'] }); } -/** Skill / ability damage (no dodge unless asked). */ -export function hurt(b, src, tgt, amount, type = 'phys', { canDodge = false, tags = [], isSkill = true } = {}) { +/** + * Skill / ability damage (no dodge unless asked). `ignoreSelect` = the ability "无视无法选择" (zones): it also reaches an + * airborne 起飞 ally, which a ground enemy's damage otherwise skips (damage.js, targeting.js evadesGround). + */ +export function hurt(b, src, tgt, amount, type = 'phys', { canDodge = false, tags = [], isSkill = true, ignoreSelect = false } = {}) { if (!tgt || !tgt.alive || !(amount > 0)) return 0; - return b.dealDamage(src, tgt, { amount, type, canDodge, isSkill, tags: ['enemyAbility', ...tags] }); + return b.dealDamage(src, tgt, { amount, type, canDodge, isSkill, ignoreSelect, tags: ['enemyAbility', ...tags] }); } /** Allies whose tile is within `n` of (r, c): 'plus' = Manhattan (周围四格), 'box' = Chebyshev (周围8格). */ @@ -895,8 +902,8 @@ function bleed(ab) { /** * 【污染秽蚀】 zone (PRTS 萨卡兹枯朽战士 / 萨卡兹枯朽战车: "范围内位于低地/高地的我方干员和召唤物每秒受到50/25点真实普通伤害 * (可对空,无视无法选择、迷彩;同名效果不叠加)"): `low` true damage per second on low ground, `high` on high ground, to every - * ally inside (flyers, stealthed and untargetable ones included); a unit inside several zones takes one tick per second - * (`mem.pollutedAt`: the last tick it took). + * ally inside (flyers, stealthed, untargetable and airborne 起飞 ones included — `ignoreSelect`; an airborne 蒂比 stands on + * her low tile: `low`); a unit inside several zones takes one tick per second (`mem.pollutedAt`: the last tick it took). */ function pollution(b, src, x, y, r, life, low, high) { zone(b, { x, y, r, life, iv: POLLUTION_INTERVAL, kind: 'pollution', tick(units) { @@ -904,15 +911,21 @@ function pollution(b, src, x, y, r, life, low, high) { const v = u.ground ? low : high; if (!(v > 0) || b.time - (u.mem.pollutedAt ?? -Infinity) < POLLUTION_INTERVAL - 1e-6) continue; u.mem.pollutedAt = b.time; - hurt(b, src, u, v, 'true', { tags: ['pollution'] }); + hurt(b, src, u, v, 'true', { tags: ['pollution'], ignoreSelect: true }); } } }); } -/** Damage zone (arts per tick). */ +/** + * Damage zone (arts per tick) — on every ally inside, like 【污染秽蚀】 (`ignoreSelect`: 起飞 allies too) [ASSUMED for the + * zones PRTS gives no 无法选择 note: 毒雾, 燃烧区域]. + */ 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, tick(units) { - for (const u of units) { hurt(b, src, u, amount, type); if (el && elAmount > 0) elem(b, src, u, el, elAmount); } + for (const u of units) { + hurt(b, src, u, amount, type, { ignoreSelect: true }); + if (el && elAmount > 0) elem(b, src, u, el, elAmount, { ignoreSelect: true }); + } } }); } @@ -1057,7 +1070,7 @@ function kitTidmag(ab) { const t0 = c.targets[0]; if (!t0) return; const l = c.targets.slice(); - for (const u of alliesInTiles(b, t0.tileR, t0.tileC, 'plus', 1)) if (!l.includes(u)) l.push(u); + for (const u of alliesInTiles(b, t0.tileR, t0.tileC, 'plus', 1)) if (!l.includes(u) && !evadesGround(e, u)) l.push(u); c.targets = l; }, }]; @@ -1673,7 +1686,7 @@ function kitLeaderMisc(key, ab, e) { case 'enemy_1513_dekght': { // 攻击时使目标与周围四格的单位受到物理伤害; 【蓄力攻击】 charge `duration` s, then ATK×atk_scale on the target's cross const s = ab.sk.ChargeAttack; - return [kitDekght(ab), { before(c, b, e2) { const t = c.targets[0]; if (t) for (const u of alliesInTiles(b, t.tileR, t.tileC, 'plus', 1)) if (!c.targets.includes(u)) c.targets.push(u); } }, + return [kitDekght(ab), { before(c, b, e2) { const t = c.targets[0]; if (t) for (const u of alliesInTiles(b, t.tileR, t.tileC, 'plus', 1)) if (!c.targets.includes(u) && !evadesGround(e2, u)) c.targets.push(u); } }, skill(s, (b, e2) => { const t = e2.blockedBy; const dur = s.bb.duration ?? 0, r = t.tileR, cc = t.tileC; @@ -1789,7 +1802,7 @@ function kitLeaderMisc(key, ab, e) { let prev = c.target; const hit = new Set([prev]); for (let k = 1; k < n; k++) { - const nx = b.alliesInRadius(prev.x, prev.y, jr).find((u) => !hit.has(u)); + const nx = b.alliesInRadius(prev.x, prev.y, jr).find((u) => !hit.has(u) && !evadesGround(e2, u)); if (!nx) break; hit.add(nx); hurt(b, e2, nx, e2.s.atk * Math.pow(fall, k), 'arts'); @@ -2320,7 +2333,7 @@ export const KITS = Object.freeze({ b.fx('telegraph', { x, y, r: 1, dur: CANNON_SHOTS, kind: 'cannon' }); for (let i = 1; i <= CANNON_SHOTS; i++) b.after(i, () => { if (!e.alive) return; - const t = b.alliesInRadius(x, y, 1).sort((p, q) => q.hpRatio - p.hpRatio)[0]; + const t = b.alliesInRadius(x, y, 1).filter((u) => !evadesGround(e, u)).sort((p, q) => q.hpRatio - p.hpRatio)[0]; if (t) { b.fx('explode', { x: t.x, y: t.y, r: 0.5, kind: 'cannon' }); hurt(b, e, t, e.s.atk * (ab.sk.Cannon.bb.atk_scale ?? 0), 'arts'); } }, { owner: e }); }, { cond: (b, e) => allTargets(b, e).length > 0 })], diff --git a/server/sim/content/kits/tier2.js b/server/sim/content/kits/tier2.js index 964a04b7..9e140968 100644 --- a/server/sim/content/kits/tier2.js +++ b/server/sim/content/kits/tier2.js @@ -35,17 +35,20 @@ function onDefaultSkill(chess) { return !d || !chess?.skill || d.skillId === chess.skill.skillId; } -/** 蒂比 take-off (起飞): airborne — blocks flyers only (flags.blockFly of the skill), releases ground enemies. */ +/** + * 起飞 of 蒂比's skills (gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人"): the skill's flags + * `liftoff` (no ground enemy blocked — Battle._blockerFor; 对地规避 — targeting.js evadesGround: no ground enemy selects or + * damages her) and `blockFly` (blocks flyers at the air radius). She stays a ground unit on her tile (PRTS 行动方式 "起飞的 + * 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰, 地面干员 bonds / items still count her). + */ +const LIFTOFF_FLAGS = Object.freeze({ blockFly: true, liftoff: true }); +/** 蒂比 take-off: the ground enemies she blocked walk on. */ function tippiTakeOff({ battle, unit }) { - unit.mem.tippiGround = unit.ground; - unit.ground = false; battle.releaseBlocked(unit); battle.fx('takeoff', { x: unit.x, y: unit.y, id: unit.id }); } -/** 蒂比 landing when the airborne skill ends. */ +/** 蒂比 landing when the airborne skill ends: the flyers she held are released. */ function tippiLand({ battle, unit }) { - if (unit.mem.tippiGround != null) unit.ground = unit.mem.tippiGround; - unit.mem.tippiGround = null; battle.releaseBlocked(unit); } @@ -594,11 +597,12 @@ export default { // --------------------------------------------------------------------------------------------------------------- // 2_13 蒂比 紧急赶场通知 (AUTO): "受到攻击后触发" — any incoming enemy attack sets it off (the kit is the only // trigger: the engine rule is disabled so non-attack damage never fires it) and a physical/arts one is dodged; takes - // off for the duration: skill range, ATK +atk, attacks become 3 shots, blocks flying (not ground) enemies. Trait - // "起飞后能够阻挡2个飞行敌人": flying enemies are blocked only while airborne. 片场工作指南: if not attacked for - // stack_time s, the next physical/arts attack is dodged (prob); every attack restarts that timer. - // S1 专业喷绘技巧 (alt, DEFAULT trigger from data): takes off at once for its duration — skill range, ATK +atk, blocks - // flyers only; no triple shot, and incoming attacks never set it off. + // off for the duration (起飞, LIFTOFF_FLAGS: she blocks no ground enemy and none attacks or damages her): skill range, + // ATK +atk, attacks become 3 shots, blocks flying (not ground) enemies. Trait "起飞后能够阻挡2个飞行敌人": flying enemies + // are blocked only while airborne. 片场工作指南: if not attacked for stack_time s, the next physical/arts attack is + // dodged (prob); every attack restarts that timer. + // S1 专业喷绘技巧 (alt, DEFAULT trigger from data): takes off at once for its duration (起飞 as above) — skill range, + // ATK +atk, blocks flyers only; no triple shot, and incoming attacks never set it off. chess_char_2_13_a: (bb, chess, def) => { const t = talentBb(chess, 0); const s2 = onDefaultSkill(chess); @@ -606,7 +610,7 @@ export default { trait: { blockFly: false }, skill: { kind: 'duration', trigger: { rule: 'CUSTOM_RANGE', grid: [] }, // never by the engine: the hit handler below - mods: { atkPct: num(bb.atk) }, flags: { blockFly: true }, + mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS, targeting: { rangeGrid: def?.skill?.rangeGrid ?? null }, attack: { hits: 3 }, onStart: tippiTakeOff, @@ -614,7 +618,7 @@ export default { }, skills: { skchr_tippi_1: { - kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: { blockFly: true }, + kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS, targeting: { rangeGrid: def?.skill?.rangeGrid ?? null }, onStart: tippiTakeOff, onEnd: tippiLand, diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index d766208e..091541c9 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -893,8 +893,8 @@ const KITS = { for (const a of unit.mem.sickles || []) { if (!a.alive || !a.deployed) continue; // PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空); - // 起飞 = an airborne skywalker (蒂比's skill: off the ground, flag blockFly) - const air = !a.ground && !!a.s.flags.blockFly; + // 起飞 = an airborne skywalker (蒂比's skills: flag `liftoff`; still a 地面单位, so she can carry one) + const air = !!a.s.flags.liftoff; for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) { if (e.isFlying && !air) continue; battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] }); diff --git a/server/sim/damage.js b/server/sim/damage.js index 249eb4dc..7610587c 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -1,6 +1,7 @@ // server/sim/damage.js — damage & heal pipeline, shields, dodge, element gauges (DESIGN §5.5). // -// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel) +// dealDamage order: (element → gauge path) | invulnerable? / 对地规避 (a ground enemy's damage to an airborne 起飞 ally — +// targeting.js evadesGround; not `ignoreSelect` damage) → 'hit' hook (mutable DamageInfo, may set cancel) // → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none) // → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul // → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled) @@ -47,6 +48,7 @@ import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js'; import { BOSS_HIT_LIMIT } from '../../shared/constants.js'; +import { evadesGround } from './targeting.js'; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); @@ -82,6 +84,8 @@ export function makeDamageInfo(d = {}) { cancel: false, noSp: !!d.noSp, ignoreSleep: !!d.ignoreSleep, + // 无视无法选择 (PRTS: 【污染秽蚀】 "可对空,无视无法选择"): reaches an airborne 起飞 ally whatever the source's 行动方式 + ignoreSelect: !!d.ignoreSelect, sourceless: !!d.sourceless, attackId: d.attackId ?? 0, }; @@ -92,6 +96,17 @@ function sleepBlocks(target, source, dmg) { return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep); } +/** + * 起飞 (flag `liftoff`): a ground enemy's damage never reaches the airborne ally — it cannot select it (对地规避, PRTS 作战机制 + * "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"), so its splash, area abilities and element fills skip it, and a shot + * already in flight or a DoT ticking when it took off lands on nothing [ASSUMED: PRTS 伤害流程 7 "取消掉隐匿/无敌状态下的攻击" + * read for 对地规避]. Checked before the `hit` hook only: 蒂比 S2 takes off inside the hook of the hit that set it off, which + * then resolves as usual (dodged if physical / arts — PRTS 备注). Sourceless damage and `ignoreSelect` damage still land. + */ +function liftoffEvades(target, source, dmg) { + return !!target.s.flags.liftoff && !dmg.ignoreSelect && evadesGround(source, target); +} + /** * Pure mitigation formula (exported for tests). `target` = the target's stats; `ign.elementalRes` = its 元素抗性 * (epDamageResistance) for 'elemental' damage. @@ -178,7 +193,7 @@ export function dealDamage(battle, source, target, dmgIn) { // gets the stats and the kill (PRTS 伤害分类 无来源 ③) const hs = dmg.sourceless ? null : source; let ts = target.s; - if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0; + if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg) || liftoffEvades(target, hs, dmg)) return 0; if (battle._hooks.hit) { battle.emit('hit', { source: hs, target, dmg, credit: source }); if (dmg.cancel || !target.alive || !target.deployed) return 0; @@ -335,6 +350,7 @@ export function applyElement(battle, source, target, dmg) { if (!el || !(el in target.elem)) return 0; if (!hasHp(target)) return 0; // a killing blow's rider: the target is dead, nothing fills or bursts (header) if (target.s.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0; + if (liftoffEvades(target, dmg.sourceless ? null : source, dmg)) return 0; if (burstLocked(target, el)) return 0; if (battle._hooks.elementHit) { battle.emit('elementHit', { source, target, dmg }); diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 96113838..1970be33 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -9,7 +9,7 @@ // follow-up "阻挡了就一定要能打到"; Battle.blockedTargets), (2) profile priority (fly/lowDef/…), // (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units // (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy -// priority: sortAllyTargets. +// priority: sortAllyTargets. 起飞 (an ally's flag `liftoff`) = 对地规避: no ground enemy selects it (evadesGround). import { COLS, ROWS } from './constants.js'; import { normDir, rotateOffset } from './dir.js'; @@ -68,16 +68,34 @@ export function canTargetEnemy(attacker, e, profile) { * "隐匿与'阻挡时解除'没有直接关系"). Devices are never targets (阻隔工事: obstacles nobody can select; “双眼皮”: 迷彩 and * off the enemy paths — PRTS). 迷彩 (flag `camou`, term ba.camou "不阻挡时不成为敌方普通攻击的目标") works the same way: * PRTS 异常效果 gives both anomalies the note "与'阻挡时解除'没有直接关系" [ASSUMED: enemy skills and splash selectors - * treat 迷彩 like target selection — officially splash and selectors without a projectile ignore it]. + * treat 迷彩 like target selection — officially splash and selectors without a projectile ignore it]. An airborne ally + * (起飞, flag `liftoff`) is never a target of a ground enemy (evadesGround). */ export function canTargetAlly(e, a, ranged) { if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') return false; const f = a.s.flags; if (f.untargetable || f.sleep) return false; if (ranged && (f.stealth || f.camou) && e.blockedBy !== a) return false; + if (f.liftoff && evadesGround(e, a)) return false; return true; } +/** + * 对地规避 of an airborne ally (起飞, buff flag `liftoff` — 蒂比's skills): true when `src` is an enemy whose 行动方式 is + * ground, which then cannot select `a` (gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人"; + * PRTS 术语释义 起飞 "包含对地规避(无法被不同阵营行动方式为地面的单位选中)"; PRTS 异常效果 MOTION_TARGET_FREE, a 无法选择 + * effect that a selector whose 行动方式 is not ground ignores). Flyers, 近地悬浮 and 浮空 enemies (Unit.isFlying — PRTS + * 行动方式: a hovering unit "是真正的飞行单位", a levitated one is seen as a flyer) still select it; it stays a ground unit + * itself ("起飞的干员仍然是地面单位"), so they need no 对空 check. Used by every enemy selection (canTargetAlly), the damage + * pipeline and Battle.applyStatus (a ground enemy's area damage, statuses and hits under way skip it — PRTS 作战机制 "AOE + * 的判定是对攻击范围内的每个可以被选中的敌人进行判定"), and the content picks that bypass canTargetAlly (chain / bounce jumps, + * 周围四格 additions). Sourceless damage and abilities that "无视无法选择" (DamageInfo `ignoreSelect`: 【污染秽蚀】) are not + * selections and still reach it. + */ +export function evadesGround(src, a) { + return !!(a && a.s && a.s.flags.liftoff && src && src.side === 'enemy' && !src.isFlying); +} + const PRIORITY_FNS = { fly: (e) => (e.isFlying ? 0 : 1), lowDef: (e) => e.s.def, diff --git a/test/content/kits_alt_t2.test.js b/test/content/kits_alt_t2.test.js index 252477b8..40d8c080 100644 --- a/test/content/kits_alt_t2.test.js +++ b/test/content/kits_alt_t2.test.js @@ -362,7 +362,8 @@ test('2_13 蒂比 S1 专业喷绘技巧: DEFAULT trigger, takes off (skill range const range0 = u.baseRangeKeys.length; h.runUntil(() => u.skill.active, 3); assert.equal(started(h, u)[0].reason, 'DEFAULT'); - assert.equal(u.ground, false, 'airborne'); + assert.equal(u.s.flags.liftoff, true, 'airborne (起飞)'); + assert.equal(u.ground, true, 'still a ground unit on her low tile'); approx(u.s.atk, u.base.atk * (1 + bb.atk)); assert.ok(u.rangeKeys.length > range0, 'skill range'); const fl = h.spawn('f', { pos: [9, 5] }); @@ -374,7 +375,7 @@ test('2_13 蒂比 S1 专业喷绘技巧: DEFAULT trigger, takes off (skill range assert.equal(hits.length, atk.reduce((n, c) => n + c.targets.length, 0), 'one shot per target (no 3 连射)'); h.runUntil(() => !u.skill.active, 40); h.step(); - assert.equal(u.ground, true, 'landed'); + assert.ok(!u.s.flags.liftoff, 'landed'); done(h); // an enemy attack from outside her range never sets S1 off (S2's 受到攻击后触发 is not hers) const h2 = run({ defs: { enemies: { r: dummy('r', { atk: 300, bat: 1, range: 3.2 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'r', pos: [9, 8] }] }); diff --git a/test/content/kits_t1t2.test.js b/test/content/kits_t1t2.test.js index ae0bf8ef..a9bfcb0b 100644 --- a/test/content/kits_t1t2.test.js +++ b/test/content/kits_t1t2.test.js @@ -962,7 +962,8 @@ test('2_13 蒂比: an incoming attack triggers 紧急赶场通知 and is dodged; assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE'); assert.equal(u.stats.taken, 0, 'the triggering hit was dodged'); approx(u.s.atk, u.base.atk * (1 + bb.atk)); - assert.equal(u.ground, false); + assert.equal(u.s.flags.liftoff, true, 'airborne (起飞)'); + assert.equal(u.ground, true, 'still a ground unit on her low tile'); h.step(); assert.equal(e.blockedBy, null, 'airborne: ground enemies are released'); const fl = h.spawn('f', { pos: [9, 5] }); diff --git a/test/render/feedback1f-liftoff.browser.test.js b/test/render/feedback1f-liftoff.browser.test.js new file mode 100644 index 00000000..5e301406 --- /dev/null +++ b/test/render/feedback1f-liftoff.browser.test.js @@ -0,0 +1,80 @@ +// test/render/feedback1f-liftoff.browser.test.js — player report F3 (sixth batch after 0.1.0) in headless Chrome: the +// client replica (the client runner's loadBrowserSim: the same /sim/ modules and data the browser simulates combat with) +// keeps ground enemies off an airborne 蒂比. On act2 m02's lower lane 蒂比 (S2 紧急赶场通知) stands on the road with 角峰 on +// the fence tile below her and a still 控潮术师 (ground caster: target + 周围四格) in range of both: its first shot sets her +// off (dodged), and while she is airborne (起飞: flag `liftoff`, 对地规避) it only hits 角峰 — never her, neither directly +// nor through the 周围四格 — and once she lands she is hit again (sim: targeting.js evadesGround, damage.js). +// +// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1f-liftoff.browser.test.js +// Chrome path: $CHROME_PATH or the macOS default. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildBattleSpec } from '../../server/sim/spec.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets')); +const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)'; + +const SPEC = buildBattleSpec({ + battleId: 'e2e.liftoff', fieldId: 'n:P1', kind: 'normal', seed: 3, modeId: 'mode_multi_hard', round: 8, stageId: 'act2autochess_m02', timeLimit: 400, + players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [], units: [ + { uid: 1, kind: 'chess', chessId: 'chess_char_2_13_a', row: 9, col: 5, dir: 'RIGHT', carryState: { sp: 999 } }, + { uid: 2, kind: 'chess', chessId: 'chess_char_1_02_a', row: 10, col: 5, dir: 'RIGHT' }, + ] }], + spawns: [{ time: 0, enemyKey: 'enemy_1161_tidmag', routeIndex: 0, count: 1, mods: { hpMul: 1e4, speedMul: 0 } }], + routes: [{ motion: 'WALK', start: [9, 7], end: [9, 2], checkpoints: [] }], flags: {}, +}); + +describe('an airborne 蒂比 is no target of ground enemies in the client replica (headless Chrome, real sim)', { skip }, () => { + let srv, browser; + before(async () => { + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../../server/index.js'); + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] }); + }); + after(async () => { + await browser?.close(); + await srv?.close(); + }); + + test('控潮术师 hits 角峰 while 蒂比 is airborne, and her again once she lands', async () => { + const page = await browser.newPage(); + const problems = []; + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + try { + await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`); + await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 }); + const r = await page.evaluate(async (spec) => { + const { loadBrowserSim } = await import('/js/battle/runner.js'); + const { spec: S, ds } = await loadBrowserSim(); + const b = S.createBattleFromSpec(spec, ds, { quiet: true }); + const hits = []; + b.on('damaged', (c) => { if (c.source && c.source.side === 'enemy') hits.push({ t: b.time, to: c.target.defId, amount: c.amount, air: !!c.target.s.flags.liftoff }); }); + let up = null, down = null, ground = null; + for (let i = 0; i < 30 * 60 && (down == null || b.time < down + 12); i++) { + b.step(); + const u = b.allyUnits.find((x) => x.defId === 'chess_char_2_13_a'); + if (up == null && u.skill.active) { up = b.time; ground = u.ground; } + if (up != null && down == null && !u.skill.active) down = b.time; + } + return { up, down, ground, hits, errors: b.errors.length }; + }, SPEC); + assert.ok(r.up != null && r.down != null, `took off at ${r.up}, landed at ${r.down}`); + const tippiAir = r.hits.filter((x) => x.to === 'chess_char_2_13_a' && x.t > r.up + 1e-6 && x.t < r.down - 1e-6); + assert.deepEqual(tippiAir.map((x) => `${x.t.toFixed(2)} ${Math.round(x.amount)}`), [], 'nothing reaches her while airborne'); + assert.equal(r.ground, true, 'still a ground unit on her tile'); + assert.ok(r.hits.some((x) => x.to === 'chess_char_1_02_a' && x.t > r.up && x.t < r.down), 'the caster shoots 角峰 meanwhile'); + assert.ok(r.hits.some((x) => x.to === 'chess_char_2_13_a' && x.t > r.down), 'hit again after landing'); + assert.equal(r.errors, 0); + assert.deepEqual(problems, []); + } finally { + await page.close(); + } + }); +}); diff --git a/test/sim/feedback1f-liftoff.test.js b/test/sim/feedback1f-liftoff.test.js new file mode 100644 index 00000000..4e3f6926 --- /dev/null +++ b/test/sim/feedback1f-liftoff.test.js @@ -0,0 +1,177 @@ +// Player report after the 0.1.0 release (sixth batch, F3): "干员蒂比(巡空者)起飞后仍能被不该打到的攻击打到". +// +// Verdict: real. Official rule (gamedata_const termDescriptionDict ba.liftoff 起飞 "不阻挡地面敌人且不会被地面敌人攻击,可以 +// 阻挡飞行敌人"; PRTS 术语释义 起飞 "※包含对地规避(无法被不同阵营行动方式为地面的单位选中)… ※起飞后单位的行动方式依旧是地面"; +// PRTS 异常效果 MOTION_TARGET_FREE 对地规避 "无法被行动方式为地面的不同阵营单位选中(属于无法选择类效果)", ignored when the +// selector's 行动方式 is not ground; PRTS 行动方式 "起飞的干员仍然是地面单位" and 近地悬浮 / 浮空 units count as flyers; +// PRTS 作战机制 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"): +// - no ground enemy (行动方式 ground: data WALK, not hovering / levitated) selects an airborne operator — normal attacks, +// the targets its abilities add (控潮术师's 周围四格, chain / bounce jumps) and its area damage and statuses skip her; +// - flyers, 近地悬浮 and 浮空 enemies still select her (she stays a ground unit: no 对空 check); +// - sourceless damage and abilities that "无视无法选择" (【污染秽蚀】, PRTS 萨卡兹枯朽战士) still reach her, at the +// low-ground rate: she is still on her low tile; +// - she stays a 地面单位 for ally rules (隐德来希 S2 puts a 血镰 on her; PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空"). +// Cause: 起飞 was `unit.ground = false` (an operator on a high tile): melee enemies could not reach her (she released them), +// but every ranged ground enemy kept shooting her and the AoE / skills of ground enemies hit her. +// [ASSUMED] a ground enemy's damage already under way (a shot in flight, a DoT ticking) is cancelled too (PRTS 作战机制 伤害 +// 流程 7 "取消掉隐匿/无敌状态下的攻击", read for 对地规避); sourceless auras of ground enemies (no selector source in the sim) +// still apply. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { hasGeneratedData, getDefaultSource } from '../../server/sim/simdata.js'; + +const REAL = { skip: !hasGeneratedData() }; +const ds = getDefaultSource(); +const TIPPI = ['chess_char_2_13_a', 'chess_char_2_13_b']; +const S1 = 'skchr_tippi_1'; // 专业喷绘技巧: MANUAL → DEFAULT trigger, takes off at once +const S2 = 'skchr_tippi_2'; // 紧急赶场通知: takes off when attacked (dodging that hit) +const BAIT = 'chess_char_1_02_a'; // 角峰 (TANK) on the fence tile below her +const TIDMAG = 'enemy_1161_tidmag'; // 控潮术师: ground caster, hits its target and the 周围四格 (+ erosion) +const JSHOOT = 'enemy_1019_jshoot'; // 隐形弩手: ground sniper +const DUMAGE = 'enemy_1168_dumage'; // 深池暗影术师: ground caster +const LAZERD = 'enemy_1041_lazerd'; // 法术大师A1: FLY caster +const SYUFO = 'enemy_2025_syufo'; // 掠海漂移体: 近地悬浮 (a flyer while it hovers) +const NHPBR = 'enemy_1267_nhpbr'; // 萨卡兹枯朽战士: ground melee; death → 【污染秽蚀】 50 / 25 true per s +const ETLCHI = 'chess_char_5_06_a'; // 隐德来希 (S2 绯红壁合: 血镰 on herself and one other 地面单位) +const GOPRO = 'enemy_1000_gopro_2'; // 猎狗pro: ground melee +const skillIndex = (id, sk) => ds.rawChess(id).skills.find((s) => s.skillId === sk).index; +const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); }; + +/** + * act2 m02's lower lane: 蒂比 on the road (9,5) facing the gate, 角峰 on the fence tile (10,5) — inside 控潮术师's 周围四格 of + * her and she of him — and a still, tanky enemy at (9,7) (in her range and both of them in its own). Runs until she has + * landed and `after` more seconds; returns the hits on her while airborne / after landing and those on the bait. + */ +function duel(id, sk, enemyKey, { after = 12, mods = {}, pos = [9, 7] } = {}) { + const h = makeBattle({ + stageId: 'act2autochess_m02', seed: 3, autoFinish: false, timeLimit: 400, hooks: ['damaged', 'skillStart', 'skillEnd'], captureNoisy: true, + units: [{ chessId: id, row: 9, col: 5, carryState: { sp: 999 }, skillIndex: skillIndex(id, sk) }, { chessId: BAIT, row: 10, col: 5 }], + enemies: [{ key: enemyKey, pos, mods: { hpMul: 1e4, speedMul: 0, ...mods } }], + }); + const u = h.unit(id), bait = h.unit(BAIT); + let up = null, down = null, airborneSeen = false; + for (let i = 0; i < 120 * 30 && (down == null || h.b.time < down + after); i++) { + h.step(); + if (up == null && u.skill.active) up = h.b.time; + if (u.skill.active && u.s.flags.liftoff) airborneSeen = true; + if (up != null && down == null && !u.skill.active) down = h.b.time; + } + const fromEnemy = (c) => c.source && c.source.side === 'enemy'; + const on = (t) => h.hooksOf('damaged').filter((c) => c.target === t && fromEnemy(c)); + const tOf = (c) => c.t ?? c.time; + return { + h, u, bait, up, down, airborneSeen, + air: on(u).filter((c) => tOf(c) > up + 1e-6 && tOf(c) < down - 1e-6), + landed: on(u).filter((c) => tOf(c) > down + 1e-6), + baitAir: on(bait).filter((c) => tOf(c) > up + 1e-6 && tOf(c) < down - 1e-6), + }; +} + +test('F3: no ground enemy hits an airborne 蒂比 — 控潮术师 (direct and 周围四格), S1 and S2, normal and elite; she is a target again once landed', REAL, () => { + for (const id of TIPPI) for (const sk of [S1, S2]) { + const r = duel(id, sk, TIDMAG); + assert.ok(r.up != null && r.down != null, `${id} ${sk}: took off and landed`); + assert.ok(r.airborneSeen, `${id} ${sk}: 起飞 is the buff flag \`liftoff\``); + assert.deepEqual(r.air.map((c) => `${c.type} ${Math.round(c.amount)}`), [], `${id} ${sk}: nothing from the ground caster while airborne`); + assert.ok(r.baitAir.length > 0, `${id} ${sk}: the caster shoots 角峰 instead`); + assert.ok(r.landed.length > 0, `${id} ${sk}: hit again after landing`); + assert.equal(r.u.ground, true, `${id} ${sk}: still on her low tile (行动方式 ground)`); + done(r.h); + } +}); + +test('F3: ranged ground snipers / casters (隐形弩手, 深池暗影术师) never shoot an airborne 蒂比', REAL, () => { + // the 隐匿 隐形弩手 is no target of hers, so S1's DEFAULT trigger never fires for it: S2 (its shot sets her off) + for (const [key, sk] of [[JSHOOT, S2], [DUMAGE, S1], [DUMAGE, S2]]) { + const r = duel(TIPPI[0], sk, key); + assert.ok(r.up != null && r.down != null, `${key} ${sk}: took off and landed`); + assert.deepEqual(r.air.map((c) => `${c.type} ${Math.round(c.amount)}`), [], `${key} ${sk}: nothing while airborne`); + assert.ok(r.baitAir.length > 0, `${key} ${sk}: it shoots 角峰 instead`); + done(r.h); + } +}); + +test('F3: flyers and 近地悬浮 enemies still attack an airborne 蒂比 (对地规避 only stops ground selectors; she stays a ground unit)', REAL, () => { + for (const key of [LAZERD, SYUFO]) { + const r = duel(TIPPI[0], S1, key, { mods: { atkMul: 0.2 } }); + assert.ok(r.up != null, `${key}: took off`); + assert.ok(r.air.length > 0, `${key}: hits her while she is airborne`); + done(r.h); + } +}); + +test('F3: the pipeline — a ground enemy\'s damage, element and statuses never land on an airborne 蒂比; a flyer\'s and sourceless ones do', REAL, () => { + const h = makeBattle({ + stageId: 'act2autochess_m02', seed: 3, autoFinish: false, timeLimit: 400, hooks: ['hit', 'damaged', 'statusApplied'], captureNoisy: true, + defs: { enemies: { enemy_g: enemyRec({ key: 'enemy_g', hp: 1e7, speed: 0, atk: 0 }), enemy_f: enemyRec({ key: 'enemy_f', hp: 1e7, speed: 0, atk: 0, motion: 'FLY' }) } }, + units: [{ chessId: TIPPI[0], row: 9, col: 5, carryState: { sp: 999 }, skillIndex: skillIndex(TIPPI[0], S1) }], + enemies: [{ key: 'enemy_g', pos: [11, 9] }, { key: 'enemy_f', pos: [9, 7] }], + }); + const u = h.unit(TIPPI[0]); + assert.ok(h.runUntil(() => u.skill.active, 3), 'S1 takes off (the flyer is in range)'); + const g = h.b.enemies.find((e) => e.defId === 'enemy_g'), f = h.b.enemies.find((e) => e.defId === 'enemy_f'); + assert.equal(g.isFlying, false); + // a ground enemy's hit: cancelled before any hook (she is not selected: no 'hit', no 片场工作指南 / S2 trigger) + const hits0 = h.hooksOf('hit').filter((c) => c.target === u).length; + assert.equal(h.b.dealDamage(g, u, { amount: 300, type: 'phys', canDodge: false, isAttack: true }), 0, 'ground attack'); + assert.equal(h.b.dealDamage(g, u, { amount: 300, type: 'true', isSkill: true }), 0, 'ground ability (true damage)'); + assert.equal(h.b.dealDamage(g, u, { type: 'element', element: 'burn', amount: 500 }), 0, 'ground element fill'); + assert.equal(u.elem.burn, 0); + assert.equal(h.hooksOf('hit').filter((c) => c.target === u).length, hits0, 'no hit hook for a ground source'); + assert.equal(h.b.applyStatus(u, 'stun', { duration: 2, source: g }), false, 'ground enemy\'s stun refused'); + assert.equal(u.s.flags.stun, false); + // a flyer, sourceless damage and an ability that ignores 无法选择 still land + assert.ok(h.b.dealDamage(f, u, { amount: 300, type: 'true', isSkill: true }) > 0, 'flyer'); + assert.ok(h.b.dealDamage(null, u, { amount: 300, type: 'true' }) > 0, 'sourceless'); + assert.ok(h.b.dealDamage(g, u, { amount: 300, type: 'true', ignoreSelect: true }) > 0, '无视无法选择 (ignoreSelect)'); + assert.equal(h.b.applyStatus(u, 'stun', { duration: 0.2, source: f }), true, 'a flyer\'s stun lands'); + // landed: the ground enemy reaches her again + h.runUntil(() => !u.skill.active, 40); + h.run(0.5); + assert.equal(u.s.flags.liftoff, undefined); + assert.ok(h.b.dealDamage(g, u, { amount: 300, type: 'true', isSkill: true }) > 0, 'landed: ground ability lands'); + done(h); +}); + +test('F3: 【污染秽蚀】 (萨卡兹枯朽战士) still reaches an airborne 蒂比, at the low-ground rate (PRTS "可对空,无视无法选择")', REAL, () => { + const h = makeBattle({ + stageId: 'act2autochess_m02', seed: 3, autoFinish: false, timeLimit: 400, hooks: ['damaged'], captureNoisy: true, + units: [{ chessId: TIPPI[0], row: 9, col: 5, carryState: { sp: 999 }, skillIndex: skillIndex(TIPPI[0], S1) }], + enemies: [{ key: NHPBR, pos: [9, 6], mods: { hpMul: 0.01, speedMul: 0 } }], + }); + const u = h.unit(TIPPI[0]); + assert.ok(h.runUntil(() => u.skill.active, 3), 'takes off'); + assert.ok(h.runUntil(() => !h.b.enemies.some((e) => e.alive), 10), 'she kills the 战士 while airborne'); + h.run(4); + assert.ok(u.skill.active && u.s.flags.liftoff, 'still airborne'); + const ticks = h.hooksOf('damaged').filter((c) => c.target === u && (c.dmg?.tags || []).includes('pollution')); + assert.ok(ticks.length >= 3, `pollution ticks on her: ${ticks.length}`); + for (const c of ticks) assert.equal(Math.round(c.amount), 50, 'low-ground rate (her tile is low ground)'); + done(h); +}); + +test('F3: an airborne 蒂比 is still a 地面单位 for 隐德来希 S2 — she carries a 血镰 and it cuts the flyer next to her', REAL, () => { + const h = makeBattle({ + stageId: 'act2autochess_m02', seed: 3, autoFinish: false, timeLimit: 400, hooks: ['damaged', 'skillStart'], captureNoisy: true, + units: [ + { chessId: TIPPI[0], row: 9, col: 5, carryState: { sp: 999 }, skillIndex: skillIndex(TIPPI[0], S1) }, + { chessId: ETLCHI, row: 9, col: 3, skillIndex: skillIndex(ETLCHI, 'skchr_etlchi_2') }, + ], + enemies: [ + { key: GOPRO, pos: [9, 4], mods: { hpMul: 1e4, speedMul: 0, atkMul: 0 } }, + { key: LAZERD, pos: [9, 6.2], mods: { hpMul: 1e4, speedMul: 0, atkMul: 0 } }, + ], + }); + const u = h.unit(TIPPI[0]), et = h.unit(ETLCHI); + assert.ok(h.runUntil(() => u.skill.active, 3), '蒂比 takes off'); + assert.ok(h.runUntil(() => et.skill.active, 40), '隐德来希 casts S2'); + assert.ok(u.skill.active && u.s.flags.liftoff, 'while 蒂比 is airborne'); + assert.ok((et.mem.sickles || []).includes(u), 'the second 血镰 is on 蒂比'); + h.run(2); + const fl = h.b.enemies.find((e) => e.defId === LAZERD); + const cuts = h.hooksOf('damaged').filter((c) => c.target === fl && c.source === et && (c.dmg?.tags || []).includes('bloodSickle')); + assert.ok(cuts.length > 0, 'her 血镰 hits the flyer (可对空 while she is airborne)'); + done(h); +});