feedback1f WT: 起飞 review — only selections skip an airborne 蒂比

- A ground enemy's damage zone no longer ignores 对地规避: 集团军重型火炮's 燃烧区域
  ("碰撞不受迷彩制约,不可对空", 迷彩 only) skips her; only 【污染秽蚀】 ("无视无法选择") reaches her.
- Not selections, so they still reach her (ignoreSelect): 假想敌:铳's 【盲信之誓】 chains
  ("无视无法选择"), the 碎铳之簧 法术护盾 counter on its attacker (a direct pick, 无来源), and the
  刺胄之弹 / 斩胄之剑 / 破胄之锤 blasts (flying units, 无来源 DoT, credited to the ground 胄).
- One-shot areas of ground enemies skip her: 烹泉's death blast and steam slow, 鼠王's 沙狱
  (ATK cut + DoT), and “帝国的甲胄”'s entrance barrage (which now hits the next unit instead of
  wasting every shot on her).
- Tests: 5 new cases in test/sim/feedback1f-liftoff.test.js, all failing before this change.
- Docs: enemies.js / bosses.js headers, the evadesGround and DamageInfo docstrings, and SIM.md.
This commit is contained in:
sganggs committed 2026-10-03 08:15:52 +08:00
1 parent 7c9ee6dad8
commit 6286448697
6 files changed
+263 -39

No files matched your search

