284 lines
17 KiB
JavaScript
284 lines
17 KiB
JavaScript
// test/content/op_zumama.test.js — the 自选 operator kit of 森蚺 (char_416_zumama, 6★ 决战者; kit
|
||
// server/sim/content/kits/ops/op-zumama.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, HES-X
|
||
// “祖玛玛的工具箱”, HES-Y “小小的丑东西” or RA-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 fidelity checklist of kits/README.md item by item.
|
||
// Run: node --test test/content/op_zumama.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 ZUMAMA = 'char_416_zumama';
|
||
const FORMS = BACKUPS.units[ZUMAMA].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const HESX = 'uniequip_002_zumama', HESY = 'uniequip_003_zumama', RAA = 'uniequip_004_zumama';
|
||
const S1 = 'skchr_zumama_1', S2 = 'skchr_zumama_2', S3 = 'skchr_zumama_3';
|
||
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);
|
||
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_tough: dummy('enemy_tough', { immunities: { stun: true } }),
|
||
enemy_frail: dummy('enemy_frail', { hp: 10 }),
|
||
};
|
||
/** Every 自选 form: [tier, elite, module]. */
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, HESX, HESY, RAA].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 = 1, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ZUMAMA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||
});
|
||
h.step();
|
||
return { h, u: h.unit(1) };
|
||
}
|
||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||
/** An enemy she blocks: a still dummy put on her own tile (blocked on the next tick). */
|
||
function blockOne(h, u, key = 'enemy_dummy') {
|
||
const e = h.spawn(key, { pos: [u.tileR, u.tileC] });
|
||
assert.ok(h.runUntil(() => e.blockedBy === u, 1), 'blocked');
|
||
return e;
|
||
}
|
||
function done(h) {
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
|
||
test('森蚺 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 萨尔贡, no 特质; triggers from the data', () => {
|
||
assert.equal(OPERATOR_KITS[ZUMAMA], KITS[ZUMAMA]);
|
||
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], [ZUMAMA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'melee', 'phys', false, 1.6], `${label(f)}: 决战者`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['sargonShip'], []], `${label(f)}: bonds / 特质`);
|
||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||
// the 重装 exception (the owner's decision of 2026-10-05): S2 / S3 cast in range (DEFAULT), the official TAKE_DAMAGE kept
|
||
const sk = form.skills[skill];
|
||
if (skill === 0) assert.deepEqual([u.skill.kind, sk.skillType, u.skill.spCost], ['passive', 'PASSIVE', 0], `${label(f)}: S1 passive`);
|
||
else assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, sk.skillType], ['DEFAULT', 'DEFAULT', 'TAKE_DAMAGE', 'MANUAL'], `${label(f)}: S${skill + 1} trigger`);
|
||
done(h);
|
||
}
|
||
}
|
||
// zh_CN numbers (full potential): E2 Lv1 3440 / 835 / 519, E2 Lv60 4121 / 949 / 601; HES-X +80/+70 → +105/+85 ATK/DEF, HES-Y +70/+80 →
|
||
// +85/+110, RA-A +280/+80 → +380/+130 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], [3440, 835, 4121, 949]);
|
||
assert.deepEqual([modOf(5, HESX).attr, modOf(6, HESX).attr, modOf(5, HESY).attr, modOf(6, HESY).attr, modOf(5, RAA).attr, modOf(6, RAA).attr],
|
||
[{ atk: 80, def: 70 }, { atk: 105, def: 85 }, { atk: 70, def: 80 }, { atk: 85, def: 110 }, { maxHp: 280, atk: 80 }, { maxHp: 380, atk: 130 }]);
|
||
});
|
||
|
||
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(ZUMAMA));
|
||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ZUMAMA), `tier ${t}`);
|
||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ZUMAMA, skillIndex: 2, uniEquipId: HESX } }, { data, kitted: KITTED_CHARS }),
|
||
{ ok: true, picks: { [SLOT[6]]: { charId: ZUMAMA, skillIndex: 2, uniEquipId: HESX } } });
|
||
});
|
||
|
||
test('S1 轻型挂斧 (PASSIVE): ATK / DEF +14 % / +18 % from every deployment, no SP', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S1);
|
||
const { h, u } = field({ tier, elite, skill: 0 });
|
||
assert.deepEqual([sk.bb.atk, sk.bb.def], elite ? [0.18, 0.18] : [0.14, 0.14], `T${tier}`);
|
||
assert.ok(u.skill.active, `T${tier}: on from the deployment`);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
|
||
h.b.retreat(u);
|
||
h.b.redeploy(u);
|
||
h.step();
|
||
assert.ok(u.skill.active, `T${tier}: on again after a redeploy`);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK again`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S2 震慑劈砍 (data DEFAULT: casts with the enemy she blocks): 16 / 17 s, ATK +100 % / +130 %, interval 1.6 + 0.4 s, every enemy she blocks stunned past 抵抗 while it lasts; a stun immunity holds; all gone after', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
const { h, u } = field({ tier, elite, skill: 1 });
|
||
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.spCost, sk.initSp], elite ? [17, 1.3, 0.4, 32, 12] : [16, 1, 0.4, 35, 11], `T${tier}`);
|
||
u.skill.gainSp(999, 'init');
|
||
h.run(2);
|
||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
|
||
h.b.applyStatus(e, 'resist', { duration: 999, value: 0.5 });
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast once she blocks it`);
|
||
assert.equal(e.blockedBy, u);
|
||
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||
approx(u.s.interval, 2.0, `T${tier}: 1.6 + 0.4 s`);
|
||
h.step(2);
|
||
assert.ok(e.s.flags.stun, `T${tier}: the blocked enemy is stunned`);
|
||
const stuns = h.hooksOf('statusApplied').filter((c) => c.status === 'stun' && c.target === e && c.source === u);
|
||
assert.ok(stuns.length > 0 && stuns.every((c) => Math.abs(c.duration - 0.1) < 1e-9), `T${tier}: 抵抗 does not shorten it`);
|
||
h.run(5);
|
||
assert.ok(e.s.flags.stun && u.skill.active, `T${tier}: still stunned while blocked`);
|
||
// a new blockee mid-skill: the first one falls, a stun-immune one and a plain one come in turn
|
||
h.b.kill(e, u);
|
||
const tough = h.spawn('enemy_tough', { pos: [u.tileR, u.tileC] });
|
||
assert.ok(h.runUntil(() => tough.blockedBy === u, 1));
|
||
h.step(3);
|
||
assert.ok(!tough.s.flags.stun, `T${tier}: 晕眩 immunity`);
|
||
h.b.kill(tough, u);
|
||
const late = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
|
||
assert.ok(h.runUntil(() => late.blockedBy === u, 1));
|
||
h.step(2);
|
||
assert.ok(late.s.flags.stun, `T${tier}: an enemy blocked mid-skill is stunned too`);
|
||
assert.ok(h.runUntil(() => !u.skill.active, sk.duration), `T${tier}: ends`);
|
||
h.run(0.15);
|
||
assert.ok(!late.s.flags.stun, `T${tier}: the stun ends with the skill`);
|
||
approx(u.s.interval, 1.6, `T${tier}: interval back`);
|
||
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
|
||
// the stunned enemies fought nobody meanwhile: her normal attacks hit them (physical, ground)
|
||
assert.ok(atkHits(h, u).some((c) => c.target === late && c.type === 'phys'), `T${tier}: she keeps attacking`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 钢铁意志 (data DEFAULT): 31 / 32 s, ATK +140 % / +170 %, DEF +90 % / +120 %, block 1 + 2, 生命回复速度 3 % max HP / s (禁疗-proof); 5 s self-stun after, blocking nobody meanwhile', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, HESX]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.def, sk.bb.block_cnt, sk.bb.hp_recovery_per_sec_by_max_hp_ratio, sk.bb.stun], elite ? [32, 1.7, 1.2, 2, 0.03, 5] : [31, 1.4, 0.9, 2, 0.03, 5], `T${tier}`);
|
||
u.skill.gainSp(999, 'init');
|
||
const e = blockOne(h, u);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with the enemy she blocks`);
|
||
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.05);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
|
||
assert.equal(u.s.blockCnt, 3, `T${tier}: block 1 + 2`);
|
||
approx(u.s.hpRegen, u.s.maxHp * 0.03, `T${tier}: 3 % max HP / s`);
|
||
// 生命回复速度, no heal: 禁疗 and 无法被治疗 do not stop it
|
||
h.b.addBuff(u, { key: 'test:healFree', flags: { healFree: true, noHeal: true } });
|
||
u.hp = u.s.maxHp * 0.5;
|
||
const hp0 = u.hp;
|
||
h.run(1);
|
||
approx(u.hp - hp0, u.s.maxHp * 0.03, `T${tier}: +3 % in 1 s under 禁疗`, 0.05);
|
||
h.b.removeBuff(u, 'test:healFree');
|
||
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1), `T${tier}: ends`);
|
||
const st = h.hooksOf('statusApplied').find((c) => c.status === 'stun' && c.target === u);
|
||
assert.ok(st, `T${tier}: self-stun`);
|
||
approx(st.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
|
||
h.step(2);
|
||
assert.ok(u.s.flags.stun && u.blocking.length === 0 && e.blockedBy !== u, `T${tier}: stunned, blocking nobody`);
|
||
assert.equal(u.s.blockCnt, 1, `T${tier}: block back to 1`);
|
||
h.run(sk.bb.stun + 0.2);
|
||
assert.ok(!u.s.flags.stun, `T${tier}: the stun is over`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T1 勇冠三军: above half HP every hit ×1.17 as an 攻击力倍率 (×1.25 with HES-Y stage 3), at or below half 22 % 庇护 (30 %) on physical / arts damage only — checked every 0.1 s', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
|
||
const y3 = elite && mod === HESY && tier === 6;
|
||
assert.deepEqual([t0.hp_ratio, t0.atk_scale, t0.damage_resistance], y3 ? [0.5, 1.25, 0.3] : [0.5, 1.17, 0.22], label(f));
|
||
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC + 1] }); // in her 1-1, not blocked: no SP, no cast
|
||
h.run(0.2);
|
||
assert.equal(u.s.atkScaleMul, t0.atk_scale, `${label(f)}: above half`);
|
||
const n0 = atkHits(h, u).length;
|
||
assert.ok(h.runUntil(() => atkHits(h, u).length > n0, 3));
|
||
const hit = atkHits(h, u)[n0];
|
||
approx(hit.amount, u.s.atk * t0.atk_scale, `${label(f)}: ×${t0.atk_scale} on a 0-DEF target`);
|
||
// half HP: 庇护, no ×1.17
|
||
u.hp = u.s.maxHp * 0.5;
|
||
h.run(0.14);
|
||
assert.equal(u.s.atkScaleMul, 1, `${label(f)}: at half: no bonus`);
|
||
const pb = u.findBuff('protect');
|
||
assert.ok(pb, `${label(f)}: 庇护`);
|
||
approx(pb.data.value, t0.damage_resistance, `${label(f)}: ${t0.damage_resistance * 100} %`);
|
||
const hit1 = (amount, type) => { u.hp = u.s.maxHp * 0.5; return h.b.dealDamage(e, u, { amount, type }); };
|
||
approx(hit1(400, 'arts'), 400 * (1 - t0.damage_resistance), `${label(f)}: arts`);
|
||
approx(hit1(300, 'true'), 300, `${label(f)}: true untouched`);
|
||
approx(hit1(u.s.def + 400, 'phys'), 400 * (1 - t0.damage_resistance), `${label(f)}: physical`);
|
||
// back above half: the bonus within one check, the 庇护 gone
|
||
u.hp = u.s.maxHp * 0.9;
|
||
h.run(0.25);
|
||
assert.equal(u.s.atkScaleMul, t0.atk_scale, `${label(f)}: above half again`);
|
||
assert.equal(u.findBuff('protect'), null, `${label(f)}: no 庇护`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('trait 决战者: no module ⇒ 阻回 (no SP of any kind, gifts too) unless she blocks; HES-X ×0.2 / HES-Y ×0.001 / RA-A ×0 SP recovery unblocked (gifts land); T2 愈战愈勇 +0.2 / s blocking (HES-X stage 3 +0.55; RA-A stays +0.2); HES-Y ATK / DEF +15 % blocking', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||
const form = formOf(tier, elite);
|
||
const m = elite ? modOf(tier, mod) : null;
|
||
const ratio = m ? m.traitOverride.bb.sp_recover_ratio : null;
|
||
const t2 = elite && mod === HESX && tier === 6 ? 0.55 : 0.2;
|
||
assert.equal(ratio, { [HESX]: -0.8, [HESY]: -0.999, [RAA]: -1 }[mod] ?? null, label(f));
|
||
const init = skillOf(tier, elite, S2).initSp;
|
||
assert.ok(u.skill.sp >= init && u.skill.sp < init + 0.05, `${label(f)}: initSp at the deployment despite 阻回 (${u.skill.sp})`);
|
||
// unblocked
|
||
h.run(0.2);
|
||
const sp0 = u.skill.sp;
|
||
h.run(5);
|
||
const unblocked = u.skill.sp - sp0;
|
||
if (ratio == null) {
|
||
assert.ok(u.s.flags.noSp, `${label(f)}: 阻回`);
|
||
approx(unblocked, 0, `${label(f)}: no SP`);
|
||
assert.equal(u.skill.gainSp(3, 'talent'), 0, `${label(f)}: a gift refused`);
|
||
} else {
|
||
assert.ok(!u.s.flags.noSp, `${label(f)}: no 阻回 with a module`);
|
||
approx(u.s.spRecovery, 1 + ratio, `${label(f)}: ×${1 + ratio}`);
|
||
approx(unblocked, 5 * (1 + ratio), `${label(f)}: ${5 * (1 + ratio)} SP in 5 s`, 1e-3);
|
||
approx(u.skill.gainSp(3, 'talent'), 3, `${label(f)}: a gift lands`);
|
||
}
|
||
assert.equal(u.findBuff('trait:zumama:block'), null, `${label(f)}: nothing blocked`);
|
||
// blocking: full SP recovery + T2
|
||
u.skill.sp = 0;
|
||
blockOne(h, u);
|
||
h.step();
|
||
approx(u.s.spRecovery, 1 + t2, `${label(f)}: 1 + ${t2} / s blocking`);
|
||
const sp1 = u.skill.sp;
|
||
h.run(5);
|
||
approx(u.skill.sp - sp1, 5 * (1 + t2), `${label(f)}: SP while blocking`, 1e-3);
|
||
if (elite && mod === HESY) {
|
||
assert.deepEqual(u.findBuff('trait:zumama:block')?.mods, { atkPct: 0.15, defPct: 0.15 }, `${label(f)}: HES-Y`);
|
||
} else assert.equal(u.findBuff('trait:zumama:block'), null, `${label(f)}: no ATK / DEF trait`);
|
||
if (elite && mod === RAA && tier === 6) {
|
||
// the composed record carries the 生息演算-only 0.4 / s; the kit reads 愈战愈勇 from talentsBase
|
||
assert.equal(u.def.raw.talents.find((t) => t.index === 1).bb.sp_recovery_per_sec, 0.4, 'the sandbox part is in the record');
|
||
assert.equal(u.def.raw.talentsBase.find((t) => t.index === 1).bb.sp_recovery_per_sec, 0.2);
|
||
}
|
||
assert.equal(form.trait.desc, '只有阻挡敌人时才能够回复技力');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('RA-A 森蚺特限证章: only its attributes and its trait ratio apply here — no 生息演算 parts (S2 block stays 1, no 物理脆弱, no attraction)', () => {
|
||
for (const tier of [5, 6]) {
|
||
const { h, u } = field({ tier, elite: true, mod: RAA, skill: 1, row: 9, col: 5 });
|
||
const m = modOf(tier, RAA);
|
||
assert.match(m.traitOverride.moduleDesc, /生息演算/);
|
||
u.skill.gainSp(999, 'init');
|
||
const far = h.spawn('enemy_dummy', { pos: [12, 9] });
|
||
const e = blockOne(h, u);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||
assert.equal(u.s.blockCnt, 1, `T${tier}: no 技能期间阻挡数+2`);
|
||
approx(e.s.physTakenMul, 1, `T${tier}: no 物理脆弱`);
|
||
approx(far.x, 9, `T${tier}: nobody pulled`);
|
||
done(h);
|
||
}
|
||
});
|