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:
sganggs committed 2026-10-03 07:17:49 +08:00
1 parent a7ec6a1438
commit 80f5bf1930
12 files changed
+363 -46

No files matched your search

+3 -2
View File
@@ -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] }] });
+2 -1
View File
@@ -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();
}
});
});
+177
View File
@@ -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);
});