feedback1f WT: 蒂比's 起飞 is 对地规避 — ground enemies no longer target or hit her while airborne
Official: ba.liftoff 起飞 "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人"; PRTS 术语释义 起飞 "包含对地规避(无法被不同 阵营行动方式为地面的单位选中)… 起飞后单位的行动方式依旧是地面"; PRTS 异常效果 MOTION_TARGET_FREE; PRTS 行动方式 "起飞的干员仍然 是地面单位". Cause: 起飞 was `unit.ground = false` (an operator on a high tile). Melee enemies were released, but every ranged ground enemy kept shooting her and the AoE / skills of ground enemies hit her (控潮术师 6 hits in one S2 window). Now 起飞 is the skill buff flag `liftoff`: - targeting.js evadesGround: no ground enemy (not Unit.isFlying) selects her (canTargetAlly; the content picks that bypass it: 周围四格 additions, chain / bounce jumps, the 自行炮 shells); flyers, 近地悬浮 and 浮空 enemies still do. - damage.js / Battle.applyStatus: a ground enemy's damage, element fill and statuses never land on her (area damage, shots under way); sourceless damage and `ignoreSelect` zones (【污染秽蚀】 "可对空,无视无法选择") still do. - Battle._blockerFor: she blocks no ground enemy; flyers at the air radius as before. - She stays a ground unit on her low tile: 污染秽蚀 at the low rate, 隐德来希 S2 can put a 血镰 on her (可对空).
This commit is contained in:
1 parent
a7ec6a1438
commit
80f5bf1930
12 files changed
+363
-46
No files matched your search
@@ -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;
|
||||
|
||||
+6
-2
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 })],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
+18
-2
@@ -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 });
|
||||
|
||||
+20
-2
@@ -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,
|
||||
|
||||
@@ -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] }] });
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user