Files
ark-stronghold-protocol/test/content/op_crosly.test.js
T
sganggs b55d4f9d04 feedback5 O18: 自选 kits for 阿, 傀影 (with his 镜中虚影) and 弑君者
Three owned-6★ 自选 picks get operator kits (kits/ops/op-haak.js, op-phatom.js, op-crosly.js; appended to
OPERATOR_KIT_FILES): every skill, both talents, the trait and every module at stage 1 / 3 in every DIY form, from the
data, PRTS (备注) and the client's battle logic (charpacks, skill / equip prefabs, buff templates).

- 阿: 怪杰 drain (profession default), 混合药物射击 (one of four per attack, GEE-X stage 2+ "all four" roll), 药剂扩散
  (healing received, GEE-Y stage 3 deploy heal on the ally ahead), S1 ASPD, S2 / S3 爆发剂 (15 hits of 500 fixed ATK on
  the ally of his range ahead, then the buff on both), GEE-X SP recovery while HP >= 80 %.
- 傀影: S1 dodge + physical-only barrier, S2 stacked ATK used per damage instance, S3 burst (x-4, ground, radial 小力
  push, one random status per cast); the twin's kit (its skill copy, killed when he leaves, back on its tile once its
  own redeploy time has run from its last deployment and he stands, 5 DP), 虚影精通, EXE-X (refund N/A), EXE-Y
  (lonely ATK, stage 3 ATK on both), ISW-A attributes only.
- 弑君者: the smoke's 命中率 cut (x-4, S3 x-1, S2 x2; battle-wide hit handler), 弑君者威名 (fresh ground enemies x1.2,
  EXE-Y stage 3 x1.35 and stun x1.5), S1 ATK / dodge, S2 no attacks + taunt -1 + end blast, S3 隐匿 + block 0 +
  2 s strikes with 6 s marks.

Tests: test/content/op_haak.test.js (10), op_phatom.test.js (8), op_crosly.test.js (8).
Golden: only diy moved — diy-094..diy-100 field the 78 new configurations (阿 24, 傀影 30, 弑君者 24; no summon is
placed by the family), diy-101..diy-104 are the 预备干员 / prototype battles re-packed after them (new index, stage
and seed). roster, bonds, fields, matches and standins are unchanged.
2026-10-06 03:04:57 +08:00