+14 -7
View File
@@ -207,8 +207,12 @@ a special priority (假想敌:铳 / 昆图斯 highest DEF, 假想敌:胄 hig
breaks ties by taunt, then latest deployed (`aggroCmp`); `untargetable` / sleeping allies and devices are never targets;
an airborne ally (起飞, flag `liftoff`: 蒂比's skills) never for a ground enemy (对地规避 — `targeting.js evadesGround`,
PRTS 术语释义 起飞 "无法被不同阵营行动方式为地面的单位选中"; flyers, 近地悬浮 and 浮空 enemies still pick it; content picks
that bypass `canTargetAlly` — 控潮术师 / 腐败骑士's 周围四格, 陷落雪祀's chain, the 碎铳之簧 bounce, the 自行炮 shells — filter
it too);
and one-shot areas that bypass `canTargetAlly` — 控潮术师 / 腐败骑士's 周围四格, 陷落雪祀's chain, the 碎铳之簧 bounce, the
自行炮 shells, “帝国的甲胄”'s barrage, 鼠王's 沙狱, 烹泉's death blast and steam — filter it too; not selections, so they
still reach it (`ignoreSelect`): abilities PRTS marks "无视无法选择" (【污染秽蚀】, 假想敌:铳's 【盲信之誓】), direct picks
(碎铳之簧's 法术护盾 counter on its attacker — PRTS 异常效果 "'直接选中'的能力…不受这些仅在选择时生效的异常效果制约") and
the blasts of flying units credited to the ground 胄 (刺胄之弹, 斩胄之剑 / 破胄之锤 — whose 掷剑 / 掷锤 pick "(无视无法选择)");
auras of ground enemies still apply [ASSUMED]);
a stealthed ally (隐匿, 排气格栅) only for the enemy it blocks — our operators keep 隐匿 while blocking (PRTS 作战机制
§隐匿; 索敌的概念: a blocked enemy "强行无视对方可选性" attacks its blocker); a camouflaged one (迷彩, flag `camou`: ba.camou
"不阻挡时不成为敌方普通攻击的目标") likewise (PRTS 异常效果: neither anomaly is "阻挡时解除") [ASSUMED: enemy skills and
@@ -224,13 +228,15 @@ research stages without `active` use `!hidden`. Active platforms/mounds (射击
ground obstacles, and an operator standing on one is elevated (`unit.ground = false`: never blocks).
**Enemy damage zones** (`content/enemies.js zone` / `dmgZone` / `pollution`): a zone ticks on the allies inside it
(flyers, stealthed, untargetable and airborne 起飞 ones included — `ignoreSelect`, PRTS 污染秽蚀 "可对空,无视无法选择";
[ASSUMED] for 毒雾 / 燃烧区域; `alliesInRadius`) through `dealDamage`, so shields absorb a damage
(flyers, stealthed and untargetable ones included; `alliesInRadius`) through `dealDamage`, so shields absorb a damage
tick, damage-taken modifiers scale it and it counts for 受击回复 SP and TAKE_DAMAGE skills like any hit (§4; element fills
excepted). 【污染秽蚀】 (萨卡兹枯朽战车's 秽蚀轰击,
萨卡兹枯朽战士's death) is **true** damage, 50 / 25 per second on low / high ground (PRTS "每秒受到50/25点真实普通伤害 …
同名效果不叠加", user playtest #6): a unit covered by several zones takes one tick per second (`unit.mem.pollutedAt`), so
a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n.
a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n. It is "可对空,无视无法选择": it also burns an airborne 起飞 ally
(`ignoreSelect`), at the low-ground rate on a low tile. The other zones are no exception to 对地规避: a ground enemy's
燃烧区域 (集团军重型火炮, PRTS "碰撞不受迷彩制约,不可对空" — 迷彩 only) skips an airborne 起飞 ally; the sourceless 毒雾 of
假想敌:蚀裂 reaches everyone inside.
**Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the
player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is
@@ -482,8 +488,9 @@ HP left is refused (damage.js `hasHp`: a lethal hit's `damaged` hook runs before
`battle.dealDamage(source, target, dmg)` → HP removed. `DamageInfo = { amount, type:'phys'|'arts'|'true'|'elemental'|'element',
element?, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul=1, canDodge (phys/arts), isSkill, isSplash,
isAttack, attackId, ignoreSleep, ignoreSelect, sourceless, tags[], cancel }` (`ignoreSelect`: the ability "无视无法选择" —
it reaches an airborne 起飞 ally whatever its source; `sourceless`: 无来源 damage — the source's stats add
isAttack, attackId, ignoreSleep, ignoreSelect, sourceless, tags[], cancel }` (`ignoreSelect`: no selection 无法选择
effects stop — an ability "无视无法选择", a direct pick such as a counter on the attacker, a flying unit's blast credited to
a ground leader — it reaches an airborne 起飞 ally whatever its source; `sourceless`: 无来源 damage — the source's stats add
nothing and the hooks get `source: null` plus `credit` = the source, which keeps the stats and the kill; a `loseHp` whose
`from` is 无来源 is 无来源 too; element bursts, leader-part transfers and 坚守 thorns use it — content damage that has a
responsible unit should pass it as `source` with `sourceless: true` rather than `source: null`, which credits nobody)
+17 -7
View File
@@ -53,6 +53,10 @@
// The huge leaders (SELF_BOUND: 胄 ×2, 管 ×2, 昆图斯, 阿利斯泰尔, 萨米的意志 — the 巨型单位 with a data `hitArea`) are
// 自缚 + 无法被阻挡 (PRTS 天赋): a persistent noMove + unblockable buff from spawn, so they never walk their route.
// Every leader (tag boss) ignores 侵蚀 gauge damage ("最终攻势中,敌方领袖不会受到侵蚀损伤").
// An airborne (起飞) operator is no selection of a ground leader or part (对地规避: canTargetAlly, the damage pipeline,
// the 碎铳之簧 bounce); still reach it (`ignoreSelect`): the 刺胄之弹 / 剑 / 锤 blasts (flying units, 无来源 DoT; 掷剑 /
// 掷锤 pick their operator "(无视无法选择)"), the 盲信之誓 chains ("无视无法选择") and the 法术护盾 counter on its attacker
// (a direct pick).
// LP effects ('lpLoss' hook + result.lpLoss) must be applied by the match (see the report of this module's owner).
// fx kinds: 'beam' 'shell' 'explode' 'telegraph' 'charge' 'link' 'dash' 'column' 'tide' 'rockfall' 'tentacle' 'equip'
// 'sword' 'vest' 'blink' 'summon' 'grow' 'phase' 'lpLoss' (x, y + extra {id, r, tiles, kind, tx, ty …}).
@@ -320,13 +324,17 @@ export function fairOrder(b, e, list, P) {
return out.concat(rest);
}
/** Stun + phys DoT on the 3×3 around (r, c). */
/**
* Stun + phys DoT on the 3×3 around (r, c), credited to `src` (胄). The blast is a flying unit's — 刺胄之弹 / 斩胄之剑 /
* 破胄之锤 (PRTS 行动方式 飞行) — and its damage 无来源, so 对地规避 does not stop it: `ignoreSelect` (an airborne 起飞 ally in
* the 3×3 is stunned and hurt like the others, although the credited 胄 walks).
*/
function stunBlast(b, src, r, c, stun, dot, dur, kind) {
b.fx('explode', { x: c, y: r, r: 1.5, kind, tiles: 'box' });
for (const u of alliesInTiles(b, r, c, 'box', 1)) {
if (stun > 0) b.applyStatus(u, 'stun', { duration: stun, source: src });
if (stun > 0) b.applyStatus(u, 'stun', { duration: stun, source: src, ignoreSelect: true });
if (dot > 0 && dur > 0) b.addBuff(u, { key: `boss:${kind}Dot`, duration: dur, refresh: 'replace', interval: 1, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(src, unit, { amount: dot, type: 'phys', canDodge: false, tags: ['enemyAbility', kind] }) });
onTick: ({ battle, unit }) => battle.dealDamage(src, unit, { amount: dot, type: 'phys', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', kind] }) });
}
}
/** Distance from point p to segment a–b. */
@@ -587,11 +595,11 @@ function kitGun(ab, e, b) {
});
if (s3) list.push({
iv: s3.bb.interval ?? 1,
tick(b2, e2) { // 【盲信之誓】 links: phys per second on operators standing on a line
tick(b2, e2) { // 【盲信之誓】 links: phys per second on operators standing on a line ("无视无法选择、迷彩": 起飞 too)
for (const sp of b2.enemies) {
if (!sp.alive || !isSpring(sp)) continue;
b2.fx('link', { x: e2.x, y: e2.y, from: e2.id, to: sp.id, kind: 'faithLink', dur: s3.bb.interval ?? 1 });
for (const u of b2.allies()) if (segDist(u.x, u.y, e2.x, e2.y, sp.x, sp.y) <= LINK_WIDTH) hurt(b2, e2, u, s3.bb.value ?? 0, 'phys');
for (const u of b2.allies()) if (segDist(u.x, u.y, e2.x, e2.y, sp.x, sp.y) <= LINK_WIDTH) hurt(b2, e2, u, s3.bb.value ?? 0, 'phys', { ignoreSelect: true, tags: ['faithLink'] });
}
},
});
@@ -636,8 +644,10 @@ function kitSpring(ab, e) {
if (P.barrier <= 1e-6) { P.barrier = 0; drop(b); }
} else if (ty === 'phys') {
c.dmg.mul *= scale;
const s = c.source;
if (s && s.side === 'ally' && s.alive) { hurt(b, e2, s, e2.s.atk * (T(ab, '1.atk_scale') ?? 0), 'phys'); elem(b, e2, s, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }
// the counter "对来源造成…无来源物理附加伤害" picks its attacker directly — no selection, so an airborne 起飞
// attacker takes it too (PRTS 异常效果 无法选择: "'直接选中'的能力…不受这些仅在选择时生效的异常效果制约")
const s = c.source, o = { ignoreSelect: true, tags: ['springCounter'] };
if (s && s.side === 'ally' && s.alive) { hurt(b, e2, s, e2.s.atk * (T(ab, '1.atk_scale') ?? 0), 'phys', o); elem(b, e2, s, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0), o); }
}
} else if (kind === 'element') { // 元素护盾: phys/arts heavily reduced
if (ty === 'phys' || ty === 'arts') c.dmg.mul *= scale;
+26 -16
View File
@@ -30,8 +30,11 @@
// special priority (优先攻击防御力最高的… / 生命上限最高的…) sorts by its key, ties by taunt then latest deployed
// (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.
// and one-shot areas that bypass it (周围四格 additions, chain and bounce jumps, shells, barrages, 沙狱, death blasts)
// filter it, and the engine refuses a ground enemy's damage and statuses on it. Not selections, so they still reach
// it (`ignoreSelect`): abilities PRTS marks "无视无法选择" (【污染秽蚀】, 【盲信之誓】), direct picks of the attacker
// (碎铳之簧's counter — PRTS 异常效果 "'直接选中'的能力…不受这些仅在选择时生效的异常效果制约") and the blasts of flying
// units credited to a ground leader (刺胄之弹, 斩胄之剑 / 破胄之锤). Auras of ground enemies still apply [ASSUMED].
//
// Special types (factions.json):
// FLY — engine (FLY motion, ranged-only targeting). Flyer kits below (御4, 护障, 寒霜, 萨科塔之翼/眼, 黑云 …).
@@ -41,9 +44,9 @@
// 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; 毒雾, 燃烧区域 —
// they ignore 无法选择, PRTS 污染秽蚀 "可对空,无视无法选择": `ignoreSelect`, 起飞 allies included),
// bleeding (removed by healing), pulsing auras.
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit, "可对空,无视无法选择"
// — `ignoreSelect`, 起飞 allies included; 毒雾, 燃烧区域: a ground enemy's zone skips a 起飞 ally — PRTS
// 集团军重型火炮 "碰撞不受迷彩制约,不可对空", no 无视无法选择), 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
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
@@ -333,15 +336,16 @@ 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, { ignoreSelect = false } = {}) {
/** Element damage (erosion mapped onto the engine gauge); `ignoreSelect` / `tags` as hurt(). */
export function elem(b, src, tgt, el, amount, { ignoreSelect = false, tags = [] } = {}) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
const element = el === 'erosion' ? EROSION : el;
return b.dealDamage(src, tgt, { type: 'element', element, amount, ignoreSelect, tags: ['enemyAbility'] });
return b.dealDamage(src, tgt, { type: 'element', element, amount, ignoreSelect, tags: ['enemyAbility', ...tags] });
}
/**
* Skill / ability damage (no dodge unless asked). `ignoreSelect` = the ability "无视无法选择" (zones): it also reaches an
* Skill / ability damage (no dodge unless asked). `ignoreSelect` = no selection the target's 无法选择 effects stop
* (abilities that "无视无法选择", direct picks, a flying unit's blast credited to a ground leader): 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 } = {}) {
@@ -917,14 +921,15 @@ function pollution(b, src, x, y, r, life, low, high) {
}
/**
* Damage zone (arts per tick) — on every ally inside, like 【污染秽蚀】 (`ignoreSelect`: 起飞 allies too) [ASSUMED for the
* zones PRTS gives no 无法选择 note: 毒雾, 燃烧区域].
* Damage zone (`amount` per tick, tagged `kind`) on every ally inside. Unlike 【污染秽蚀】 it does not ignore 无法选择:
* a ground enemy's zone skips an airborne 起飞 ally (the damage pipeline; PRTS 集团军重型火炮 【燃烧区域】 "碰撞不受迷彩
* 制约,不可对空" — 迷彩 only). A sourceless zone (假想敌:蚀裂's 毒雾) reaches everyone inside.
*/
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, { ignoreSelect: true });
if (el && elAmount > 0) elem(b, src, u, el, elAmount, { ignoreSelect: true });
hurt(b, src, u, amount, type, { tags: [kind] });
if (el && elAmount > 0) elem(b, src, u, el, elAmount, { tags: [kind] });
}
} });
}
@@ -1583,12 +1588,14 @@ function kitTeapot(ab) {
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;
// a selection (PRTS 烹泉 "死亡爆炸(…无视迷彩,不可对空)", no 无视无法选择): an airborne 起飞 ally is skipped
const hit = (units) => units.filter((u) => !evadesGround(e, u));
// 被击倒后爆炸造成范围法术伤害 (one blast of ATK ×1 [ASSUMED scale]) …
b.fx('explode', { x, y, r, kind: 'teaBoom', id: e.id });
for (const u of b.alliesInRadius(x, y, r)) hurt(b, null, u, atk, 'arts');
for (const u of hit(b.alliesInRadius(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 }); };
const slow = (units) => { for (const u of hit(units)) b.addBuff(u, { key: 'ab:teaBoom', duration: iv + 0.1, refresh: 'extend', mods: { aspd }, visible: true }); };
slow(b.alliesInRadius(x, y, r));
if (dur > 0) zone(b, { x, y, r, life: dur, iv, kind: 'teaSteam', tick: slow });
},
@@ -1666,7 +1673,9 @@ function kitLeaderMisc(key, ab, e) {
if (!t) return;
const dur = ss.bb.duration ?? 0, x = t.x, y = t.y;
b.fx('zone', { x, y, r: SANDSTORM_RADIUS, dur, kind: 'sandStorm', id: e2.id });
// the 沙狱弹道 "击中…范围内的所有我方单位(弹道可对空)" selects: an airborne 起飞 ally is skipped (no 无视无法选择)
for (const u of b.alliesInRadius(x, y, SANDSTORM_RADIUS)) {
if (evadesGround(e2, u)) continue;
b.addBuff(u, { key: 'ab:sandStorm', duration: dur, refresh: 'replace', interval: 1, visible: true, mods: { atkPct: ss.bb.atk ?? 0 },
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: ss.bb.damage ?? 0, type: 'arts', canDodge: false, tags: ['enemyAbility', 'sandStorm'] }) });
}
@@ -2385,7 +2394,8 @@ export const KITS = Object.freeze({
if (!lock) return;
b.fx('telegraph', { x: lock.x, y: lock.y, r: 1, kind: 'barrage', id: e.id });
for (let i = 0; i < n; i++) {
const t = alliesInTiles(b, lock.tileR, lock.tileC, 'box', 1).sort((p, q) => q.hp - p.hp)[0];
// "对碰撞范围内的1名当前生命值最高的我方单位" — a selection: never an airborne 起飞 ally (PRTS “帝国的甲胄”)
const t = alliesInTiles(b, lock.tileR, lock.tileC, 'box', 1).filter((u) => !evadesGround(e, u)).sort((p, q) => q.hp - p.hp)[0];
if (t) hurt(b, e, t, e.s.atk, 'phys');
}
}, { owner: e });
+4 -2
View File
@@ -84,7 +84,8 @@ export function makeDamageInfo(d = {}) {
cancel: false,
noSp: !!d.noSp,
ignoreSleep: !!d.ignoreSleep,
// 无视无法选择 (PRTS: 【污染秽蚀】 "可对空,无视无法选择"): reaches an airborne 起飞 ally whatever the source's 行动方式
// no selection 无法选择 effects stop (an ability that "无视无法选择" such as PRTS 【污染秽蚀】, a direct pick, a flying
// unit's blast credited to a ground leader): reaches an airborne 起飞 ally whatever the source's 行动方式
ignoreSelect: !!d.ignoreSelect,
sourceless: !!d.sourceless,
attackId: d.attackId ?? 0,
@@ -101,7 +102,8 @@ function sleepBlocks(target, source, dmg) {
* "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.
* then resolves as usual (dodged if physical / arts — PRTS 备注). Sourceless damage and `ignoreSelect` damage (no
* selection: 无视无法选择, direct picks, flying units' blasts — targeting.js evadesGround) still land.
*/
function liftoffEvades(target, source, dmg) {
return !!target.s.flags.liftoff && !dmg.ignoreSelect && evadesGround(source, target);
+5 -3
View File
@@ -88,9 +88,11 @@ export function canTargetAlly(e, a, ranged) {
* 行动方式: 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.
* 的判定是对攻击范围内的每个可以被选中的敌人进行判定"), and the content picks and one-shot areas that bypass canTargetAlly
* (chain / bounce jumps, 周围四格 additions, shells, barrages, 沙狱, death blasts). Not selections, so they still reach it:
* sourceless damage and DamageInfo / applyStatus `ignoreSelect` — abilities that "无视无法选择" (【污染秽蚀】, 【盲信之誓】),
* direct picks (碎铳之簧's counter on its attacker: PRTS 异常效果 "'直接选中'的能力…不受这些仅在选择时生效的异常效果制约")
* and the blasts of flying units credited to a ground leader (刺胄之弹, 斩胄之剑 / 破胄之锤).
*/
export function evadesGround(src, a) {
return !!(a && a.s && a.s.flags.liftoff && src && src.side === 'enemy' && !src.isFlying);
+197 -4
View File
@@ -8,14 +8,15 @@
// - 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;
// - what is no selection still reaches her: sourceless damage, abilities that "无视无法选择" (【污染秽蚀】 at the
// low-ground rate — she is still on her low tile; 【盲信之誓】), direct picks (碎铳之簧's counter on its attacker —
// PRTS 异常效果 "'直接选中'的能力…不受这些仅在选择时生效的异常效果制约") and flying units' blasts (刺胄之弹, 斩胄之剑);
// a ground enemy's zone does not (集团军重型火炮 【燃烧区域】 "碰撞不受迷彩制约,不可对空": 迷彩 only);
// - 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.
// 流程 7 "取消掉隐匿/无敌状态下的攻击", read for 对地规避); auras of ground enemies (光环, field-wide debuffs) still apply.
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -175,3 +176,195 @@ test('F3: an airborne 蒂比 is still a 地面单位 for 隐德来希 S2 — she
assert.ok(cuts.length > 0, 'her 血镰 hits the flyer (可对空 while she is airborne)');
done(h);
});
// ---------------------------------------------------------------------------------------------------------------
// Review round: which enemy abilities are selections (skip an airborne 蒂比) and which are not (still reach her).
// PRTS 异常效果 无法选择: "'直接选中'的能力不会进行具体的目标选择,故同样不受这些仅在选择时生效的异常效果制约"; abilities
// marked "无视无法选择" ignore it; a selector whose 行动方式 is not ground ignores 对地规避 (MOTION_TARGET_FREE).
const UACANN = 'enemy_10122_uacann_2'; // 集团军重型火炮: ground; hits leave a 3 s 【燃烧区域】 (PRTS "碰撞不受迷彩制约,不可对空")
const SPRING_A = 'enemy_9018_actrpa'; // “碎铳之簧” 法术护盾: phys hits → "对来源造成…无来源物理附加伤害" + erosion
const GUN2 = 'enemy_9017_achunt_2'; // 假想敌:铳 (隐秘核心): 【盲信之誓】 chains "无视无法选择、迷彩"
const SPRING_C = 'enemy_9020_actrpc'; // “碎铳之簧” 频次护盾 (a chain end)
const HELM = 'enemy_9013_acstmk'; // 假想敌:胄 (ground): 【灭顶之灾】 → 刺胄之弹 (行动方式 飞行)
const BLADE = 'enemy_9014_acstma'; // “斩胄之剑” (行动方式 飞行): 掷剑 "(无视无法选择)"
const SFHU = 'enemy_1203_sfhu'; // 烹泉: ground; death blast "无视迷彩,不可对空" + 【烹泉减益】
const MOUSEK = 'enemy_1509_mousek'; // “鼠王”: ground; 【沙狱】 ATK cut + arts per second on a 3×3
const VTSK = 'enemy_10027_vtsk'; // “帝国的甲胄”: ground; entrance barrage on the highest-HP unit around its lock
const pool = (hp) => ({ hp, maxHp: hp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } });
const WAVES = getDefaultSource();
const tAt = (c) => c.t ?? c.time;
/** Steps `h` until 蒂比 has landed (or `max` s); returns [take-off, landing] times. */
function airborne(h, u, max = 60) {
let up = null, down = null;
for (let i = 0; i < max * 30 && down == null; i++) {
h.step();
if (up == null && u.s.flags.liftoff) up = h.b.time;
if (up != null && down == null && !u.s.flags.liftoff) down = h.b.time;
}
return [up, down];
}
test('F3 review: a ground enemy\'s 【燃烧区域】 (集团军重型火炮, "不可对空", no 无视无法选择) never ticks on an airborne 蒂比; it does once she has landed', 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) }, { chessId: BAIT, row: 10, col: 5 }],
enemies: [{ key: UACANN, pos: [9, 7], mods: { hpMul: 1e4, speedMul: 0 } }],
});
const u = h.unit(TIPPI[0]), bait = h.unit(BAIT);
const [up, down] = airborne(h, u);
assert.ok(up != null && down != null, 'took off and landed');
assert.ok(u.alive, 'she survives the window (the zones on 角峰 next to her used to kill her)');
const burn = (t, a, z) => h.hooksOf('damaged').filter((c) => c.target === t && (c.dmg?.tags || []).includes('burning') && tAt(c) > a && tAt(c) < z);
assert.ok(burn(bait, up, down).length > 0, 'the cannon shoots 角峰 and the zone burns him');
assert.deepEqual(burn(u, up + 1e-6, down - 1e-6).map((c) => `${tAt(c).toFixed(1)} ${Math.round(c.amount)}`), [], 'no zone tick on her while airborne');
h.run(15);
assert.ok(burn(u, down, Infinity).length > 0, 'landed: the zone burns her again');
done(h);
});
test('F3 review: “碎铳之簧” 法术护盾 counter is 无来源 on its attacker (直接选中) — it reaches an airborne 蒂比', 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: SPRING_A, pos: [9, 7], mods: { hpMul: 1e4, speedMul: 0 } }],
});
const u = h.unit(TIPPI[0]);
assert.ok(h.runUntil(() => u.s.flags.liftoff, 3), 'takes off (the spring is in range)');
h.run(10);
assert.ok(u.s.flags.liftoff, 'still airborne');
const on = h.hooksOf('damaged').filter((c) => c.target === u);
assert.ok(on.some((c) => c.dmg.type === 'phys' && (c.dmg.tags || []).includes('springCounter')), 'the physical counter lands');
assert.ok(on.some((c) => c.dmg.type === 'element' && (c.dmg.tags || []).includes('springCounter')), 'its erosion lands');
assert.ok(!on.some((c) => c.dmg.isAttack), 'the ground spring\'s own attacks never select her');
done(h);
});
test('F3 review: 假想敌:铳\'s 【盲信之誓】 chain ("无视无法选择") hurts an airborne 蒂比 standing on it', REAL, () => {
const h = makeBattle({
kind: 'boss', sharedBoss: pool(1e7), seed: 3, autoFinish: false, timeLimit: 400, hooks: ['damaged'], captureNoisy: true,
setup(b) { b.enemyOverrides = WAVES.getWave('act1autochess_h08_02').overrides; },
units: [{ chessId: TIPPI[0], row: 10, col: 7, carryState: { sp: 999 }, skillIndex: skillIndex(TIPPI[0], S1) }],
});
h.step();
const put = (key, pos, o = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: 0, ...o.mods }, tag: o.tag ?? null });
put(GUN2, [3, 10], { tag: 'boss' }).profile.noAttack = true;
put(SPRING_C, [3, 4], { tag: 'part' }).profile.noAttack = true;
put(LAZERD, [3, 8.2], { mods: { hpMul: 1e4, atkMul: 0 } }); // her S1 target
const u = h.unit(TIPPI[0]);
assert.equal(u.y, 3, 'on the chain between 铳 (3,10) and the spring (3,4)');
const [up, down] = airborne(h, u);
assert.ok(up != null && down != null, 'took off and landed');
const chain = h.hooksOf('damaged').filter((c) => c.target === u && (c.dmg?.tags || []).includes('faithLink') && tAt(c) > up && tAt(c) < down);
assert.ok(chain.length >= 20, `the chain hurts her every second while airborne: ${chain.length}`);
done(h);
});
test('F3 review: the 刺胄之弹 and 斩胄之剑 blasts (flying units — 对地规避 does not stop them; 无来源 DoT) still stun and hurt an airborne 蒂比', REAL, () => {
// 刺胄之弹: 胄 (ground) cannot pick her; the shell flies at 角峰 next to her and its 3×3 blast catches her
{
const h = makeBattle({
kind: 'boss', sharedBoss: pool(1e7), seed: 3, autoFinish: false, timeLimit: 400, hooks: ['damaged', 'statusApplied'], captureNoisy: true,
units: [{ chessId: TIPPI[0], row: 10, col: 7, carryState: { sp: 999 }, skillIndex: skillIndex(TIPPI[0], S1) }, { chessId: BAIT, row: 11, col: 7 }],
});
h.step();
h.spawn(LAZERD, { pos: [3, 8.2], routeIndex: 0, mods: { speedMul: 0, hpMul: 1e4, atkMul: 0 } });
h.spawn(HELM, { pos: [3, 9], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' }).profile.noAttack = true;
const u = h.unit(TIPPI[0]);
let stunAir = false, dotAir = 0;
for (let i = 0; i < 31 * 30; i++) {
h.step();
if (!u.s.flags.liftoff) continue;
if (u.s.flags.stun) stunAir = true;
dotAir = h.hooksOf('damaged').filter((c) => c.target === u && (c.dmg?.tags || []).includes('helmShell')).length;
}
assert.ok(u.s.flags.liftoff, 'still airborne');
const st = h.hooksOf('statusApplied').filter((c) => c.status === 'stun');
assert.ok(st.some((c) => c.target === h.unit(BAIT)), 'the shell went to 角峰');
assert.ok(stunAir, 'she is stunned while airborne');
assert.ok(dotAir >= 2, `the DoT ticks on her: ${dotAir}`);
done(h);
}
// 斩胄之剑 掷剑 "选择…攻击力最低的1名我方单位(无视无法选择)": it dives at her while she is airborne, 胄 on the field
{
const H08 = 'act1autochess_h08_01';
const tpl = WAVES.getWave(H08);
const origin = tpl.extraRoutes[tpl.branches.left_hand_origin[0][0].routeIndex];
const h = makeBattle({
kind: 'boss', sharedBoss: pool(1e7), seed: 3, autoFinish: false, timeLimit: 400, hooks: ['damaged', 'statusApplied'], captureNoisy: true,
setup(b) { b.opts.templateId = H08; },
units: [{ chessId: TIPPI[0], row: 12, col: 15, carryState: { sp: 999 }, skillIndex: skillIndex(TIPPI[0], S1) }],
});
h.step();
h.spawn(LAZERD, { pos: [5, 16], routeIndex: 0, mods: { speedMul: 0, hpMul: 1e4, atkMul: 0 } });
h.spawn(HELM, { pos: [2, 9], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' }).profile.noAttack = true;
h.spawn(BLADE, { pos: origin.start, routeIndex: 0, tag: 'part' });
const u = h.unit(TIPPI[0]);
let stunAir = false, dotAir = false;
for (let i = 0; i < 31 * 30; i++) {
h.step();
if (!u.s.flags.liftoff) continue;
if (u.s.flags.stun) stunAir = true;
if (h.hooksOf('damaged').some((c) => c.target === u && (c.dmg?.tags || []).includes('bladeDive'))) dotAir = true;
}
assert.ok(u.s.flags.liftoff, 'still airborne');
assert.ok(stunAir, 'the dive stuns her while airborne');
assert.ok(dotAir, 'its DoT hurts her while airborne');
done(h);
}
});
test('F3 review: one-shot area abilities of ground enemies skip an airborne 蒂比 — 烹泉 death blast and steam, 鼠王 【沙狱】, “帝国的甲胄” barrage', REAL, () => {
// 烹泉: dies next to her while she is airborne (PRTS "死亡爆炸(…无视迷彩,不可对空)": a selection, no 无视无法选择)
{
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) }, { chessId: BAIT, row: 10, col: 5 }],
enemies: [{ key: SFHU, pos: [9, 6.5], mods: { hpMul: 0.4, speedMul: 0 } }],
});
const u = h.unit(TIPPI[0]), bait = h.unit(BAIT);
let slowAir = false, baitSlow = false;
for (let i = 0; i < 25 * 30; i++) { h.step(); if (u.s.flags.liftoff && u.findBuff('ab:teaBoom')) slowAir = true; if (bait.findBuff('ab:teaBoom')) baitSlow = true; }
assert.ok(!h.b.enemies.some((e) => e.alive && e.defId === SFHU), '烹泉 died');
assert.ok(u.s.flags.liftoff, 'she is still airborne');
const blast = (t) => h.hooksOf('damaged').filter((c) => c.target === t && (c.dmg?.tags || []).includes('teaBoom'));
assert.equal(blast(u).length, 0, 'no blast on her');
assert.ok(blast(bait).length > 0 && baitSlow, '角峰 takes the blast and the slow');
assert.ok(!slowAir, 'no 【烹泉减益】 on her');
done(h);
}
// 鼠王 【沙狱】 centred on 角峰 next to her: neither its ATK cut nor its DoT attaches to her
{
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) }, { chessId: BAIT, row: 10, col: 5 }],
enemies: [{ key: MOUSEK, pos: [9, 8], mods: { hpMul: 1e4, speedMul: 0, atkMul: 0.01 } }, { key: LAZERD, pos: [9, 6.2], mods: { hpMul: 1e4, speedMul: 0, atkMul: 0 } }],
});
const u = h.unit(TIPPI[0]), bait = h.unit(BAIT);
assert.ok(h.runUntil(() => u.s.flags.liftoff, 3), 'takes off');
const m = h.b.enemies.find((e) => e.defId === MOUSEK);
const storm = m.mem.ab.list.find((a) => a && a.cd === m.mem.ab.sk.SandStorm.cd && a.fire);
storm.left = 0.1; // 【沙狱】 now (its initial cooldown is 60 s)
h.run(2);
assert.ok(bait.findBuff('ab:sandStorm'), '沙狱 on 角峰');
assert.ok(u.s.flags.liftoff && !u.findBuff('ab:sandStorm'), 'not on the airborne 蒂比 next to him');
done(h);
}
// “帝国的甲胄” entrance barrage: locks 角峰 (she cannot be selected) and hits the highest-HP unit she is not
{
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) }, { chessId: BAIT, row: 10, col: 5 }],
enemies: [{ key: LAZERD, pos: [9, 7], mods: { hpMul: 1e4, speedMul: 0, atkMul: 0 } }],
});
const u = h.unit(TIPPI[0]), bait = h.unit(BAIT);
assert.ok(h.runUntil(() => u.s.flags.liftoff, 3), 'takes off');
bait.hp = Math.min(bait.hp, 100); // she has the most HP in the 3×3
const e = h.spawn(VTSK, { pos: [9, 8], routeIndex: 0, mods: { speedMul: 0, atkMul: 0.01 } });
h.run(1.5);
const shots = (t) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === e && !c.dmg.isAttack).length;
assert.equal(shots(u), 0, 'no shot on her');
assert.equal(shots(bait), ds.rawEnemy(VTSK).skills.find((s) => s.prefabKey === 'Appear').bb.times, 'every shot hits 角峰 instead of being wasted on her');
done(h);
}
});