Files
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

291 lines
19 KiB
JavaScript
Raw Permalink 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_phatom.test.js — the 自选 operator kit of 傀影 (char_250_phatom, 6★ 处决者; kit
// server/sim/content/kits/ops/op-phatom.js) and of his summon 镜中虚影 (token_10007_phatom_twin), fielded the production way
// (a DIY slot + its `diy` pick, simdata getDiy; the twin as a placed token piece `{ kind: 'token', tokenId, ownerUid }`) 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
// 克里斯汀小姐的毛毡, EXE-Y “如影随形” or ISW-A 傀影特限证章 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
// data/backups.json (the form of that slot status, the token variant of that owner form); the fidelity checklist of
// kits/README.md item by item.
// Run: node --test test/content/op_phatom.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';
import { TWIN } from '../../server/sim/content/kits/ops/op-phatom.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 PHATOM = 'char_250_phatom';
const FORMS = BACKUPS.units[PHATOM].forms;
const TOKEN = BACKUPS.tokens[TWIN];
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const EX = 'uniequip_002_phatom', EY = 'uniequip_003_phatom', IS = 'uniequip_004_phatom';
const S1 = 'skchr_phatom_1', S2 = 'skchr_phatom_2', S3 = 'skchr_phatom_3';
const TEXAS = 'chess_char_1_08_a';
const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusKey(tier, elite)];
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 twin's variant of an owner form (+ its module part). */
const twinOf = (tier, elite, mod) => {
const v = TOKEN.variants[`${PHATOM}@${statusKey(tier, elite)}`];
const bm = elite && mod ? v.byModule?.[mod] : null;
return { stats: bm?.stats ?? v.stats, talents: bm?.talents ?? v.talents, skillOf: (i) => (i === 0 ? v.skill : v.bySkill[i].skill) };
};
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_heavy: dummy('enemy_heavy', { mass: 9 }), enemy_evade: dummy('enemy_evade'),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, EX, EY, IS].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, his twin piece (uid 2) at `twinAt` unless null, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, twinAt = [12, 4], others = [], seed = 5, dp = 0 } = {}) {
const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: PHATOM, skillIndex: skill, uniEquipId: mod }, elite, row, col }];
if (twinAt) units.push({ uid: 2, kind: 'token', tokenId: TWIN, ownerUid: 1, row: twinAt[0], col: twinAt[1] });
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'statusApplied', 'death', 'deploy', 'dodge'], captureNoisy: true,
units: [...units, ...others],
});
h.step();
h.b.players[0].dp = dp;
return { h, u: h.unit(1), t: twinAt ? h.unit(2) : null };
}
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`);
}
test('傀影 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 1, melee ground-only, 16 s redeploy, 维多利亚, no 特质; his placed 镜中虚影 deploys after him with the variant\'s stats, 禁疗 and its copy of his skill', () => {
assert.equal(OPERATOR_KITS[PHATOM], KITS[PHATOM]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u, t } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null, tw = twinOf(tier, elite, mod);
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, u.skill.kind, !!u.kit.generic, u.kit.skillSource], [PHATOM, SLOT[tier], form.skills[skill].skillId, 'passive', false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd, 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), 100 + (m?.attr.aspd ?? 0), 16, 8], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly], [1, 'melee', 'phys', false], `${label(f)}: 处决者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
// the twin
assert.deepEqual([t.defId, t.ownerUnit === u, t.alive, t.deployed, t.deploySeq > u.deploySeq, !!t.kit.generic], [TWIN, true, true, true, true, false], `${label(f)}: twin deployed after him`);
assert.deepEqual([t.base.maxHp, t.base.atk, t.base.def, t.base.respawnTime, t.base.cost], [tw.stats.maxHp, tw.stats.atk, tw.stats.def, 45, 5], `${label(f)}: twin stats`);
assert.deepEqual([t.def.skill.id, t.skill.kind], [tw.skillOf(skill).skillId, 'passive'], `${label(f)}: twin skill copy`);
assert.deepEqual([t.s.blockCnt, t.profile.attack, t.profile.canHitFly, t.liveRangeGrid], [1, 'melee', false, [[0, 0], [0, 1]]], `${label(f)}: twin executor`);
assert.ok(t.s.flags.noHeal, `${label(f)}: twin 持有禁疗`);
done(h);
}
}
// the numbers (zh_CN): 傀影 E2 Lv1 1413 / 457 / 270, E2 Lv60 1653 / 539 / 304; the twin 1126 / 429 / 226 → 1318 / 508 / 254,
// EXE-Y stage 3 "属性进一步增强" 1418 / 568 / 294; modules
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], [1413, 457, 1653, 539]);
assert.deepEqual([twinOf(5, false).stats.maxHp, twinOf(5, true).stats.atk, twinOf(6, true, EY).stats.maxHp, twinOf(6, true, EY).stats.atk, twinOf(5, true, EY).stats.atk], [1126, 508, 1418, 568, 508]);
assert.deepEqual([modOf(5, EX).attr, modOf(6, EX).attr, modOf(5, EY).attr, modOf(6, EY).attr, modOf(5, IS).attr, modOf(6, IS).attr],
[{ maxHp: 100, atk: 50 }, { maxHp: 140, atk: 73 }, { atk: 40, def: 24 }, { atk: 75, def: 30 }, { atk: 37, aspd: 5 }, { atk: 71, aspd: 7 }]);
});
test('a 自选 pick: 傀影 is offered at tiers 5 and 6; a roster with EXE-X / EXE-Y passes validateDiyPicks, his 集成战略 module ISW-A is refused', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(PHATOM));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(PHATOM), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: PHATOM, skillIndex: 2, uniEquipId: EY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: PHATOM, skillIndex: 2, uniEquipId: EY } } });
const bad = validateDiyPicks({ [SLOT[5]]: { charId: PHATOM, skillIndex: 0, uniEquipId: IS } }, { data, kitted: KITTED_CHARS });
assert.equal(bad.error, 'BAD_TARGET');
assert.match(bad.detail, /集成战略/);
});
test('S1 暗夜魅影 (被动): at each deployment 30 % / 40 % physical dodge and a 屏障 of 35 % / 50 % max HP that absorbs physical damage only, both 10 s; the twin\'s copy on itself', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, EY]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.skillType, sk.spType, sk.bb.prob, sk.bb.hp_ratio, sk.bb.duration], ['PASSIVE', 'ON_DEPLOY', elite ? 0.4 : 0.3, elite ? 0.5 : 0.35, 10]);
const { h, u, t } = field({ tier, elite, mod, skill: 0 });
for (const x of [u, t]) {
const b = x === u ? sk.bb : twinOf(tier, elite, mod).skillOf(0).bb;
approx(x.s.dodgePhys, b.prob, `${x.name}: physical dodge`);
assert.equal(x.s.dodgeArts, 0, `${x.name}: no arts dodge`);
const sh = x.buffs.find((y) => y.shield > 0);
approx(sh.shield, x.s.maxHp * b.hp_ratio, `${x.name}: ${b.hp_ratio * 100} % max HP`);
assert.equal(sh.shieldType, 'phys', `${x.name}: physical only`);
}
// arts damage passes the barrier, physical damage is absorbed
const hp0 = u.hp;
h.b.dealDamage(null, u, { amount: 100, type: 'arts', canDodge: false });
approx(u.hp, hp0 - 100, 'arts passes');
h.b.dealDamage(null, u, { amount: 100, type: 'phys', canDodge: false });
approx(u.hp, hp0 - 100, 'physical absorbed');
h.run(10.2);
assert.ok(u.s.dodgePhys === 0 && !u.buffs.some((y) => y.shield > 0), 'gone after 10 s');
redeployNow(h, u);
approx(u.s.dodgePhys, sk.bb.prob, 'again at a redeployment');
done(h);
}
});
test('S2 血色乐章 (被动): at each deployment 8 / 9 stacks of ATK +13 % / +16 %; each damage instance he deals uses one (a dodged attack none); the twin\'s copy has its own', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, EX]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.bb.times, sk.bb.atk], [elite ? 9 : 8, elite ? 0.16 : 0.13]);
const { h, u, t } = field({ tier, elite, mod, skill: 1 });
assert.equal(u.findBuff('phatom:skill').stacks, sk.bb.times);
assert.equal(t.findBuff('phatom:twin:skill').stacks, sk.bb.times, 'the twin\'s own stacks');
approx(u.s.atk, u.base.atk * (1 + sk.bb.times * sk.bb.atk + (u.findBuff('trait:phatom:lonely') ? 0.1 : 0)), 'ATK');
// an enemy that dodges everything: no stack used
const ev = h.spawn('enemy_evade', { pos: [10, 5] });
h.b.addBuff(ev, { key: 'test:dodge', mods: { dodgePhys: 1 } });
h.run(3);
assert.ok(h.hooksOf('dodge').some((c) => c.source === u), 'dodged attacks');
assert.equal(u.findBuff('phatom:skill').stacks, sk.bb.times, 'none used');
h.b.removeBuff(ev, 'test:dodge');
const fullAtk = u.s.atk;
const n0 = h.hooksOf('damaged').filter((c) => c.source === u).length;
h.run(0.95);
const hits = h.hooksOf('damaged').filter((c) => c.source === u).slice(n0);
assert.ok(hits.length >= 1, 'hits');
assert.equal(u.findBuff('phatom:skill').stacks, sk.bb.times - hits.length, 'one per damage instance');
approx(hits[0].amount, fullAtk, 'the first hit at full stacks (a 0-DEF target)');
approx(u.s.atk, fullAtk - u.base.atk * sk.bb.atk * hits.length, 'ATK −atk per stack used');
h.run(30);
assert.equal(u.findBuff('phatom:skill'), null, 'all used');
done(h);
}
});
test('S3 夜幕突袭 (被动): at each deployment 210 % / 240 % ATK physical to every ground enemy of the x-4, a radial 小力 push and ONE status for all — 停顿 / 晕眩 / 束缚 2.5 / 3 s (each drawn over the casts); flyers and enemies outside untouched; the twin\'s copy at its deployment', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, EY]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.rangeId, sk.bb.atk_scale, sk.bb.force, sk.bb.sluggish, sk.bb.root, sk.bb.stun], ['x-4', elite ? 2.4 : 2.1, 0, elite ? 3 : 2.5, elite ? 3 : 2.5, elite ? 3 : 2.5]);
const drawn = new Set();
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
const { h, u, t } = field({ tier, elite, mod, skill: 2, seed, twinAt: [11, 6] });
const ahead = h.spawn('enemy_dummy', { pos: [10, 5] }), diag = h.spawn('enemy_dummy', { pos: [9, 3] });
const heavy = h.spawn('enemy_heavy', { pos: [11, 4] });
const fly = h.spawn('enemy_fly', { pos: [9, 4] }), far = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step(); // the enemy tile index
const n0 = h.hooksOf('damaged').length, s0 = h.hooksOf('statusApplied').length;
redeployNow(h, u);
const hits = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.tags?.includes('phatom:raid'));
assert.deepEqual(hits.map((c) => c.target).sort((a, b) => a.id - b.id), [ahead, diag, heavy].sort((a, b) => a.id - b.id), `seed ${seed}: the ground enemies of his x-4`);
for (const c of hits) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `seed ${seed}: ${sk.bb.atk_scale * 100} % ATK`);
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true]);
}
assert.ok(!hits.some((c) => c.target === fly || c.target === far), 'flyer / outside untouched');
assert.ok(ahead.x > 5.5, `seed ${seed}: pushed away (radial, 小力)`);
approx(heavy.y, 11, `seed ${seed}: 重量 9 — 小力 moves nothing`);
const st = h.hooksOf('statusApplied').slice(s0).filter((c) => c.source === u);
assert.equal(new Set(st.map((c) => c.status)).size, 1, `seed ${seed}: one status for the cast`);
assert.equal(st.length, 3, `seed ${seed}: on each of the three`);
for (const c of st) approx(c.duration, sk.bb[c.status === 'bind' ? 'root' : c.status], `seed ${seed}: ${c.status}`);
drawn.add(st[0].status);
// the twin's copy at its own deployment
const tn0 = h.hooksOf('damaged').length;
redeployNow(h, t);
const th = h.hooksOf('damaged').slice(tn0).filter((c) => c.source === t && c.dmg?.tags?.includes('phatom:raid'));
assert.ok(th.length >= 1 && th.every((c) => Math.abs(c.amount - t.s.atk * twinOf(tier, elite, mod).skillOf(2).bb.atk_scale) < 1e-6), `seed ${seed}: the twin's burst`);
done(h);
}
assert.deepEqual([...drawn].sort(), ['bind', 'sluggish', 'stun'], 'all three statuses over the casts');
}
});
test('T1 镜中虚影 / T2 虚影精通: the twin (禁疗: no heal reaches it) is withdrawn when he leaves the field and comes back on its tile 35 s (45 − 10; EXE-X stage 3: 45 − 16) after its LAST deployment, paying 5 DP, only while he stands', () => {
for (const f of [[5, false, null], [5, true, EX], [6, true, EX]]) {
const [tier, elite, mod] = f;
const cut = twinOf(tier, elite, mod).talents.find((x) => x.bb.respawn_time != null).bb.respawn_time;
assert.equal(cut, tier === 6 && mod === EX ? -16 : -10, label(f));
const { h, u, t } = field({ tier, elite, mod, skill: 1, dp: 50 });
const wait = 45 + cut;
// 禁疗: an ally heal does not reach it
t.hp = 100;
h.b.heal(u, t, 500);
assert.equal(t.hp, 100, `${label(f)}: 禁疗`);
// knocked out while he stands: back `wait` s after its deployment at 0 s, 5 DP paid
h.run(5);
h.b.kill(t, null);
assert.ok(!t.alive && !t.removed, `${label(f)}: the piece stays`);
h.run(wait - 5 - 1);
assert.ok(!t.alive, `${label(f)}: not before ${wait} s`);
assert.ok(h.runUntil(() => t.alive, 1.5), `${label(f)}: back at ${wait} s`);
assert.deepEqual([t.tileR, t.tileC, h.b.players[0].dp], [12, 4, 45], `${label(f)}: its tile, 5 DP`);
const at = h.b.time;
// he is knocked out: the twin is withdrawn with him (强制撤退 — KillTokens) and waits for him
h.b.kill(u, null);
u.respawnAt = Infinity; // he stays down until the test brings him back
assert.ok(!t.alive, `${label(f)}: withdrawn with him`);
assert.equal(h.hooksOf('death').filter((c) => c.unit === t).slice(-1)[0].reason, 'retreat', `${label(f)}: a withdrawal, not a knock-out`);
h.run(wait + 1);
assert.ok(!t.alive && !u.alive, `${label(f)}: no return while he is down`);
assert.ok(h.b.redeploy(u, { free: true }));
assert.ok(h.runUntil(() => t.alive, 0.5), `${label(f)}: back with him once its own timer (from ${at.toFixed(1)} s) has run`);
// no DP: it waits
h.b.kill(t, null);
h.b.players[0].dp = 0;
h.run(wait + 1);
assert.ok(!t.alive, `${label(f)}: no DP, no return`);
h.b.players[0].dp = 5;
assert.ok(h.runUntil(() => t.alive, 0.5), `${label(f)}: back with the DP`);
done(h);
}
});
test('EXE-X / ISW-A: EXE-X\'s refund has no effect (no retreat in battle) — only its attributes and T2 at stage 3; ISW-A acts by its attributes only (集成战略-only trait and talent)', () => {
{
const { h, u } = field({ tier: 6, elite: true, mod: EX, skill: 0, dp: 7 });
assert.deepEqual(u.def.traitBb, { withdraw_cost_recover_ratio: 0.8 });
h.b.retreat(u);
h.step();
assert.equal(h.b.players[0].dp, 7, 'no refund');
done(h);
}
{
const { h, u, t } = field({ tier: 6, elite: true, mod: IS, skill: 1, dp: 50 });
assert.match(modOf(6, IS).traitOverride.moduleDesc, /^在集成战略中/);
assert.ok(modOf(6, IS).talentChanges.filter((x) => x.desc).every((x) => /在集成战略中/.test(x.desc)));
assert.equal(u.findBuff('module:phatom:both'), null, 'no ATK while both stand');
h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(5);
h.b.kill(t, null);
assert.ok(!h.runUntil(() => t.alive, 29), 'its attacks shorten no redeploy time');
done(h);
}
});
test('EXE-Y “如影随形”: ATK +10 % while no allied operator stands on the 4 tiles beside him (the twin does not count); stage 3: ATK +10 % on him and the twin while both are on the field', () => {
for (const f of [[5, true, EY], [6, true, EY], [6, true, null]]) {
const [tier, elite, mod] = f;
const ey = mod === EY, y3 = ey && tier === 6;
const { h, u, t } = field({ tier, elite, mod, skill: 0, twinAt: [10, 5], others: [{ uid: 3, chessId: TEXAS, row: 12, col: 7 }] });
const texas = h.unit(3);
assert.equal(!!u.findBuff('trait:phatom:lonely'), ey, `${label(f)}: the twin beside him does not count`);
if (ey) assert.deepEqual(u.findBuff('trait:phatom:lonely').mods, { atkPct: 0.1 });
assert.equal(!!u.findBuff('module:phatom:both'), y3, `${label(f)}: both on the field`);
assert.equal(!!t.findBuff('module:phatom:both'), y3, `${label(f)}: the twin too`);
if (y3) assert.match(FORMS['2/60/7/3'].modules.find((m) => m.uniEquipId === EY).talentChanges[0].desc, /本体和虚影同时在场时,攻击力各\+10%/);
// an operator beside him
assert.ok(h.b.relocate(texas, 11, 4), 'moved beside him');
h.step();
assert.equal(u.findBuff('trait:phatom:lonely'), null, `${label(f)}: an operator beside`);
// the twin down: the shared ATK goes
h.b.kill(t, null);
h.step();
assert.equal(u.findBuff('module:phatom:both'), null, `${label(f)}: twin down`);
done(h);
}
});