303 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/content/op_crosly.test.js — the 自选 operator kit of 弑君者 (char_1502_crosly, 6★ 处决者; kit
// server/sim/content/kits/ops/op-crosly.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, EXE-X 弑君刃
// or EXE-Y 木炭画 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that slot
// status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_crosly.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const CROSLY = 'char_1502_crosly';
const FORMS = BACKUPS.units[CROSLY].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const EX = 'uniequip_002_crosly', EY = 'uniequip_003_crosly';
const S1 = 'skchr_crosly_1', S2 = 'skchr_crosly_2', S3 = 'skchr_crosly_3';
const YAK = 'chess_char_1_02_a', TEXAS = 'chess_char_1_08_a';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
/** The talents a form fights with: the module's changes composed (index 0 / 1, −1 = hidden). */
const talentOf = (tier, elite, mod, i) => {
const base = formOf(tier, elite).talents.find((t) => t.index === i)?.bb ?? {};
const ch = (elite ? modOf(tier, mod)?.talentChanges ?? [] : []).find((t) => t.talentIndex === i)?.bb ?? {};
return { ...base, ...ch };
};
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_hitter: dummy('enemy_hitter', { atk: 200, range: 1.5, bat: 0.5 }), enemy_caster: dummy('enemy_caster', { atk: 200, range: 1.5, bat: 0.5, dmgType: 'arts' }),
enemy_flyhitter: dummy('enemy_flyhitter', { atk: 200, range: 1.5, bat: 0.5, motion: 'FLY' }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, EX, EY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 弑君者 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CROSLY, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
/** Knock `u` out and bring it straight back on its tile (free). */
function redeployNow(h, u) {
h.b.kill(u, null);
assert.ok(h.b.redeploy(u, { free: true }), `${u.name} redeployed`);
}
/**
* Watch the 命中率 cut `hitter` suffers from now on: its attacks counted before (priority 295) and after (270) the smoke's
* handler (280); allies are kept alive. Returns () => { n, rate }.
*/
function watch(h, hitter) {
const seen = new Set(), passed = new Set();
h.b.on('hit', (c) => { if (c.source === hitter && c.dmg.isAttack) seen.add(c.dmg.attackId); }, { priority: 295 });
h.b.on('hit', (c) => { if (c.source === hitter && c.dmg.isAttack && !c.dmg.cancel) passed.add(c.dmg.attackId); }, { priority: 270 });
h.b.on('fatal', (c) => { if (c.unit.side === 'ally') c.prevented = true; });
return () => ({ n: seen.size, rate: seen.size ? 1 - passed.size / seen.size : 0 });
}
test('弑君者 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, melee ground-only, 22 s redeploy, 叙拉古, no 特质; each skill a timed deployment skill (被动 ON_DEPLOY), running from the deployment', () => {
assert.equal(OPERATOR_KITS[CROSLY], KITS[CROSLY]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null, rec = form.skills[skill];
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CROSLY, SLOT[tier], rec.skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.respawnTime, u.base.cost],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 22, 12], `${label(f)}: stats`);
assert.deepEqual([u.profile.attack, u.profile.dmgType, u.profile.canHitFly], ['melee', 'phys', false], `${label(f)}: 处决者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1 (S3 too)`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['siracusaShip'], []], `${label(f)}: bonds / 特质`);
assert.deepEqual([rec.skillType, rec.spType, u.skill.kind, u.skill.rule, u.skill.active, u.skill.activations], ['PASSIVE', 'ON_DEPLOY', 'duration', 'NEVER', true, 1], `${label(f)}: S${skill + 1} from the deployment`);
approx(u.skill.timeLeft, [rec.duration, rec.bb.duration, rec.duration][skill], `${label(f)}: S${skill + 1} duration`, 0.01);
assert.equal(u.s.blockCnt, skill === 2 ? 0 : 1, `${label(f)}: blocks ${skill === 2 ? 0 : 1}`);
done(h);
}
}
// the numbers (zh_CN): E2 Lv1 1322 / 425 / 273, E2 Lv60 1569 / 505 / 307; EXE-X +90 / +34 / +23 → +130 / +45 / +30,
// EXE-Y +84 / +63 → +135 / +88 HP / ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1322, 425, 1569, 505]);
assert.deepEqual([modOf(5, EX).attr, modOf(6, EX).attr, modOf(5, EY).attr, modOf(6, EY).attr],
[{ maxHp: 90, atk: 34, def: 23 }, { maxHp: 130, atk: 45, def: 30 }, { maxHp: 84, atk: 63 }, { maxHp: 135, atk: 88 }]);
});
test('a 自选 pick: 弑君者 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(CROSLY));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CROSLY), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: CROSLY, skillIndex: 2, uniEquipId: EY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: CROSLY, skillIndex: 2, uniEquipId: EY } } });
});
test('S1 尘烟蔽目 (被动): for 10 s from each deployment ATK +65 % / +80 % and 30 % / 40 % physical and arts dodge', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, EX]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.prob], [10, elite ? 0.8 : 0.65, elite ? 0.4 : 0.3]);
const { h, u } = field({ tier, elite, mod, skill: 0 });
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK');
assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts], [sk.bb.prob, sk.bb.prob], 'dodge');
h.run(10.1);
assert.deepEqual([u.skill.active, u.s.atk, u.s.dodgePhys, u.s.dodgeArts], [false, u.base.atk, 0, 0], 'over');
redeployNow(h, u);
assert.ok(u.skill.active && u.skill.activations === 2, 'again at a redeployment');
done(h);
}
});
test('S2 硝烟震爆 (被动): for 8 s no attacks and taunt level −1; then 270 % / 350 % ATK physical to every ground enemy of the x-4 smoke (fresh ones ×T2); no blast when she is stunned as it ends', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, EY]]) {
const sk = skillOf(tier, elite, S2), t1 = talentOf(tier, elite, mod, 1);
assert.deepEqual([sk.bb.duration, sk.bb.taunt_level, sk.bb['attack@atk_scale_s2'], sk.bb.talent_scale], [8, -1, elite ? 3.5 : 2.7, 2]);
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.equal(u.s.taunt, -1, 'taunt level −1');
const front = h.spawn('enemy_dummy', { pos: [10, 5] }), diag = h.spawn('enemy_dummy', { pos: [11, 3] });
const marked = h.spawn('enemy_dummy', { pos: [9, 4] });
const fly = h.spawn('enemy_fly', { pos: [9, 5] }), far = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step();
h.b.dealDamage(marked, u, { amount: 1, type: 'phys', canDodge: false }); // it has damaged her
h.run(7);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0, 'no attacks while it runs');
assert.ok(h.runUntil(() => !u.skill.active, 2), 'over after 8 s');
const blast = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('crosly:blast'));
assert.deepEqual(blast.map((c) => c.target).sort((a, b) => a.id - b.id), [front, diag, marked].sort((a, b) => a.id - b.id), 'the ground enemies of her x-4');
for (const c of blast) {
approx(c.amount, u.s.atk * sk.bb['attack@atk_scale_s2'] * (c.target === marked ? 1 : t1.damage_scale), `${c.target === marked ? 'marked' : 'fresh'}: ${sk.bb['attack@atk_scale_s2'] * 100} %`);
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true]);
}
assert.ok(!blast.some((c) => c.target === fly || c.target === far), 'flyer / outside untouched');
assert.equal(u.s.taunt, 0, 'taunt back');
// stunned as it ends: no blast
redeployNow(h, u);
h.run(7.5);
h.b.applyStatus(u, 'stun', { duration: 2, force: true });
const n0 = h.hooksOf('damaged').length;
h.run(1);
assert.ok(!u.skill.active, 'ended');
assert.equal(h.hooksOf('damaged').slice(n0).filter((c) => c.dmg?.tags?.includes('crosly:blast')).length, 0, 'stunned: no blast');
done(h);
}
});
test('S3 烽烟行刑场 (被动, 16 s): 隐匿 and block count 0 (enemies neither target nor are blocked by her); a strike every 2 s on one unmarked ground enemy of the x-1 — 2 hits × 150 % / 190 % ATK and 晕眩 1.5 s — marking it 6 s; her range stays 1-1; all back after', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, EX]]) {
const sk = skillOf(tier, elite, S3), t1 = talentOf(tier, elite, mod, 1);
assert.deepEqual([sk.duration, sk.rangeId, sk.bb['attack@s3_cd'], sk.bb['attack@times'], sk.bb['attack@atk_scale_s3'], sk.bb['attack@stun'], sk.bb.mark_duration],
[16, 'x-1', 2, 2, elite ? 1.9 : 1.5, 1.5, 6]);
const { h, u } = field({ tier, elite, mod, skill: 2 });
assert.deepEqual([!!u.s.flags.stealth, u.s.blockCnt, u.liveRangeGrid], [true, 0, formOf(tier, elite).rangeGrid], '隐匿, block 0, 1-1');
const a = h.spawn('enemy_dummy', { pos: [10, 5] }), b = h.spawn('enemy_dummy', { pos: [12, 4] }), c = h.spawn('enemy_dummy', { pos: [10, 6] });
const fly = h.spawn('enemy_fly', { pos: [9, 4] }), far = h.spawn('enemy_dummy', { pos: [10, 7] });
const hitter = h.spawn('enemy_hitter', { pos: [11, 4] });
h.run(15.5);
const hits = h.hooksOf('damaged').filter((x) => x.source === u && x.dmg?.isAttack);
assert.equal(hits.length % 2, 0, 'two hits a strike');
const strikes = [];
for (const x of hits) if (!strikes.length || Math.abs(strikes[strikes.length - 1].t - x.t) > 1e-6) strikes.push({ t: x.t, target: x.target });
assert.ok(strikes.length >= 7, `${strikes.length} strikes`);
for (let i = 1; i < strikes.length; i++) approx(strikes[i].t - strikes[i - 1].t, 2, 'every 2 s', 0.02);
assert.ok(hits.every((x) => [a, b, c, hitter].includes(x.target)), 'unmarked ground enemies of the x-1 only (no flyer, nothing 3 tiles away)');
assert.ok(!hits.some((x) => x.target === fly || x.target === far));
for (const x of hits) {
assert.deepEqual([x.type, x.dmg.isSkill], ['phys', true]);
approx(x.amount, u.s.atk * sk.bb['attack@atk_scale_s3'] * t1.damage_scale, `${sk.bb['attack@atk_scale_s3'] * 100} % ×T2 (fresh)`);
}
// the 6 s mark: never the same target twice within 6 s
const last = new Map();
for (const st of strikes) {
if (last.has(st.target)) assert.ok(st.t - last.get(st.target) >= 6 - 1e-6, `marked 6 s (${st.target.defId})`);
last.set(st.target, st.t);
}
const stuns = h.hooksOf('statusApplied').filter((x) => x.source === u && x.status === 'stun');
assert.equal(stuns.length, strikes.length, 'one 晕眩 a strike');
for (const x of stuns) approx(x.duration, sk.bb['attack@stun'], '1.5 s');
assert.equal(h.hooksOf('damaged').filter((x) => x.source === hitter && x.target === u).length, 0, '隐匿: the hitter beside her never targets her');
assert.equal(u.blocking.length, 0, 'blocks nothing');
h.run(1);
assert.deepEqual([u.skill.active, !!u.s.flags.stealth, u.s.blockCnt], [false, false, 1], 'all back after 16 s');
done(h);
}
});
test('T1 吞咽苦厄: while her skill runs, the attacks of ground enemies in her smoke miss 20 % (EXE-X stage 3: 25 %; S2: ×2) — x-4, S3 x-1; none outside it, none for air units, none once the skill is over', () => {
// S1, normal: a hitter in her x-4 (beside her) — 20 %; another two tiles ahead — 0 %
{
const t0 = talentOf(5, false, null, 0);
assert.deepEqual([t0.damage_hitrate_physical, t0.damage_hitrate_magical], [-0.2, -0.2]);
const { h, u } = field({ tier: 5, skill: 0, others: [{ uid: 2, chessId: YAK, row: 10, col: 7 }] });
const inside = h.spawn('enemy_caster', { pos: [11, 4] }), outside = h.spawn('enemy_hitter', { pos: [10, 6] });
u.skill.extend(400);
const wIn = watch(h, inside), wOut = watch(h, outside);
h.run(300);
const rIn = wIn(), rOut = wOut();
assert.ok(rIn.n > 300 && Math.abs(rIn.rate - 0.2) < 0.05, `arts attacker in the x-4: ${rIn.rate} of ${rIn.n}`);
assert.ok(rOut.n > 300 && rOut.rate === 0, `two tiles ahead: ${rOut.rate} of ${rOut.n}`);
u.skill.extend(-999);
h.step(2);
const wAfter = watch(h, inside);
h.run(60);
const after = wAfter();
assert.ok(after.n > 60 && after.rate === 0, 'skill over: no smoke');
done(h);
}
// S2 at EXE-X stage 3: 25 % × 2
{
const t0 = talentOf(6, true, EX, 0);
assert.equal(t0.damage_hitrate_physical, -0.25);
const { h, u } = field({ tier: 6, elite: true, mod: EX, skill: 1 });
const hitter = h.spawn('enemy_hitter', { pos: [10, 5] });
const fly = h.spawn('enemy_flyhitter', { pos: [9, 4] });
u.skill.extend(400);
const w = watch(h, hitter), wFly = watch(h, fly);
h.run(300);
const r = w(), rFly = wFly();
assert.ok(r.n > 300 && Math.abs(r.rate - 0.5) < 0.05, `S2 ×2: ${r.rate} of ${r.n}`);
assert.ok(rFly.n > 300 && rFly.rate === 0, `an air unit in the smoke: ${rFly.rate} of ${rFly.n}`);
done(h);
}
// S3: the x-1 reaches two tiles ahead
{
const { h, u } = field({ tier: 5, skill: 2, others: [{ uid: 2, chessId: YAK, row: 10, col: 7 }] });
const hitter = h.spawn('enemy_hitter', { pos: [10, 6] });
u.skill.extend(400);
const w = watch(h, hitter);
h.run(300);
const r = w();
assert.ok(r.n > 150 && Math.abs(r.rate - 0.2) < 0.06, `S3 x-1: ${r.rate} of ${r.n}`);
done(h);
}
});
test('T2 弑君者威名: physical damage she deals to a ground enemy that has not damaged her this deployment ×1.2 (EXE-Y stage 3: ×1.35, and her 晕眩 on it ×1.5); a 持续伤害 tick marks nothing; the marks go when she leaves the field', () => {
for (const f of [[5, false, null], [6, true, EY], [5, true, EY]]) {
const [tier, elite, mod] = f;
const t1 = talentOf(tier, elite, mod, 1), hidden = talentOf(tier, elite, mod, -1);
const y3 = elite && mod === EY && tier === 6;
assert.deepEqual([t1.damage_scale, hidden.one_minus_status_resistance ?? 0], y3 ? [1.35, 0.5] : [1.2, 0], label(f));
const { h, u } = field({ tier, elite, mod, skill: 0 });
h.run(10.1); // S1 over: plain attacks
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
const hitsOn = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg?.isAttack);
h.run(2);
approx(hitsOn().slice(-1)[0].amount, u.s.atk * t1.damage_scale, `${label(f)}: fresh ×${t1.damage_scale}`);
h.b.dealDamage(e, u, { amount: 5, type: 'true', canDodge: false, tags: ['dot'] });
h.run(2);
approx(hitsOn().slice(-1)[0].amount, u.s.atk * t1.damage_scale, `${label(f)}: a DoT tick marks nothing`);
h.b.dealDamage(e, u, { amount: 5, type: 'phys', canDodge: false });
h.run(2);
approx(hitsOn().slice(-1)[0].amount, u.s.atk, `${label(f)}: it damaged her ⇒ ×1`);
// EXE-Y stage 3: a stun of hers lasts ×1.5 on a fresh enemy only
const fresh = h.spawn('enemy_dummy', { pos: [9, 5] });
h.b.applyStatus(fresh, 'stun', { duration: 2, source: u });
h.b.applyStatus(e, 'stun', { duration: 2, source: u });
const st = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun').slice(-2);
approx(st[0].duration, y3 ? 3 : 2, `${label(f)}: fresh ×${y3 ? 1.5 : 1}`);
approx(st[1].duration, 2, `${label(f)}: marked ×1`);
// her redeployment clears the marks
redeployNow(h, u);
h.run(10.1);
h.run(2);
approx(hitsOn().slice(-1)[0].amount, u.s.atk * t1.damage_scale, `${label(f)}: fresh again after a redeployment`);
done(h);
}
});
test('EXE-X 弑君刃: the refund has no effect (no retreat in battle); EXE-Y 木炭画: ATK +10 % while no allied operator stands on the 4 tiles beside her (stages 1 and 3)', () => {
{
const { h, u } = field({ tier: 6, elite: true, mod: EX, skill: 0 });
assert.deepEqual(u.def.traitBb, { withdraw_cost_recover_ratio: 0.8 });
h.b.players[0].dp = 3;
h.b.retreat(u);
h.step();
assert.equal(h.b.players[0].dp, 3, 'no refund');
done(h);
}
for (const f of [[5, true, EY], [6, true, EY], [6, true, EX]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 7 }] });
const texas = h.unit(2);
assert.equal(!!u.findBuff('trait:crosly:lonely'), mod === EY, `${label(f)}: alone`);
if (mod === EY) approx(u.s.atk, u.base.atk * 1.1, `${label(f)}: ATK +10 %`);
assert.ok(h.b.relocate(texas, 10, 5));
h.step();
assert.equal(u.findBuff('trait:crosly:lonely'), null, `${label(f)}: an operator beside her`);
done(h);
}
});