Files
ark-stronghold-protocol/test/content/op_crosly.test.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +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, 18 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), 18, 9], `${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.22 (EXE-Y stage 3: ×1.37, 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.37, 0.5] : [1.22, 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);
}
});