- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
399 lines
23 KiB
JavaScript
399 lines
23 KiB
JavaScript
// test/sim/playtest6-skills.test.js — user playtest #6, workstream WE (skill triggers and buffs), with the real data and kits.
|
||
// #15 "技能范围比攻击范围大的技能在敌人进入技能范围时不触发": PRTS 卫戍协议/帮助 §作战阶段 技能操作 — "携带拥有技能范围的技能
|
||
// (非攻击距离增加)的干员:不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能" (SKILL_RANGE);
|
||
// an attack-range change keeps the basic strategy; the class rows (重装 "不受技能范围影响,受到伤害时释放技能" …) cover every
|
||
// MANUAL skill of the class; "自动操作具有3s冷却"; GDGLOW_SKILL_2 "全场存在可选目标时释放技能".
|
||
// #16 "古米的治疗队友的技能在身边队友受伤时并没触发,古米接敌之后才触发": 备用军粮 is 自动触发 (no 技能策略); PRTS 备注 "此技能
|
||
// 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式…直至古米完成一次普通攻击的治疗". Audit: 塞雷娅
|
||
// S1 "仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击" — checked at her attack (≤ half HP), no waiting.
|
||
// #8 "新约能天使2技能给队友套护盾好像没生效": PRTS 备注 "选择的友方干员为攻击范围内仇恨值最高的我方干员"; "获得的屏障均以
|
||
// 自身生命上限为标准计算;屏障每秒衰减量为:初始屏障量/30".
|
||
// #3 "忍冬的叙拉古阵营隐身不会结束" (friend): the 叙拉古 隐匿 lasts its bond time; her S3 迷彩 (ba.camou "不阻挡时不成为敌方
|
||
// 普通攻击的目标"; PRTS 异常效果: "与'阻挡时解除'没有直接关系") keeps every enemy but the one she blocks off her, as
|
||
// 隐匿 does, lasts until her next cast ("直至下一次开启技能"), is not 隐匿 and no longer merges with a timed 隐匿 (伪装服).
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||
import { absoluteRangeKeys, canTargetAlly } from '../../server/sim/targeting.js';
|
||
import { flagsOf } from '../../server/sim/snapshot.js';
|
||
import { AUTO_OP_COOLDOWN, COLS } from '../../server/sim/constants.js';
|
||
import { UF } from '../../shared/constants.js';
|
||
import { statusIconKey } from '../../public/js/render/style.js';
|
||
|
||
const ds = getDefaultSource();
|
||
const raw = (id) => ds.rawChess(id);
|
||
const skillOf = (id, skillId) => raw(id).skills.find((s) => s.skillId === skillId);
|
||
const approx = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||
const noGarrison = (...ids) => Object.fromEntries(ids.map((id) => [id, { ...raw(id), garrisonIds: [] }]));
|
||
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||
const HOOKS = ['damaged', 'heal', 'skillStart', 'skillEnd', 'attack', 'statusApplied'];
|
||
const run = (o) => makeBattle({ seed: 3, autoFinish: false, timeLimit: 300, hooks: HOOKS, captureNoisy: true, ...o });
|
||
const starts = (h, u) => h.hooksOf('skillStart').filter((c) => c.unit === u);
|
||
const fill = (u) => u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
|
||
|
||
// =================================================================================================================
|
||
// #15 skill ranges
|
||
|
||
test('#15 德克萨斯 S2 剑雨 (skill range 13 tiles, attack range 2): an enemy entering the skill range casts it — no attack, stealthed ones count', () => {
|
||
const id = 'chess_char_1_08_a';
|
||
const stealthy = dummy('enemy_sneak');
|
||
for (const sneak of [false, true]) {
|
||
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_sneak: stealthy }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }] });
|
||
const u = h.unit(id);
|
||
assert.equal(u.skill.id, 'skchr_texas_2');
|
||
assert.equal(u.skill.rule, 'SKILL_RANGE');
|
||
h.step();
|
||
fill(u);
|
||
h.run(1);
|
||
assert.equal(u.skill.activations, 0, 'nobody in the skill range');
|
||
// (10,6): two tiles ahead — inside 剑雨's range, outside her 2-tile attack range
|
||
const keys = new Set(absoluteRangeKeys(u.def.skill.rangeGrid, 10, 4, u.dir, 0));
|
||
assert.ok(keys.has(10 * COLS + 6) && !u.rangeKeySet.has(10 * COLS + 6));
|
||
const e = h.spawn(sneak ? 'enemy_sneak' : 'enemy_d', { pos: [10, 6] });
|
||
if (sneak) h.b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true });
|
||
h.step(2);
|
||
assert.equal(u.skill.activations, 1, `cast at once${sneak ? ' (无视其不可选中: a stealthed enemy counts)' : ''}`);
|
||
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, 'without attacking');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('#15 audit: every MANUAL chess skill with its own 技能范围 casts with an enemy only on a skill-range tile (normal + elite)', () => {
|
||
const pool = ds.chessIds().filter((id) => (raw(id).skills || []).length); // hidden chess too (effects bring them)
|
||
let n = 0;
|
||
for (const id of pool) {
|
||
for (const s of raw(id).skills || []) {
|
||
if (s.trigger?.rule !== 'SKILL_RANGE') continue;
|
||
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 5, skillIndex: s.index }] });
|
||
const u = h.unit(id);
|
||
assert.equal(u.skill.id, s.skillId);
|
||
assert.equal(u.skill.rule, 'SKILL_RANGE', `${id} ${s.skillId}`);
|
||
h.step();
|
||
const skill = absoluteRangeKeys(s.rangeGrid, 10, 5, u.dir, 0).filter((k) => { const r = Math.floor(k / COLS), c = k % COLS; return r >= 9 && r <= 12 && c >= 2 && c <= 10 && k !== 10 * COLS + 5; });
|
||
const outside = skill.filter((k) => !u.rangeKeySet.has(k));
|
||
const k = (outside.length ? outside : skill)[0];
|
||
assert.ok(k != null, `${id} ${s.skillId}: a field tile in the skill range`);
|
||
fill(u);
|
||
h.spawn('enemy_d', { pos: [Math.floor(k / COLS), k % COLS] });
|
||
h.run(0.5);
|
||
assert.ok(u.skill.activations >= 1, `${id} ${s.skillId} (${s.name}): cast by an enemy on its skill range${outside.length ? ' outside the attack range' : ''}`);
|
||
done(h);
|
||
n++;
|
||
}
|
||
}
|
||
assert.ok(n >= 20, `${n} SKILL_RANGE skills`);
|
||
});
|
||
|
||
test('#15 an attack-range change ("攻击范围扩大") keeps the basic strategy: 银灰 S3 waits for an enemy in his initial range', () => {
|
||
const id = 'chess_char_4_22_a';
|
||
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 3 }] });
|
||
const u = h.unit(id);
|
||
assert.equal(u.skill.id, 'skchr_svrash_3');
|
||
assert.equal(u.skill.rule, 'DEFAULT');
|
||
h.step();
|
||
fill(u);
|
||
const far = absoluteRangeKeys(u.def.skill.rangeGrid, 10, 3, u.dir, 0).find((k) => !u.rangeKeySet.has(k) && Math.floor(k / COLS) === 10);
|
||
h.spawn('enemy_d', { pos: [10, far % COLS] });
|
||
h.run(1);
|
||
assert.equal(u.skill.activations, 0, 'an enemy only in the expanded range: no cast (PRTS "非攻击距离增加")');
|
||
h.spawn('enemy_d', { pos: [10, 4] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'an enemy in the initial range: cast before the attack');
|
||
done(h);
|
||
});
|
||
|
||
test('#15 重装 "不受技能范围影响,受到伤害时释放技能": 瑕光 S3 ignores enemies in its skill range until she is hit', () => {
|
||
const id = 'chess_char_3_12_a';
|
||
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_hit: dummy('enemy_hit', { atk: 200, bat: 1 }) }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }] });
|
||
const u = h.unit(id);
|
||
assert.equal(u.skill.id, 'skchr_blemsh_3');
|
||
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
|
||
h.step();
|
||
fill(u);
|
||
h.spawn('enemy_d', { pos: [10, 5] });
|
||
h.run(2);
|
||
assert.equal(u.skill.activations, 0, 'enemies in range, attacked by her, but she is not hit');
|
||
h.spawn('enemy_hit', { pos: [10, 4] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'the first hit casts it');
|
||
assert.equal(starts(h, u)[0].reason, 'TAKE_DAMAGE');
|
||
done(h);
|
||
});
|
||
|
||
test('#15 the automatic operations cool down 3 s: 锏 S2 (2 charges, skill range) spends its charges 3 s apart', () => {
|
||
const id = 'chess_char_6_19_b';
|
||
const s = skillOf(id, 'skchr_blkkgt_2');
|
||
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index }], enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
|
||
const u = h.unit(id);
|
||
assert.equal(u.skill.maxCharges, 2);
|
||
h.step();
|
||
fill(u);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
|
||
const t1 = h.b.time;
|
||
h.run(AUTO_OP_COOLDOWN - 0.2);
|
||
assert.equal(u.skill.activations, 1, 'the stored charge waits');
|
||
assert.ok(h.runUntil(() => u.skill.activations === 2, 1));
|
||
assert.ok(h.b.time - t1 >= AUTO_OP_COOLDOWN - 1e-6);
|
||
done(h);
|
||
});
|
||
|
||
test('#15 "作战开始时部署的单位将进入冷却": a MANUAL skill ready at the battle start waits 3 s after the initial deployment (the engine default); an AUTO one does not', () => {
|
||
const rec = (id, skillType) => chessRec({ id, profession: 'SUPPORT', subProfessionId: 'bard', stats: { atk: 0 }, skill: { skillType, spCost: 10, initSp: 10, durationType: 'NONE', duration: 5, trigger: { rule: 'SP_FULL' } } });
|
||
// flags.startOpCooldown undefined = the Battle default the matches use (the harness otherwise turns it off)
|
||
const h = run({ flags: { startOpCooldown: undefined }, defs: { chess: { t_man: rec('t_man', 'MANUAL'), t_auto: rec('t_auto', 'AUTO') } },
|
||
units: [{ chessId: 't_man', row: 10, col: 3 }, { chessId: 't_auto', row: 10, col: 6 }] });
|
||
assert.equal(h.b.flags.startOpCooldown, AUTO_OP_COOLDOWN);
|
||
const man = h.unit('t_man'), auto = h.unit('t_auto');
|
||
h.step();
|
||
assert.ok(man.skill.ready && man.skill.rule === 'SP_FULL');
|
||
assert.equal(auto.skill.activations, 1, 'AUTO: at once');
|
||
assert.ok(h.runUntil(() => man.skill.activations === 1, 5));
|
||
assert.ok(h.b.time >= AUTO_OP_COOLDOWN - 1e-6, `MANUAL: not before ${AUTO_OP_COOLDOWN} s (${h.b.time})`);
|
||
done(h);
|
||
});
|
||
|
||
test('#15 GDGLOW_SKILL_2 "全场存在可选目标时释放技能": 荒芜拉普兰德 S3 casts with an enemy anywhere on the field, 纯烬艾雅法拉 S3 with an injured ally anywhere', () => {
|
||
const w = 'chess_char_6_18_a';
|
||
let h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(w) }, units: [{ chessId: w, row: 12, col: 2 }] });
|
||
let u = h.unit(w);
|
||
assert.equal(u.skill.rule, 'GDGLOW_SKILL_2');
|
||
h.step();
|
||
fill(u);
|
||
h.run(1);
|
||
assert.equal(u.skill.activations, 0, 'no enemy');
|
||
h.spawn('enemy_d', { pos: [9, 10] }); // far outside her range
|
||
assert.ok(!u.rangeKeySet.has(9 * COLS + 10));
|
||
h.step(2);
|
||
assert.equal(u.skill.activations, 1, 'an enemy anywhere');
|
||
done(h);
|
||
const a = 'chess_char_6_20_a';
|
||
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000 } });
|
||
h = run({ defs: { chess: { ...noGarrison(a), t_mate: mate } }, units: [{ chessId: a, row: 12, col: 2 }, { chessId: 't_mate', row: 9, col: 10 }] });
|
||
u = h.unit(a);
|
||
assert.equal(u.skill.rule, 'GDGLOW_SKILL_2');
|
||
h.step();
|
||
fill(u);
|
||
h.run(1);
|
||
assert.equal(u.skill.activations, 0, 'nobody to heal');
|
||
h.unit('t_mate').hp = 5000;
|
||
h.step(2);
|
||
assert.equal(u.skill.activations, 1, 'an injured ally anywhere');
|
||
done(h);
|
||
});
|
||
|
||
// =================================================================================================================
|
||
// #16 古米 / 塞雷娅 heal skills
|
||
|
||
test('#16 古米 备用军粮: an injured ally next to her casts it with no enemy on the field; her next attack heals that ally (normal + elite)', () => {
|
||
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
|
||
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0 } });
|
||
const h = run({ defs: { chess: { ...noGarrison(id), t_mate: mate } }, units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_mate', row: 11, col: 5 }] });
|
||
const u = h.unit(id), m = h.unit('t_mate');
|
||
assert.equal(u.skill.id, 'skchr_sunbr_1');
|
||
h.step();
|
||
fill(u);
|
||
h.run(2);
|
||
assert.equal(u.skill.activations, 0, 'nobody injured');
|
||
m.hp = 4000;
|
||
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m), 2), 'healed without any enemy (before: only once she was hit)');
|
||
const heal = h.hooksOf('heal').find((c) => c.source === u && c.target === m);
|
||
const tb = raw(id).trait.bb;
|
||
const k = tb.hp_ratio != null && 0.4 < tb.hp_ratio ? tb.heal_scale : 1;
|
||
approx(heal.amount, u.s.atk * u.def.skill.bb.heal_scale * k, `${id} heal_scale × ATK`);
|
||
assert.equal(starts(h, u)[0].reason, 'SKILL_RANGE');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('#16 古米 备用军粮 with an enemy blocked: the cast turns her next attack into the heal (the enemy is not hit by it); no second charge on the same heal', () => {
|
||
const id = 'chess_char_1_10_b';
|
||
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0 } });
|
||
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_mate: mate, t_mate2: { ...mate, chessId: 't_mate2', baseId: 't_mate2' } } },
|
||
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_mate', row: 11, col: 4 }, { chessId: 't_mate2', row: 9, col: 4 }], enemies: [{ key: 'enemy_d', pos: [10, 4] }] });
|
||
const u = h.unit(id), m = h.unit('t_mate'), m2 = h.unit('t_mate2');
|
||
h.step();
|
||
assert.equal(u.skill.maxCharges, 2);
|
||
u.atkCd = 1.5; // the heal waits for her next attack
|
||
fill(u);
|
||
m.hp = 3000;
|
||
h.run(1);
|
||
assert.equal(starts(h, u).length, 1, 'one charge cast');
|
||
assert.equal(u.skill.charges, 1, 'the other stays stored while the heal waits');
|
||
const hits0 = h.hooksOf('damaged').filter((c) => c.source === u).length;
|
||
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m), 2));
|
||
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, hits0, 'that attack was the heal, not a hit');
|
||
m2.hp = 3000;
|
||
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m2), 5), 'the stored charge heals the next injured ally');
|
||
done(h);
|
||
});
|
||
|
||
test('#16 塞雷娅 S1 急救 (audit): checked at her attack — an ally of her area at ≤ half HP turns that attack into the heal; healed back meanwhile ⇒ she just attacks', () => {
|
||
// PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击"; text corrected to "血量小于等于一半"
|
||
const id = 'chess_char_5_11_a';
|
||
const s = skillOf(id, 'skchr_demkni_1');
|
||
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0, blockCnt: 0 } });
|
||
const setup = (withEnemy) => {
|
||
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_mate: mate } },
|
||
units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index }, { chessId: 't_mate', row: 10, col: 5 }],
|
||
enemies: withEnemy ? [{ key: 'enemy_d', pos: [10, 4] }] : [] });
|
||
h.step();
|
||
return [h, h.unit(id), h.unit('t_mate')];
|
||
};
|
||
let [h, u, m] = setup(false);
|
||
assert.equal(u.skill.rule, 'DEFAULT');
|
||
fill(u);
|
||
m.hp = 4000;
|
||
h.run(2);
|
||
assert.equal(u.skill.activations, 0, 'no enemy, no attack: no cast');
|
||
done(h);
|
||
[h, u, m] = setup(true);
|
||
const e = h.enemies()[0];
|
||
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e).length;
|
||
fill(u);
|
||
m.hp = 6000;
|
||
assert.ok(h.runUntil(() => hits() >= 2, 5), '60 %: she attacks the enemy she blocks');
|
||
assert.equal(u.skill.activations, 0, 'and casts nothing');
|
||
// an ally at exactly half HP: her next attack is the heal (the enemy is not hit by it)
|
||
u.atkCd = 0.5;
|
||
m.hp = 5000;
|
||
const hits0 = hits();
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
|
||
assert.equal(starts(h, u)[0].reason, 'DEFAULT');
|
||
const heal = h.hooksOf('heal').find((c) => c.source === u && c.target === m);
|
||
assert.ok(heal, 'healed at once, in place of that attack');
|
||
approx(heal.amount, u.s.atk * s.bb.heal_scale);
|
||
assert.equal(hits(), hits0, 'that attack hit nobody');
|
||
assert.ok(!u.skill.pending);
|
||
// dropped to 40 %, healed back to 80 % before her attack: no cast, she keeps attacking (no heal mode left waiting)
|
||
fill(u);
|
||
u.atkCd = 1;
|
||
m.hp = 4000;
|
||
h.run(0.5);
|
||
m.hp = 8000;
|
||
const hits1 = hits();
|
||
h.run(4);
|
||
assert.equal(u.skill.activations, 1, 'no second cast');
|
||
assert.ok(hits() >= hits1 + 3, `she kept hitting the enemy (${hits() - hits1} hits in 4 s)`);
|
||
// the engine's safety net: a cast whose ally condition fails before the attack is withdrawn, its charge returned
|
||
m.hp = 4000;
|
||
u.atkCd = 5;
|
||
assert.ok(u.skill.activate('test'));
|
||
assert.ok(u.skill.pending && u.skill.charges === 0);
|
||
m.hp = 10000;
|
||
h.step();
|
||
assert.ok(!u.skill.pending && u.skill.charges === 1, 'withdrawn, the charge kept');
|
||
done(h);
|
||
});
|
||
|
||
// =================================================================================================================
|
||
// #8 新约能天使 S2
|
||
|
||
test('#8 新约能天使 S2 开火成瘾症: the ally is the highest-仇恨值 operator of her range (taunt, then the latest deployed); both shields = 180 % of their own max HP, −initial/30 per s', () => {
|
||
const id = 'chess_char_6_13_a';
|
||
const mate = (key, o = {}) => chessRec({ id: key, profession: 'SNIPER', skill: null, stats: { maxHp: 3000, atk: 0, aspd: 100, ...o } });
|
||
// rows deploy top first (row 11 before row 10): t_back (11,4) comes before t_front (10,5); both are in her range
|
||
const setup = (taunt) => {
|
||
const h = run({
|
||
defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_back: mate('t_back', { aspd: 150 }), t_front: mate('t_front', { maxHp: 5000 }) } },
|
||
units: [{ chessId: id, row: 10, col: 3 }, { chessId: 't_back', row: 11, col: 4 }, { chessId: 't_front', row: 10, col: 5 }],
|
||
enemies: [{ key: 'enemy_d', pos: [10, 6] }],
|
||
});
|
||
const u = h.unit(id);
|
||
h.step();
|
||
if (taunt) h.b.addBuff(h.unit('t_back'), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
|
||
fill(u);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||
return h;
|
||
};
|
||
let h = setup(false);
|
||
const u = h.unit(id), back = h.unit('t_back'), front = h.unit('t_front');
|
||
const bb = u.def.skill.bb;
|
||
assert.ok(front.aggroSeq > back.aggroSeq);
|
||
assert.ok(front.findBuff('angel2:stolen') && !back.findBuff('angel2:stolen'), 'the latest deployed (not the fastest attacker) is chosen');
|
||
approx(front.s.shield, front.s.maxHp * bb.shield_max_hp_ratio, 'her shield: 180 % of HER max HP');
|
||
approx(u.s.shield, u.s.maxHp * bb.shield_max_hp_ratio, 'his own: of his');
|
||
assert.ok(flagsOf(front) & UF.SHIELD, 'the ally shows the shield (b.snap flag: the HP-bar shield line)');
|
||
const st = h.eventsOf('status').find((e) => e[1] === front.id && e[2] === 'angel2:barrier' && e[3] === 1);
|
||
assert.ok(st && statusIconKey(st[2]) === 'shield', 'and the shield icon in its status row');
|
||
const s0 = front.s.shield;
|
||
h.run(3);
|
||
approx(front.s.shield, s0 * (1 - 3 / bb.shield_max_duration), 'decays by initial/30 per second', 0.02);
|
||
h.b.dealDamage(h.enemies()[0], front, { amount: 1000, type: 'true' });
|
||
assert.equal(front.hp, front.s.maxHp, 'the shield absorbs the damage');
|
||
done(h);
|
||
h = setup(true);
|
||
assert.ok(h.unit('t_back').findBuff('angel2:stolen'), 'a higher taunt level comes first');
|
||
done(h);
|
||
});
|
||
|
||
// =================================================================================================================
|
||
// #3 忍冬: 叙拉古 隐匿 and her S3 迷彩
|
||
|
||
test('#3 忍冬 with 6 叙拉古: the bond 隐匿 ends at 32 + 0.4 × layers s; her S3 迷彩 is not 隐匿, keeps all but the enemy she blocks off her, until the next cast', () => {
|
||
const id = 'chess_char_3_18_a', L = 20;
|
||
const h = run({
|
||
defs: { enemies: { enemy_f: dummy('enemy_f', { hp: 1 }), enemy_b: dummy('enemy_b'), enemy_r: dummy('enemy_r', { atk: 50, range: 3, applyWay: 'RANGED', bat: 1 }) }, chess: noGarrison(id) },
|
||
units: [{ chessId: id, row: 10, col: 4 }], bonds: { siracusaShip: { count: 6, active: true, tier: 2, layers: L } },
|
||
});
|
||
const u = h.unit(id);
|
||
h.step();
|
||
assert.ok(u.s.flags.stealth, '叙拉古 6: 隐匿 after deployment');
|
||
// a kill during 隐狐之艺 ⇒ 迷彩 at its end
|
||
fill(u);
|
||
h.spawn('enemy_f', { pos: [10, 5] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||
h.runUntil(() => !u.skill.active, 20);
|
||
assert.ok(u.s.flags.camou, '迷彩 after the skill (a kill during it)');
|
||
h.runUntil(() => h.b.time >= 32 + 0.4 * L + 0.1, 60);
|
||
assert.ok(!u.s.flags.stealth, 'the bond 隐匿 is over at its time');
|
||
assert.ok(u.s.flags.camou && !u.blocking.length);
|
||
assert.ok(flagsOf(u) & UF.STEALTH, 'the 迷彩 shows (see-through)');
|
||
const r = h.spawn('enemy_r', { pos: [11, 6] });
|
||
assert.equal(canTargetAlly(r, u, true), false, 'and works (no ranged target)');
|
||
// an enemy she blocks: only that one may attack her — 迷彩, like 隐匿, is not lifted by blocking (PRTS 异常效果)
|
||
u.skill.charges = 0; u.skill.sp = 0; // (her SP is full again by now: no new cast on that enemy yet)
|
||
const b = h.spawn('enemy_b', { pos: [10, 4] });
|
||
h.step(2);
|
||
assert.equal(b.blockedBy, u);
|
||
assert.ok(flagsOf(u) & UF.STEALTH, 'blocking: still shown camouflaged');
|
||
assert.equal(canTargetAlly(b, u, true), true, 'the enemy she blocks may target her');
|
||
assert.equal(canTargetAlly(r, u, true), false, 'the ranged enemy still may not');
|
||
h.run(3);
|
||
assert.equal(h.hooksOf('damaged').filter((c) => c.source === r && c.target === u).length, 0, 'and does not');
|
||
// it lasts until her next cast
|
||
fill(u);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 2, 5));
|
||
assert.ok(!u.s.flags.camou, 'the next cast ends it');
|
||
done(h);
|
||
});
|
||
|
||
test('#3 伪装服 (timed 隐匿) on 忍冬 is neither made permanent nor stripped by her S3 迷彩 (their keys used to collide)', () => {
|
||
const id = 'chess_char_3_18_a';
|
||
const item = 'chess_item_4_04_e_a';
|
||
const h = run({
|
||
defs: { enemies: { enemy_f: dummy('enemy_f', { hp: 1 }) }, chess: noGarrison(id) },
|
||
units: [{ chessId: id, row: 10, col: 4, items: [item] }],
|
||
});
|
||
const u = h.unit(id);
|
||
h.step();
|
||
fill(u);
|
||
h.spawn('enemy_f', { pos: [10, 5] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||
h.runUntil(() => !u.skill.active, 20);
|
||
assert.ok(u.s.flags.camou);
|
||
h.b.dealDamage(null, u, { amount: 10, type: 'true' }); // 伪装服: the first damage taken ⇒ 隐匿 for its duration
|
||
const cloak = u.findBuff('stealth');
|
||
assert.ok(cloak && Number.isFinite(cloak.timeLeft) && u.s.flags.stealth, '伪装服 隐匿, timed');
|
||
h.run(cloak.timeLeft + 0.2);
|
||
assert.ok(!u.s.flags.stealth, 'the 隐匿 ends at its time although the 迷彩 lasts');
|
||
assert.ok(u.s.flags.camou);
|
||
// and her next cast removes the 迷彩 only (a fresh 伪装服 隐匿 would stay: another key)
|
||
h.b.applyStatus(u, 'stealth', { duration: 5, source: u });
|
||
fill(u);
|
||
h.spawn('enemy_f', { pos: [10, 5] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 2, 5));
|
||
assert.ok(!u.s.flags.camou && u.s.flags.stealth, 'the cast strips the 迷彩, not a 隐匿');
|
||
done(h);
|
||
});
|