diff --git a/docs/SIM.md b/docs/SIM.md index 4be9888f..57b5fdd0 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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) diff --git a/server/sim/content/bosses.js b/server/sim/content/bosses.js index acaec640..3263b82f 100644 --- a/server/sim/content/bosses.js +++ b/server/sim/content/bosses.js @@ -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; diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index bc846fb4..67a62fdd 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -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 }); diff --git a/server/sim/damage.js b/server/sim/damage.js index 7610587c..eabceffe 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -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); diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 1970be33..f72b0f94 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -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); diff --git a/test/sim/feedback1f-liftoff.test.js b/test/sim/feedback1f-liftoff.test.js index 4e3f6926..8d841cc7 100644 --- a/test/sim/feedback1f-liftoff.test.js +++ b/test/sim/feedback1f-liftoff.test.js @@ -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); + } +});