Files
ark-stronghold-protocol/test/content/op_cgbird.test.js
T
sganggs c1bf395207 feedback5 O8: 自选 operator kits 闪灵, 夜莺 (+ her 幻影) and 嵯峨 — every skill, talent, trait and module at every form
- op-shining.js 闪灵 (医师): S1 信条; S2 自动掩护 — the next heal gives its target a 屏障 of atk_scale × ATK for duration s
  (every damage type) and DEF +def while one of her barriers holds (PRTS 备注: barriers add up, own timers, older first, the
  DEF bonus does not stack); S3 教条力场 — allies in range DEF +def %; T1 黑恶魔的庇护 (allies in range DEF +60; PHY-Y stage
  3: +100, 地面 units +40 more); T2 法典 (ASPD +10; PHY-X stage 3: +15, ATK +25 % with S2, SP +0.6/s with S3); PHY-Y heals on
  地面 units ×1.15, PHY-X heals on allies below 50 % ×1.15.
- op-cgbird.js 夜莺 (群愈师): S1 治疗强化·γ型; S2 法术护盾 — every target of the next heal gets an arts-only 屏障
  (shieldType 'arts') and RES +flat; S3 圣域 — range y-4 (data ACTIVE_RANGE), allies in it RES ×(1 + v) and arts dodge;
  T1 白恶魔的庇护 (RES +15; RIN-X stage 3: healing received ×1.05); RIN-X range = its module grid, RIN-Y 4 heals; T2 幻影:
  its own talent (30 % / RIN-Y stage 3 40 % physical dodge, 3 % max HP 流失 per second), her stock (+2 / RIN-Y stage 3 +3
  per deployment, at most 3 — PRTS 备注), a fallen board 幻影 back on its tile after its redeploy time using one and its DP.
- op-saga.js 嵯峨 (尖兵): S1 冲锋号令·γ型 (SP_FULL); S2 除恶 — +4 DP, up to 6 ground enemies of the x-6 cross (重伤 ones
  included), the ones still 重伤 0.7 s later killed by her (PRTS 备注); S3 怒目 — +1 DP/s (20), +0.5 s interval, range +1,
  every blocked enemy, one more attack on a target below half HP; T1 劝善 — her damage is never lethal, an enemy at ≤ 1 HP
  becomes 重伤 (PRTS 术语释义: move −80 %, 禁用近战, 禁疗, no regeneration, a forced 无来源 death 10 s later, none while 消失),
  its killer +2 SP, she never selects a 重伤 enemy (profile skipEnemy); SOL-X stage 3 +15 % damage below half HP; T2 清明
  (once per deployment below 40 %: 70 % physical dodge, 生命回复速度 5 % / SOL-Y stage 3 6.5 % — its blackboard — for 15 / 18 s);
  SOL-X block ATK / DEF +8 %; SOL-Y's 首次部署 cost cut has no battle effect.
- kits/index.js OPERATOR_KIT_FILES: the three files, one line each.
Golden (diy only; every other family unchanged): diy-003…008 are new — 闪灵, 夜莺 and 嵯峨 in every form × module × skill at
tiers 5 and 6; the prototype battles moved from diy-003…006 to diy-009…012 (same pieces; their seed follows the index);
diy-001 / 002 (推进之王) unchanged.
Tests: test/content/op_shining.test.js (9), op_cgbird.test.js (10), op_saga.test.js (15).
2026-10-05 23:58:00 +08:00

304 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_cgbird.test.js — the 自选 operator kit of 夜莺 (char_179_cgbird, 6★ 群愈师; kit
// server/sim/content/kits/ops/op-cgbird.js) and her summon 幻影 (token_10003_cgbird_bird), fielded the production way (a DIY
// slot + its `diy` pick, simdata getDiy; the 幻影 as a board token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1,
// skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, RIN-X “封闭的希望” or RIN-Y “莺歌” at stage 1
// (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_cgbird.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks, diyRecord } from '../../shared/diy.js';
import { attackRangeGrid } from '../../shared/loadoutRecord.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 CGBIRD = 'char_179_cgbird';
const PHANTOM = 'token_10003_cgbird_bird';
const FORMS = BACKUPS.units[CGBIRD].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const RINX = 'uniequip_002_cgbird', RINY = 'uniequip_003_cgbird';
const S1 = 'skcom_heal_up[3]', S2 = 'skchr_cgbird_2', S3 = 'skchr_cgbird_3';
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusOf(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);
const tokenVariant = (tier, elite, mod) => {
const v = BACKUPS.tokens[PHANTOM].variants[`${CGBIRD}@${statusOf(tier, elite)}`];
return mod && v.byModule?.[mod] ? { ...v, ...v.byModule[mod] } : v;
};
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 ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
const ALLIES = { test_melee_a: chessRec({ id: 'test_melee_a', profession: 'WARRIOR', stats: { maxHp: 10000, def: 100, res: 10, atk: 0 }, skill: null }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, RINX, RINY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/**
* 夜莺 as uid 1 on the 高台 tile (11,2) facing RIGHT — her y-2: rows 10–12, cols 1–4; RIN-X adds (11,5); 圣域's y-4 adds
* (11,5) and (9,1)–(9,3) —, allies (melee, no skill) at (11,3) uid 2, (10,3) uid 3, (12,3) uid 4, (10,4) uid 5 (all in
* y-2), (11,5) uid 6 (RIN-X / y-4 only), (9,3) uid 7 (y-4 only), (11,8) uid 8 (outside); `phantom` = a board 幻影 of hers.
*/
function field({ tier = 5, elite = false, mod = null, skill = 0, seed = 5, phantom = null, dp = 0 } = {}) {
const ally = (uid, row, col) => ({ uid, chessId: 'test_melee_a', row, col });
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, hooks: ['damaged', 'heal', 'attack', 'skillStart', 'skillEnd', 'death', 'deploy'], captureNoisy: true,
units: [
{ uid: 1, diy: { slot: SLOT[tier], charId: CGBIRD, skillIndex: skill, uniEquipId: mod }, elite, row: 11, col: 2 },
ally(2, 11, 3), ally(3, 10, 3), ally(4, 12, 3), ally(5, 10, 4), ally(6, 11, 5), ally(7, 9, 3), ally(8, 11, 8),
...(phantom ? [{ uid: 9, kind: 'token', tokenId: PHANTOM, ownerUid: 1, row: phantom[0], col: phantom[1] }] : []),
],
});
h.step();
const u = h.unit(1);
return { h, u, a: (uid) => h.unit(uid), bird: phantom ? h.b.allyUnits.find((t) => t.kind === 'token' && t.uid === 9) : null };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const rangeOf = (tier, elite, mod) => {
const rec = diyRecord(SLOT[tier], { charId: CGBIRD, skillIndex: 0, uniEquipId: mod }, { elite, data: { chess: CHESS, backups: BACKUPS } });
return attackRangeGrid(rec);
};
test('夜莺 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, y-2 (RIN-X: its grid), 3 heals (RIN-Y: 4), no 特质', () => {
assert.equal(OPERATOR_KITS[CGBIRD], KITS[CGBIRD]);
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;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CGBIRD, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.aspd],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res + (m?.attr.res ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.profile.dmgType, u.profile.heal?.mode, u.profile.heal?.count, u.profile.attack], ['heal', 'multi', elite && mod === RINY ? 4 : 3, 'ranged'], `${label(f)}: 群愈师`);
const rinx = elite && mod === RINX;
const mg = rinx ? modOf(tier, RINX).talentChanges.find((t) => t.talentIndex === -1).rangeGrid : form.rangeGrid;
assert.deepEqual(u.liveRangeGrid, mg, `${label(f)}: range`);
assert.deepEqual(u.liveRangeGrid, rangeOf(tier, elite, mod), `${label(f)}: the range the card draws (attackRangeGrid)`);
assert.equal(mg.length, rinx ? 13 : 12, `${label(f)}: y-2 (+ [0,3])`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: enemies target her`);
done(h);
}
}
assert.deepEqual([modOf(5, RINX).attr, modOf(6, RINX).attr, modOf(5, RINY).attr, modOf(6, RINY).attr], [{ atk: 40, res: 5 }, { atk: 65, res: 5 }, { maxHp: 160, aspd: 5 }, { maxHp: 220, aspd: 7 }]);
});
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(CGBIRD));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CGBIRD), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CGBIRD, skillIndex: 1, uniEquipId: RINY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: CGBIRD, skillIndex: 1, uniEquipId: RINY } } });
});
test('群愈师 heals 3 allies at once; RIN-Y “莺歌” 4 (stages 1 and 3)', () => {
for (const f of [[5, false, null], [5, true, RINY], [6, true, RINY], [6, true, RINX]]) {
const [tier, elite, mod] = f;
const { h, u, a } = field({ tier, elite, mod, skill: 0 });
for (const uid of [2, 3, 4, 5]) a(uid).hp = 1000 + uid;
const n0 = h.hooksOf('attack').length;
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 6), label(f));
const at = h.hooksOf('attack').slice(n0).find((c) => c.attacker === u);
assert.equal(new Set(at.targets.map((t) => t.uid)).size, elite && mod === RINY ? 4 : 3, `${label(f)}: heals per attack`);
done(h);
}
});
test('S1 治疗强化·γ型 (MANUAL, data DEFAULT): 30 s of ATK +55 % / +70 %, cast on an injured ally in range; 35 / 32 SP from 20', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, a } = field({ tier, elite, mod: elite ? RINX : null, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk], ['DEFAULT', elite ? 32 : 35, 20, 30, elite ? 0.7 : 0.55], `T${tier}`);
u.skill.gainSp(999);
a(8).hp = 100; // outside her range: no cast
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured in range`);
a(2).hp = 100;
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.runUntil(() => !u.skill.active, 35);
approx(u.s.atk, u.base.atk, `T${tier}: back`);
done(h);
}
});
test('S2 法术护盾 (AUTO, data DEFAULT, 2 charges): the next heal gives each of its targets a 屏障 of 50 % / 60 % ATK for 3.5 / 4 s that absorbs arts damage only, and RES +10 / +15 while it holds', () => {
for (const f of [[5, false, null], [6, true, RINY]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S2);
const { h, u, a } = field({ tier, elite, mod, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, u.skill.kind, sk.spCost, sk.bb.atk_scale, sk.bb.duration, sk.bb.magic_resistance],
['DEFAULT', 2, 'charges', 10, elite ? 0.6 : 0.5, elite ? 4 : 3.5, elite ? 15 : 10], label(f));
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured, no cast`);
const hurt = [2, 3, 4, 5].map(a);
hurt.forEach((x, i) => { x.hp = 1000 + i; });
assert.ok(h.runUntil(() => u.skill.activations === 1 && hurt.some((x) => x.findBuff('cgbird:barrier')), 5), `${label(f)}: cast with a heal`);
const got = hurt.filter((x) => x.findBuff('cgbird:barrier'));
const first = got.map((x) => x.findBuff('cgbird:barrier'));
assert.equal(got.length, elite && mod === RINY ? 4 : 3, `${label(f)}: every target of that heal`);
for (const x of got) {
const b = x.findBuff('cgbird:barrier');
approx(b.shield, u.s.atk * sk.bb.atk_scale, `${label(f)}: ${sk.bb.atk_scale * 100} % ATK`);
approx(b.duration, sk.bb.duration, `${label(f)}: ${sk.bb.duration} s`);
assert.equal(b.shieldType, 'arts', `${label(f)}: arts only`);
assert.deepEqual(x.findBuff('cgbird:barrierRes')?.mods, { resFlat: sk.bb.magic_resistance }, `${label(f)}: RES bonus`);
}
const x = got[0], b = x.findBuff('cgbird:barrier');
const hp0 = x.hp, sh0 = b.shield;
h.b.dealDamage(null, x, { amount: 50, type: 'phys', canDodge: false });
assert.ok(x.hp < hp0, `${label(f)}: physical damage passes`);
approx(b.shield, sh0, `${label(f)}: the barrier untouched by physical damage`);
h.b.dealDamage(null, x, { amount: 20, type: 'true' });
approx(b.shield, sh0, `${label(f)}: … and by true damage`);
const hp1 = x.hp;
h.b.dealDamage(null, x, { amount: 30, type: 'arts', canDodge: false });
approx(x.hp, hp1, `${label(f)}: arts damage absorbed`);
assert.ok(b.shield < sh0, `${label(f)}: the barrier took it`);
h.b.dealDamage(null, x, { amount: 1e6, type: 'arts', canDodge: false, resIgnorePct: 1 });
assert.equal(x.findBuff('cgbird:barrier'), null, `${label(f)}: broken`);
assert.equal(x.findBuff('cgbird:barrierRes'), null, `${label(f)}: the RES bonus goes with it`);
h.run(sk.bb.duration + 0.1);
got.forEach((y, i) => assert.ok(!y.buffs.includes(first[i]), `${label(f)}: the first heal's barrier expired`));
done(h);
}
});
test('S3 圣域 (MANUAL, data ACTIVE_RANGE on y-4): cast for an injured ally inside y-4 only; 60 s, range y-4, ATK +40 % / +50 %, allies in it RES ×1.9 / ×2.05 and 15 % / 20 % arts dodge; all back after', () => {
for (const f of [[5, false, null], [6, true, null], [6, true, RINX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
const { h, u, a } = field({ tier, elite, mod, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.rangeId, sk.bb.atk, sk.bb.magic_resistance, sk.bb.prob],
['ACTIVE_RANGE', 60, 120, 'y-4', elite ? 0.5 : 0.4, elite ? 1.05 : 0.9, elite ? 0.2 : 0.15], label(f));
assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid, `${label(f)}: the trigger grid is its running range`);
const base = u.liveRangeGrid;
u.skill.gainSp(999);
a(8).hp = 100; // outside y-4
h.run(2);
assert.equal(u.skill.activations, 0, `${label(f)}: no injured ally in y-4`);
a(7).hp = 100; // (9,3): y-4 only
assert.ok(h.runUntil(() => u.skill.active, 2), `${label(f)}: cast for the ally 2 rows down`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: y-4 while it runs`);
h.run(0.3);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
for (const uid of [2, 6, 7]) assert.deepEqual(a(uid).findBuff('skill:cgbird:sanctuary')?.mods, { resMul: 1 + sk.bb.magic_resistance, dodgeArts: sk.bb.prob }, `${label(f)}: uid ${uid} in y-4`);
assert.deepEqual(u.findBuff('skill:cgbird:sanctuary')?.mods, { resMul: 1 + sk.bb.magic_resistance, dodgeArts: sk.bb.prob }, `${label(f)}: herself`);
assert.equal(a(8).findBuff('skill:cgbird:sanctuary'), null, `${label(f)}: outside`);
// RES = (10 + 15 talent) × (1 + v) on an ally (base 10)
approx(a(7).s.res, Math.min(100, (10 + 15) * (1 + sk.bb.magic_resistance)), `${label(f)}: RES`);
approx(a(7).s.dodgeArts, sk.bb.prob, `${label(f)}: arts dodge`);
u.skill.end('test');
h.run(0.3);
assert.deepEqual(u.liveRangeGrid, base, `${label(f)}: own range back`);
for (const uid of [2, 6, 7]) assert.equal(a(uid).findBuff('skill:cgbird:sanctuary'), null, `${label(f)}: uid ${uid} after`);
done(h);
}
});
test('T1 白恶魔的庇护: allies in her range RES +15 (herself too, not outside); RIN-X: its range tile too, stage 3 also 受到的治疗效果 ×1.05', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, a } = field({ tier, elite, mod, skill: 0 });
h.run(0.3);
const rinx = elite && mod === RINX, x3 = rinx && tier === 6;
const want = x3 ? { resFlat: 15, healingTakenMul: 1.05 } : { resFlat: 15 };
for (const uid of [2, 3, 4, 5]) assert.deepEqual(a(uid).findBuff('talent:cgbird:res')?.mods, want, `${label(f)}: uid ${uid}`);
assert.deepEqual(u.findBuff('talent:cgbird:res')?.mods, want, `${label(f)}: herself`);
assert.deepEqual(a(6).findBuff('talent:cgbird:res')?.mods ?? null, rinx ? want : null, `${label(f)}: (11,5) — the RIN-X tile`);
for (const uid of [7, 8]) assert.equal(a(uid).findBuff('talent:cgbird:res'), null, `${label(f)}: uid ${uid} outside`);
approx(a(2).s.res, 10 + 15, `${label(f)}: RES`);
if (x3) {
a(2).hp = 1000;
const n0 = h.hooksOf('heal').length;
assert.ok(h.runUntil(() => h.hooksOf('heal').slice(n0).some((c) => c.target === a(2) && c.source === u), 6));
const c = h.hooksOf('heal').slice(n0).find((x) => x.target === a(2) && x.source === u);
approx(c.amount, u.s.atk * 1.05, `${label(f)}: healing received ×1.05`);
}
done(h);
}
});
test('幻影 (T2): the token of her form — HP 3000 / 4989, RES 75, blocks nothing, taunt 1, no attack; 30 % physical dodge (RIN-Y stage 3: 40 %), loses 3 % of its max HP each second', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, bird } = field({ tier, elite, mod, skill: 0, phantom: [12, 8] }); // outside her range: no heals on it
const v = tokenVariant(tier, elite, mod);
assert.ok(bird && bird.alive, `${label(f)}: deployed with the board`);
assert.equal(bird.ownerUnit, u, `${label(f)}: hers`);
assert.deepEqual([bird.base.maxHp, bird.base.res, bird.s.blockCnt, bird.s.taunt, bird.profile.noAttack], [v.stats.maxHp, 75, 0, 1, true], `${label(f)}: stats`);
assert.equal(v.stats.maxHp, elite ? 4989 : 3000);
const y3 = elite && tier === 6 && mod === RINY;
approx(bird.s.dodgePhys, y3 ? 0.4 : 0.3, `${label(f)}: physical dodge`);
assert.equal(bird.s.dodgeArts, 0, `${label(f)}: no arts dodge`);
const hp0 = bird.hp;
h.run(3.02);
approx(hp0 - bird.hp, 3 * 0.03 * bird.s.maxHp, `${label(f)}: 3 × 3 % in 3 s`, 1e-6);
assert.ok(h.hooksOf('damaged').some((c) => c.target === bird && c.dmg?.tags?.includes('hpLoss')), `${label(f)}: a 流失`);
// enemies: a 30 % dodge on physical hits, none on arts
let dodged = 0;
for (let i = 0; i < 400; i++) if (h.b.dealDamage(null, bird, { amount: 1, type: 'phys' }) === 0) dodged++;
assert.ok(Math.abs(dodged / 400 - (y3 ? 0.4 : 0.3)) < 0.08, `${label(f)}: ${dodged} / 400 dodged`);
done(h);
}
});
test('幻影: each 夜莺 deployment adds 2 (RIN-Y stage 3: 3) to her stock, at most 3; a fallen 幻影 comes back on its tile 20 s later using one and paying 5 DP; it stays when she leaves', () => {
for (const f of [[5, true, RINY], [6, true, RINY], [6, false, null]]) {
const [tier, elite, mod] = f;
const { h, u, bird } = field({ tier, elite, mod, skill: 0, phantom: [12, 8], dp: 50 });
const cnt = elite && tier === 6 && mod === RINY ? 3 : 2;
assert.equal(u.mem.phantomStock, cnt, `${label(f)}: her deployment gave ${cnt}; the board piece used none`);
const tile = [bird.tileR, bird.tileC];
h.b.kill(bird, null);
assert.ok(!bird.alive && !bird.removed, `${label(f)}: fallen, kept`);
h.run(19.5);
assert.ok(!bird.alive, `${label(f)}: not before its 20 s`);
const dp0 = h.b.players[0].dp;
assert.ok(h.runUntil(() => bird.alive, 1), `${label(f)}: back after 20 s`);
assert.deepEqual([bird.tileR, bird.tileC], tile, `${label(f)}: on its own tile`);
assert.equal(u.mem.phantomStock, cnt - 1, `${label(f)}: one used`);
approx(dp0 - h.b.players[0].dp, 5, `${label(f)}: its DP cost`);
approx(bird.hp, bird.s.maxHp, `${label(f)}: full HP`, 0.05);
approx(bird.s.dodgePhys, tokenVariant(tier, elite, mod).talents.at(-1).bb.prob, `${label(f)}: its talent again`);
// she leaves: the deployed 幻影 stays; she comes back: +cnt (cap 3)
h.b.retreat(u);
h.step();
assert.ok(bird.alive, `${label(f)}: stays when she leaves`);
h.b.redeploy(u, { free: true });
assert.equal(u.mem.phantomStock, Math.min(3, cnt - 1 + cnt), `${label(f)}: +${cnt}, at most 3`);
// none held: a fallen 幻影 waits
u.mem.phantomStock = 0;
h.b.kill(bird, null);
h.run(30);
assert.ok(!bird.alive, `${label(f)}: no 幻影 held, none comes`);
u.mem.phantomStock = 1;
assert.ok(h.runUntil(() => bird.alive, 1), `${label(f)}: comes as soon as one is held`);
assert.equal(u.mem.phantomStock, 0);
done(h);
}
});
test('幻影 without DP waits for it; 夜莺 heals her 幻影 (an injured 友方单位 in range) and her S2 barrier reaches it', () => {
const { h, u, bird } = field({ tier: 6, elite: true, mod: RINY, skill: 1, phantom: [12, 4], dp: 0 });
// the drain makes it an injured ally of her range
u.skill.gainSp(999);
assert.ok(h.runUntil(() => bird.findBuff('cgbird:barrier'), 6), 'barrier on the 幻影 with the next heal');
assert.ok(h.hooksOf('heal').some((c) => c.source === u && c.target === bird), 'healed');
h.b.kill(bird, null);
h.run(25);
assert.ok(!bird.alive, 'no DP: still down');
h.b.players[0].dp = 5;
assert.ok(h.runUntil(() => bird.alive, 1), 'back once the DP is there');
approx(h.b.players[0].dp, 0, 'paid 5');
done(h);
});