feedback5: merge O14 — 自选 kits for 年, 斩业星熊 and 珊比; golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 01:26:00 +08:00
commit 6a8284837d
8 files changed
+2110 -104

No files matched your search

+3
View File
@@ -103,6 +103,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-ifrit.js',
'op-aphris.js',
'op-lin.js',
'op-nian.js',
'op-hsgma2.js',
'op-thumpy.js',
]);
/**
+291
View File
@@ -0,0 +1,291 @@
// server/sim/content/kits/ops/op-hsgma2.js — 斩业星熊 (char_1044_hsgma2) 自选 operator kit: 6★ 驭法铁卫 (重装), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_1044_hsgma2, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, AST-X at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), gamedata_const
// ba.berserk 坚忍 ("根据已损失的生命值获得相应比例的属性加成,损失一定比例时达最大加成") and ba.sluggish 停顿, PRTS 斩业星熊 (业火 备注:
// 负生命值 — "期间持有禁疗,一切生命值变动将先计入负生命值来抵消/增加负生命值…只有负生命值达到上限时才会被正常击倒…负生命值与真实生命值
// 无关", "生命恢复的提供方式为增加自身的“生命回复速度(百分比)”属性,不受治疗加成和禁疗影响", "需成功受到伤害,生命恢复效果才会失效并
// 重新计时"; S1 备注 "反伤效果会直接选中伤害来源,但仅对阵营为敌方的来源造成伤害"; S2 备注 "治疗效果无视自身的禁疗,计算治疗量时以目标
// 受伤前后的生命值差的绝对值为准", "投出的盾牌以自身前方1.2格为起点,逆时针旋转365°,角速度90°/s,碰撞半径1.0,碰撞范围内存在可选目标时
// 速度-80%", "技能持续至盾牌旋转365°并收回时结束"; S3 备注 "技能期间攻击可对空", 临死模式 — 阻回 / 沉默 / 不死, allies' 不死 and the
// lethal excess as her 流失, the forced exit, "若携带X模组,则X模组的“造成额外伤害”效果依然生效"; AST-X 备注 — the extra damage's
// conditions "①此为普通攻击或普通攻击附带的伤害;②伤害来源于斩业星熊自身;③不为本模组特性的伤害…伤害为法术附加伤害", "仅对敌方
// 单位反弹伤害,伤害为法术普通伤害"), PRTS 卫戍协议/帮助 技能策略 (her S3 row "于技能持续时间过半后…关闭技能(进入其技能效果的
// 临死模式)" added in 上半, REMOVED with 下半; "通常不会自动关闭技能").
// - Trait (驭法铁卫) "技能开启时普通攻击会造成法术伤害": physical attacks while no skill runs (the kit's trait dmgType — the
// data's 'arts' is the skill-on type) and arts while one runs (every skill's attack override); block 3, ground-only
// melee on her 1-1. Module AST-X 无迹 adds "且攻击和受到攻击时对目标额外造成10%攻击力的法术伤害" (trait bb atk_scale): every
// damage instance of her normal attacks (skill attacks included; not the shield, a counter or the module's own damage)
// is followed by atk_scale × ATK arts on that enemy (附加伤害: no dodge), and every enemy attack she takes returns
// atk_scale × ATK arts (普通伤害) to the attacker — whether a skill runs or not [ASSUMED: PRTS's list of the checked
// conditions names no skill period].
// - T1 业火 "生命降至0时进入“我执”…": a lethal damage instance leaves her on the field in 我执 instead (the damage past her
// HP opens the negative pool — the engine keeps her at its 1-HP floor for the official 0); in 我执 every later damage
// instance goes to the pool (no HP loss; it still counts as damage taken: 受击回复 SP, counters) until the pool would
// reach max_minus_hp_ratio × max HP — that blow knocks her out as usual (unless a 不死 holds her); she holds 禁疗 (flags
// noHeal + healFree, the status 'healFree'); after [heal].interval s without a damage instance landing she gets the
// 生命回复速度(百分比) attribute [heal].hp_recovery_per_sec_by_max_hp_ratio (an hpRegenRatio buff — not 治疗: 禁疗 does
// not stop it, no 治疗加成 scales it); whatever raises her HP above the floor (that regeneration, S2's heal that ignores
// 禁疗) clears the pool first, and she leaves 我执 once it is empty. A 流失 that reaches 0 counts whole into the pool
// [ASSUMED: the engine reports no HP before a loss].
// - T2 鬼之架势 "在场时,自身获得最高+50法术抗性和35%攻击力的坚忍(损失70%生命值时达到最大加成)": RES +min_magic_resistance and ATK
// +min_atk scaled linearly by the HP lost from max_hp_ratio down to min_hp_ratio (坚忍), re-read every tick; in 我执 at
// its maximum. AST-X stage 3: the maximum at 50 % lost (min_hp_ratio 0.5) and "达到最大加成时,攻击力额外+15%" (atk).
// - S1 恶业苦果 (AUTO, 受击回复 SP, 持续时间无限 — a toggle): ATK +atk, DEF +def, arts attacks; every enemy attack she takes
// deals atk_scale × ATK arts to the attacker (a direct pick of the damage source, enemies only). A self buff: the owner's
// AUTO rule fires it as soon as its SP is full (`trigger: 'SP_FULL'`, as 引星棘刺 S1 — kits/README.md checklist 5).
// - S2 无始无明 (AUTO, attack SP; data DEFAULT): the cast replaces the attack about to be made — the shield throw, three hits
// of attack@atk_scale × ATK arts on every enemy she blocks (none blocked: her target [ASSUMED]); then the shield circles
// her: from 1.2 tiles ahead, counter-clockwise 365° at 90°/s, ×0.2 while a targetable ground enemy [ASSUMED: no 对空
// note, unlike S3] is within 1.0 of it; every `interval` s from the throw [ASSUMED] each such enemy takes 停顿 `sluggish`
// s and shield_atk_scale × ATK arts, and she heals heal_ratio × the HP it lost (ignoring her 禁疗). The skill runs until
// the turn is done (its return taken as instant [ASSUMED]): no SP meanwhile, and her normal attacks of the flight are arts.
// - S3 地狱变相 (MANUAL; data ACTIVE_RANGE on the running x-2 — the owner's rule over the 重装 exception, rawRule
// TAKE_DAMAGE): `duration` s — range x-2, max HP +max_hp, ATK +atk, two hits on up to attack@max_target enemies, air units
// too (PRTS 备注), arts. 主动关闭 (a close: `skill.end('manual')`) starts the 临死模式 for before_dead_duration s: the
// skill's bonuses and range stay, four hits, 阻回 + 沉默, 不死 (her negative pool never knocks her out); every other allied
// operator on her range holds 不死 and the part of a damage instance past its HP becomes her 流失 (a 流失 it takes is not
// passed on); then she leaves the field (a retreat with the death animation, as 史尔特尔's 余烬; the official partial DP
// refund of a withdrawal is not modelled — no retreat in the sim refunds DP). No strategy of this mode closes it: PRTS
// 帮助 lists the 上半 row that did as removed in 下半, so in a fight the skill simply runs its `duration`.
import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { bodyInRadius } from '../../../body.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_hsgma2_1';
const S2 = 'skchr_hsgma2_2';
const S3 = 'skchr_hsgma2_3';
/** S2's shield (PRTS 备注): start 1.2 tiles ahead, 365° counter-clockwise at 90°/s, collision radius 1.0, −80 % near a target. */
const SHIELD_START = 1.2;
const SHIELD_TURN = 365;
const SHIELD_SPEED = 90;
const SHIELD_RADIUS = 1.0;
const SHIELD_SLOW = 0.8;
/** An engine cap on S2's run (the shield's turn ends it long before: 365° at 18°/s takes 20.3 s). */
const S2_CAP = 60;
/** S3's x-2 when the data carries no grid. */
const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1],
[0, 2], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]);
const GROUND = Object.freeze({ canHitFly: false });
const EGO = 'talent:hsgma2:ego';
const EGO_REGEN = 'talent:hsgma2:egoRegen';
const BERSERK = 'talent:hsgma2:berserk';
const DYING = 'skill:hsgma2:dying';
const ASTX = 'hsgma2:astx';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0));
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
export default {
char_1044_hsgma2: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const throwParts = { hits: 3, atkScale: num(b2['attack@atk_scale'], 1), hitAllBlocked: true };
// this unit's attack overrides (the kit is built per unit): S2's throw turns into plain arts attacks after it lands,
// S3's two hits become four in the 临死模式
const s2Attack = { dmgType: 'arts' };
const s3Attack = { dmgType: 'arts', hits: 2 };
const plainS2 = () => { delete s2Attack.hits; delete s2Attack.atkScale; delete s2Attack.hitAllBlocked; };
/** S2: the shield's centre after `turned` degrees (row 0 is the bottom row: counter-clockwise = a growing angle). */
const shieldAt = (unit, sh) => {
const a = sh.a0 + (sh.turned * Math.PI) / 180;
return { x: unit.x + SHIELD_START * Math.cos(a), y: unit.y + SHIELD_START * Math.sin(a) };
};
const touching = (battle, unit, p) => battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND) && bodyInRadius(e, p.x, p.y, SHIELD_RADIUS));
/** S2: one shield pulse — 停顿, the arts hit, and her heal of heal_ratio × the HP it removed (ignoring her 禁疗). */
const shieldPulse = (battle, unit, p) => {
for (const e of touching(battle, unit, p)) {
battle.applyStatus(e, 'sluggish', { duration: num(b2.sluggish, 1), source: unit });
if (!e.alive) continue;
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.shield_atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'hsgma2:shield'] });
if (dealt > 0 && unit.alive) battle.heal(unit, unit, dealt * num(b2.heal_ratio), { self: true, ignoreHealFree: true });
battle.fx('strike', { x: p.x, y: p.y, id: unit.id, skill: 'hsgma2:shield' });
}
};
return {
trait: { dmgType: 'phys' },
skills: {
[S1]: {
kind: 'toggle', trigger: { rule: 'SP_FULL' },
mods: nz({ atkPct: num(b1.atk), defPct: num(b1.def) }),
attack: { dmgType: 'arts' },
},
[S2]: {
kind: 'duration', duration: S2_CAP,
attack: s2Attack,
onStart({ unit }) { Object.assign(s2Attack, throwParts); unit.mem.hsShield = null; },
onAttack({ battle, unit }) {
if (!('hits' in s2Attack)) return;
plainS2(); // the throw was this attack: the shield flies from now on
const [fr, fc] = unit.fwd;
unit.mem.hsShield = { a0: Math.atan2(fr, fc), turned: 0, acc: 0 };
battle.fx('zone', { x: unit.x, y: unit.y, radius: SHIELD_START + SHIELD_RADIUS, dur: SHIELD_TURN / SHIELD_SPEED, id: unit.id, skill: 'hsgma2:shield' });
shieldPulse(battle, unit, shieldAt(unit, unit.mem.hsShield));
},
onTick({ battle, unit, skill, dt }) {
const sh = unit.mem.hsShield;
if (!sh) return;
const iv = Math.max(0.05, num(b2.interval, 0.5));
sh.acc += dt;
while (sh.acc + 1e-9 >= iv && unit.mem.hsShield === sh) { sh.acc -= iv; shieldPulse(battle, unit, shieldAt(unit, sh)); }
if (unit.mem.hsShield !== sh) return;
const slowed = touching(battle, unit, shieldAt(unit, sh)).length > 0;
sh.turned += SHIELD_SPEED * (slowed ? 1 - SHIELD_SLOW : 1) * dt;
if (sh.turned >= SHIELD_TURN - 1e-9) skill.end('shield');
},
onEnd({ unit }) { unit.mem.hsShield = null; plainS2(); },
},
[S3]: {
kind: 'duration',
mods: nz({ hpPct: num(b3.max_hp), atkPct: num(b3.atk) }),
targeting: { rangeGrid: skillRec(chess, S3)?.rangeGrid ?? X2, maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 1))), canHitFly: true },
attack: s3Attack,
onStart({ unit }) { s3Attack.hits = 2; unit.mem.hsDying = null; },
onEnd({ battle, unit, skill, reason }) {
const dying = unit.mem.hsDying;
if (!dying && reason === 'manual' && unit.alive && unit.deployed) {
// 主动关闭 ⇒ 临死模式: the skill keeps running (its bonuses, range and override stay; no skillStart — it is
// no new cast) for before_dead_duration s with four hits, 阻回 + 沉默, and 不死 (the fatal hooks below)
const dur = num(b3.before_dead_duration, 11);
unit.mem.hsDying = { seq: unit.deploySeq };
s3Attack.hits = 4;
skill.active = true;
skill.timeLeft = dur;
battle.addBuff(unit, { key: DYING, duration: dur, flags: { noSp: true, silence: true }, visible: true, tags: ['skill'] });
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id });
return;
}
if (!dying) return;
unit.mem.hsDying = null;
s3Attack.hits = 2;
battle.removeBuff(unit, DYING);
// "11秒后强制退出战场" (视为撤回干员)
if (reason === 'duration' && unit.alive && unit.deployed) battle.retreat(unit, { reason: 'retreat', dying: true });
},
},
},
talents: [
{ install(battle, unit) { // 业火: 我执 — the negative pool, 禁疗, the quiet-time regeneration
const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp;
const quiet = num(t0['hsgma2_t_1[heal].interval'], 5);
const regen = num(t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']);
const enter = (pool) => {
unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time };
battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] });
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id, talent: 'hsgma2:ego' });
};
const leave = () => {
unit.mem.hsEgo = null;
battle.removeBuff(unit, EGO);
battle.removeBuff(unit, EGO_REGEN);
};
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.hsOverflow = false; leave(); } }, { owner: unit });
// a damage instance (after the shields): into the pool while in 我执, or the lethal one opens it
battle.on('hpDamage', (c) => {
if (c.target !== unit || !unit.alive) return;
const ego = unit.mem.hsEgo;
if (ego) {
ego.lastHurt = battle.time;
if (ego.pool + c.amount < capOf()) { ego.pool += c.amount; c.amount = 0; } else unit.mem.hsOverflow = true;
return;
}
if (c.amount < unit.hp) return;
const over = c.amount - unit.hp;
if (over < capOf()) { c.amount = Math.max(0, unit.hp - 1); enter(over); } else unit.mem.hsOverflow = true;
}, { owner: unit, priority: -50 });
// HP reaching 0 anyway: the 临死模式's 不死 holds her; a full pool knocks her out; a 流失 goes to the pool
battle.on('fatal', (c) => {
if (c.unit !== unit || c.prevented) return;
const overflow = !!unit.mem.hsOverflow;
unit.mem.hsOverflow = false;
const ego = unit.mem.hsEgo;
if (unit.mem.hsDying) {
c.prevented = true;
if (ego) ego.pool = Math.min(capOf(), ego.pool + c.amount); else enter(Math.min(capOf(), c.amount));
return;
}
if (overflow) return;
const pool = (ego ? ego.pool : 0) + c.amount;
if (pool >= capOf()) return;
c.prevented = true;
if (ego) ego.pool = pool; else enter(pool);
}, { owner: unit, priority: -40 });
battle.on('tick', () => {
unit.mem.hsOverflow = false;
const ego = unit.mem.hsEgo;
if (!ego) return;
if (!up(unit)) { leave(); return; }
// HP above the 1-HP floor (her regeneration, a heal that ignores 禁疗) clears the pool first
const surplus = unit.hp - 1;
if (surplus > 0 && ego.pool > 0) { const take = Math.min(surplus, ego.pool); ego.pool -= take; unit.hp -= take; }
if (!(ego.pool > 1e-9)) { leave(); return; }
const want = regen > 0 && battle.time - ego.lastHurt >= quiet - 1e-9;
const has = !!unit.findBuff(EGO_REGEN);
if (want && !has) battle.addBuff(unit, { key: EGO_REGEN, mods: { hpRegenRatio: regen }, tags: ['talent'] });
else if (!want && has) battle.removeBuff(unit, EGO_REGEN);
}, { owner: unit });
} },
{ install(battle, unit) { // 鬼之架势: 坚忍 RES / ATK by the HP lost; AST-X stage 3: ATK +atk more at the maximum
const hi = num(t1.max_hp_ratio, 1), lo = num(t1.min_hp_ratio, 0.3);
const res0 = num(t1.max_magic_resistance), res1 = num(t1.min_magic_resistance);
const atk0 = num(t1.max_atk), atk1 = num(t1.min_atk), extra = num(t1.atk);
if (!(hi > lo) || !(res0 || res1 || atk0 || atk1)) return;
battle.on('tick', () => {
const cur = unit.findBuff(BERSERK);
if (!up(unit)) { if (cur) battle.removeBuff(unit, BERSERK); return; }
const f = clamp01((hi - unit.hpRatio) / (hi - lo));
if (cur && Math.abs((cur.data?.f ?? -1) - f) < 1e-4) return;
const mods = nz({ resFlat: res0 + (res1 - res0) * f, atkPct: atk0 + (atk1 - atk0) * f + (f >= 1 - 1e-9 ? extra : 0) });
if (!Object.keys(mods).length) { if (cur) battle.removeBuff(unit, BERSERK); return; }
battle.addBuff(unit, { key: BERSERK, mods, data: { f }, tags: ['talent'] });
}, { owner: unit });
} },
],
install(battle, unit) {
// S1 恶业苦果: every enemy attack she takes ⇒ atk_scale × ATK arts back to the attacker
battle.on('damaged', (c) => {
const s = c.source;
if (c.target !== unit || !unit.alive || unit.skill?.id !== S1 || !unit.skill.active) return;
if (!s || s.side !== 'enemy' || !s.alive || !c.dmg?.isAttack) return;
battle.dealDamage(unit, s, { amount: unit.s.atk * num(b1.atk_scale), type: 'arts', canDodge: false, isSkill: true, ignoreSelect: true, tags: ['skill', 'counter'] });
battle.fx('counter', { x: s.x, y: s.y, id: unit.id });
}, { owner: unit });
// AST-X 无迹: her attack damage ⇒ +atk_scale × ATK arts (附加伤害); an enemy attack she takes ⇒ atk_scale × ATK arts back
const astx = num(tb.atk_scale);
if (astx > 0) {
battle.on('damaged', (c) => {
const t = c.target;
if (c.source !== unit || !c.dmg?.isAttack || c.type === 'element' || !t || t.side !== 'enemy' || !hasHp(t)) return;
battle.dealDamage(unit, t, { amount: unit.s.atk * astx, type: 'arts', canDodge: false, tags: ['module', ASTX] });
}, { owner: unit, priority: -20 });
battle.on('damaged', (c) => {
const s = c.source;
if (c.target !== unit || !unit.alive || !s || s.side !== 'enemy' || !s.alive || !c.dmg?.isAttack) return;
battle.dealDamage(unit, s, { amount: unit.s.atk * astx, type: 'arts', ignoreSelect: true, tags: ['module', ASTX, 'counter'] });
}, { owner: unit });
}
// S3 临死模式: every other allied operator on her range holds 不死; the part of a damage instance past its HP is her 流失
const shelters = (t) => !!unit.mem.hsDying && up(unit) && !!t && t !== unit && t.side === 'ally' && t.kind === 'op' && t.alive
&& !!unit.rangeKeySet && unit.rangeKeySet.has(t.tileR * COLS + t.tileC);
battle.on('hpDamage', (c) => {
const t = c.target;
if (!shelters(t) || c.amount < t.hp) return;
const over = c.amount - t.hp;
c.amount = Math.max(0, t.hp - 1);
if (over > 0) battle.loseHp(unit, over, { source: c.credit ?? null, tags: ['skill', 'hsgma2:shoulder'] });
}, { owner: unit, priority: -60 });
battle.on('fatal', (c) => { if (!c.prevented && shelters(c.unit)) c.prevented = true; }, { owner: unit, priority: -40 });
},
};
},
};
+170
View File
@@ -0,0 +1,170 @@
// server/sim/content/kits/ops/op-nian.js — 年 (char_2014_nian) 自选 operator kit: 6★ 铁卫 (重装), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_2014_nian, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), gamedata_const
// ba.shield 护盾 ("每层护盾可以抵挡一次伤害") and ba.buffres 抵抗, PRTS 年 (积甲成山 备注 "根据重装干员数量获得的治疗效果加成与
// 生命上限加成叠加时以加法叠加"; 干明可鉴 备注 "攻击力与防御力增益持续时间无限"; 铜印 备注 "沉默效果与反伤效果会直接选中伤害来源,
// 但仅对阵营为敌方的来源产生效果"; 铁御 技能范围 x-2).
// - Trait (铁卫) "能够阻挡三个敌人": the profession default (block 3 from the data stats), ground-only physical melee on her
// 1-1. Module PRO-X 混沌阵列 adds "阻挡敌人时防御力+20%" (trait bb def): DEF + while she blocks at least one enemy.
// Module PRO-Y “请勿叩门” "能够阻挡四个敌人" (attr block_cnt +1, already in the stats). From stage 2 its trait part carries
// the blackboard of 积甲成山's module text (battle_equip_table: TRAIT part max_hp / heal_scale / heal_scale_addition /
// heal_scale_max_value / max_stack_cnt, the talent part only the team bonus) "场上每有一名重装干员,自身受到治疗效果和生命上限
// 额外提升4%,最多叠加3层": n = the 重装 operators on the field, her included [ASSUMED: "每有一名重装干员" counts every 重装 on
// the field] and a teammate's in a shared field (alliesFor), at most max_stack_cnt — max HP +max_hp × n (直接乘算 with her
// other "+%" bonuses) and healing received ×(heal_scale + heal_scale_addition × n), at most heal_scale_max_value (the
// PRTS note's 加法叠加: the stacks add up, 104 / 108 / 112 %). Re-counted every tick.
// - T1 积甲成山 "编入队伍时,所有【重装】职业干员的生命上限+16%": every 重装 operator of her player's team (her included) max HP
// +max_hp for the whole battle, deployed or not (the kits' 编入队伍时 convention: 斯卡蒂 深海掠食者, 早露 学生楷模). PRO-Y
// stage 3: +21 % (the module talent change).
// - T2 干明可鉴 "部署后立即获得3层护盾": `times` 护盾 layers at every deployment (a hit-negating barrier: buff shieldHits — each
// layer negates one damage instance). PRO-X stage 2+ (talent change atk / def / sp / max_stack_cnt) "每层护盾破裂时,攻击力
// +7%,防御力+7%且获得3点技力": each broken layer one more stack of ATK / DEF + (at most max_stack_cnt; "持续时间无限": until she
// leaves the field [ASSUMED: a deployment's layers give a deployment's stacks]) and +sp SP (none while a skill runs: AK's
// no-SP-during-a-skill rule, tier1 giveSp).
// - S1 锡灼 (MANUAL, time SP; data DEFAULT — the owner's 重装 cast-in-range exception, rawRule TAKE_DAMAGE): `duration` s of
// DEF +def and ATK +atk; normal attacks deal arts damage.
// - S2 铜印 (MANUAL; data DEFAULT): `duration` s — stops attacking; DEF +def, block +block_cnt; every enemy attack she takes
// (each damage instance of one, as 星熊 荆棘 / 泡泡) deals atk_scale × ATK arts to the attacker and silences it `silence`
// s ("失去特殊能力"): a direct pick of the damage source (ignoreSelect), enemies only (PRTS 备注), even when a 护盾 layer
// negated the hit.
// - S3 铁御 (MANUAL; data DEFAULT — its x-2 is a 技能范围 for the allies, no attack-range change): `duration` s of ATK
// +[self].atk (block +[self].block_cnt = 0); every other allied operator on her x-2 ("周围其他友方干员": summons are no 干员
// [ASSUMED]) gets DEF +[ally].def, block +[ally].block_cnt and 抵抗 (the engine `resist` status, value
// −one_minus_status_resistance), refreshed every AURA s while the skill runs; two 年 of a shared field keep the stronger
// (installAura's rule). The DEF / block buff ends with the skill, the 抵抗 within AURA_DUR.
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up, giveSp } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_nian_1';
const S2 = 'skchr_nian_2';
const S3 = 'skchr_nian_3';
/** 铁御's aura: refresh period and buff lifetime (s) — the tier-4 kits' AURA / AURA_DUR. */
const AURA = 0.2;
const AURA_DUR = 0.25;
/** 铁御's 技能范围 x-2 when the data carries no grid. */
const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1],
[0, 2], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]);
const SHIELD = 'talent:nian:shield';
const BREAK = 'talent:nian:shieldBreak';
const GUARD = 'skill:nian:ironGuard';
const FORT = 'trait:nian:fortify';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 【重装】 operator (summons are never 重装 operators). */
const isTank = (a) => !!a && a.kind === 'op' && a.def?.profession === 'TANK';
/** The mods of `o` without its zero entries. */
const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0));
export default {
char_2014_nian: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const guardGrid = skillRec(chess, S3)?.rangeGrid ?? X2;
const allyDef = num(b3['nian_s_3[ally].def']), allyBlock = num(b3['nian_s_3[ally].block_cnt']);
const resist = Math.min(0.95, Math.max(0, -num(b3.one_minus_status_resistance)));
/** 铁御: the other allied operators on her x-2 get the DEF / block buff and 抵抗 for AURA_DUR. */
const ironGuard = (battle, unit) => {
const keys = new Set(absoluteRangeKeys(guardGrid, unit.tileR, unit.tileC, unit.dir, 0));
for (const a of battle.alliesFor(unit)) {
if (a === unit || a.kind !== 'op' || a.hidden || !keys.has(a.tileR * COLS + a.tileC)) continue;
const cur = a.findBuff(GUARD);
if (!(cur && cur.source !== unit && (cur.data?.v ?? 0) > allyDef && cur.timeLeft > 0.05)) {
battle.addBuff(a, { key: GUARD, duration: AURA_DUR, mods: nz({ defPct: allyDef, blockCnt: allyBlock }), source: unit, data: { v: allyDef }, tags: ['skill', 'aura'] });
}
if (resist > 0) battle.applyStatus(a, 'resist', { duration: AURA_DUR, value: resist, source: unit });
}
};
return {
skills: {
[S1]: { kind: 'duration', mods: nz({ defPct: num(b1.def), atkPct: num(b1.atk) }), attack: { dmgType: 'arts' } },
[S2]: { kind: 'duration', mods: nz({ defPct: num(b2.def), blockCnt: num(b2.block_cnt) }), attack: { noAttack: true } },
[S3]: {
kind: 'duration',
mods: nz({ atkPct: num(b3['nian_s_3[self].atk']), blockCnt: num(b3['nian_s_3[self].block_cnt']) }),
onStart({ battle, unit }) {
unit.mem.nianGuardAcc = 0;
ironGuard(battle, unit);
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'nian:ironGuard' });
},
onTick({ battle, unit, dt }) {
unit.mem.nianGuardAcc = (unit.mem.nianGuardAcc ?? 0) + dt;
if (unit.mem.nianGuardAcc + 1e-9 < AURA) return;
unit.mem.nianGuardAcc -= AURA;
ironGuard(battle, unit);
},
onEnd({ battle, unit }) {
for (const a of battle.allyUnits) {
const b = a.findBuff(GUARD);
if (b && b.source === unit) battle.removeBuff(a, b);
}
},
},
},
talents: [
{ install(battle, unit) { // 积甲成山: every 重装 operator of her team max HP +max_hp (PRO-Y stage 3: the module's value)
const hp = num(t0.max_hp);
if (!(hp > 0)) return;
for (const a of battle.allyUnits) if (isTank(a) && a.ownerId === unit.ownerId) statBuff(battle, a, 'talent:nian:armor', { hpPct: hp });
} },
{ install(battle, unit) { // 干明可鉴: `times` 护盾 layers at every deployment; PRO-X stage 2+: ATK / DEF stack + SP per broken layer
const times = Math.max(0, Math.floor(num(t1.times)));
if (!(times > 0)) return;
const atk = num(t1.atk), def = num(t1.def), sp = num(t1.sp), cap = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1)));
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
unit.mem.nianLayers = times;
battle.addBuff(unit, { key: SHIELD, shieldHits: times, visible: true, tags: ['talent'] });
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, n: times });
}, { owner: unit });
if (!(atk || def || sp)) return;
battle.on('damaged', (c) => {
if (c.target !== unit || !unit.alive) return;
const left = unit.findBuff(SHIELD)?.shieldHits ?? 0;
const broken = (unit.mem.nianLayers ?? 0) - left;
if (!(broken > 0)) return;
unit.mem.nianLayers = left;
for (let i = 0; i < broken; i++) {
if (atk || def) battle.addBuff(unit, { key: BREAK, refresh: 'stack', stacks: 1, maxStacks: cap, mods: nz({ atkPct: atk, defPct: def }), visible: true, tags: ['talent'] });
giveSp(unit, sp);
}
battle.fx('shieldBreak', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
} },
],
install(battle, unit) {
// PRO-X 混沌阵列: 阻挡敌人时防御力+20%
const blockDef = num(tb.def);
if (blockDef) toggleBuff(battle, unit, 'trait:nian:block', () => unit.blocking.length > 0, { defPct: blockDef });
// PRO-Y stage 2+: per 重装 operator on the field max HP +max_hp and healing received +heal_scale_addition (≤ the cap)
const perHp = num(tb.max_hp), healAdd = num(tb.heal_scale_addition), stackCap = Math.floor(num(tb.max_stack_cnt));
if ((perHp > 0 || healAdd > 0) && stackCap > 0) {
const healBase = num(tb.heal_scale, 1), healMax = num(tb.heal_scale_max_value, Infinity);
battle.on('tick', () => {
const cur = unit.findBuff(FORT);
const n = up(unit) ? Math.min(stackCap, battle.alliesFor(unit).filter(isTank).length) : 0;
if ((cur?.data?.n ?? 0) === n) return;
if (!(n > 0)) { battle.removeBuff(unit, FORT); return; }
battle.addBuff(unit, { key: FORT, mods: nz({ hpPct: perHp * n, healingTakenMul: Math.min(healMax, healBase + healAdd * n) }), data: { n }, tags: ['trait'] });
}, { owner: unit });
}
// S2 铜印: every enemy attack she takes ⇒ atk_scale × ATK arts back to the attacker + silence `silence` s
const scale = num(b2.atk_scale), sil = num(b2.silence);
battle.on('damaged', (c) => {
const src = c.source;
if (c.target !== unit || !unit.alive || unit.skill?.id !== S2 || !unit.skill.active) return;
if (!src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return;
if (scale > 0) battle.dealDamage(unit, src, { amount: unit.s.atk * scale, type: 'arts', canDodge: false, isSkill: true, ignoreSelect: true, tags: ['skill', 'counter'] });
if (sil > 0 && src.alive) battle.applyStatus(src, 'silence', { duration: sil, source: unit });
battle.fx('counter', { x: src.x, y: src.y, id: unit.id });
}, { owner: unit });
},
};
},
};
+301
View File
@@ -0,0 +1,301 @@
// server/sim/content/kits/ops/op-thumpy.js — 珊比 (char_4235_thumpy) 自选 operator kit: 6★ 本源铁卫 (重装), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4235_thumpy, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, PRP-X at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), gamedata_const
// ba.dt.erosion2 侵蚀损伤·我方 / ba.barrier 屏障 / ba.sluggish 停顿, PRTS 珊比 (talent 备注 "每次珊比成功造成伤害后,产生侵蚀
// 损伤/冷却减少效果;且实际上侵蚀损伤与冷却减少为互斥的效果…侵蚀损伤爆发期间将改为减少当前爆发持续时间", "每次珊比成功造成伤害后,
// 才会对目标施加用于检测侵蚀损伤爆发的【脚板标记】…珊比退场时清除所有已施加的标记", "若触发天赋时珊比已有来自此天赋的屏障,则改为
// 为已有屏障Buff累加伤害吸收量"; S1 备注 "推动效果为径向推力,攻击到未被阻挡的敌人时也会生效"; S2 备注 — the spraying order,
// "强力胶的效果施加无视无法选择,但其不会对隐藏、隐匿、无敌的单位产生停顿与伤害效果", "停顿效果先于此技能的物理伤害生效", "追加侵蚀
// 损伤的“其他干员”判断以伤害的来源为准,无来源伤害始终无法触发此效果"; S3 备注 — the conveyor "影响自身前方4格可通行地块(不包括
// 自身所在格,中心判定)内的所有未被阻挡的地面敌方敌人,可影响隐匿单位,无法影响自缚单位", "每帧…传送0.8/30格…卫戍协议为每秒20帧",
// "伤害效果影响自身所在格及正前方4格内所有地面敌方单位,以及所有被自身阻挡的敌方单位").
// - Trait (本源铁卫) "能够阻挡三个敌人,可以造成元素损伤": the profession default (block 3), ground-only physical melee on her
// own tile (range 0-1). Module PRP-X 出发的勇气 adds "阻挡敌人时,自身造成的元素损伤提升15%" (trait bb ep_damage_scale):
// every element fill she deals ×ep_damage_scale while she blocks — an `elementHit` multiplier (the kits' convention: 菲莱 /
// 余 PRP-X).
// - T1 探险理论: "珊比受到的元素损伤降低15%" (ep_damage_scale 0.85): an `elementHit` multiplier on her at priority 20 (the
// operators' 受到的元素损伤 convention, docs/SIM.md §7.2). "珊比造成物理伤害时,附带攻击力10%的侵蚀损伤,并使目标的侵蚀损伤冷却减少
// 1秒" (ep_damage_ratio[trigger] / duration_dec): after each physical damage instance of hers that removes HP — attacks,
// the glue and conveyor ticks too ("每次珊比成功造成伤害后") — ep_damage_ratio[trigger] × ATK 侵蚀损伤 on the target, or,
// while its 侵蚀 burst (爆发冷却) runs, that cooldown −duration_dec s instead. The skills' own ep_damage_ratio[trigger] 0
// is read as their ability carrying no rider of its own [ASSUMED: PRTS names no exception to the talent].
// - T2 坚硬脚板: her HP damage marks the enemy (【脚板标记】, kept through a 重生, cleared when she leaves the field); a marked
// enemy's 侵蚀 burst — whoever filled it — gives her one stack of DEF +def (PRP-X stage 3: and ATK +atk; flat, at most
// max_stack_cnt, until she leaves the field [ASSUMED]) and shield_value 屏障 (one barrier buff: a further trigger adds to
// it), at most scale × her max HP.
// - S1 “还不走?” (MANUAL; data DEFAULT — the owner's 重装 cast-in-range exception, rawRule TAKE_DAMAGE): `duration` s of
// ATK / DEF +; she strikes every enemy she blocks at once and pushes each one she strikes with 小力 (attack@force 0) —
// radially from her (PRTS 备注), Battle.push's official 力度 − 重量 distance.
// - S2 “慢慢走~” (MANUAL; DEFAULT): `duration` s of ATK / DEF +atk / +def. At the cast she sprays glue on her own tile (not
// counted) and max_cnt − 1 (4, "占据4格") passable ground tiles by PRTS's order — front tile, its right, its left, its
// front (front blocked: her left, her right), then spreading from the last sprayed tile not spread yet: right, left,
// front — left / right / front being hers (glueTiles). Every `interval` s, the cast included [ASSUMED: the first pulse at
// the cast], every ground enemy on the glue [ASSUMED: the glue lies on the ground; flyers pass over it] — untargetable
// ones too, never a 隐藏 / 隐匿 / 无敌 one — takes 停顿 `sluggish` s, then atk_scale × ATK physical. While it lies,
// another allied operator's physical damage that removes HP from an enemy on the glue adds ep_damage_ratio[damage] × her
// ATK 侵蚀损伤 from her (her PRP-X scales it) [ASSUMED: the rider is hers]; 无来源 damage never does.
// - S3 “不准走!” (MANUAL; DEFAULT): `duration` s — block +block_cnt, strikes every blocked enemy at once, ATK / DEF +. The
// conveyor: the passable tiles among the max_cnt − 1 tiles ahead of her (not hers); every tick each unblocked ground enemy
// standing on one (by its centre tile), 重量 ≤ mass_level, not 自缚 — 隐匿 ones too — is carried towards her after its own
// movement by CONVEYOR_FRAME × CONVEYOR_FPS × dt: the official 0.8 / 30 tile per frame at 卫戍协议's 20 frames per second
// (PRTS: 0.533 tiles/s here, not the regular game's 0.8), never into an impassable tile (keeping 0.2 tile off its edge on a
// horizontal belt). Every `interval` s, the cast included [ASSUMED], every targetable ground enemy on her tile and the
// max_cnt − 1 tiles ahead and every enemy she blocks takes atk_scale × ATK physical; an enemy there whose 侵蚀 bursts
// loses thumpy[ep_break_water].def DEF more — permanent and stacking, as the burst's own −120 [ASSUMED].
import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js';
import { offsetTile, frontOf, dirVec } from '../../../dir.js';
import { bodyInKeys } from '../../../body.js';
import { enemyStealthed, tileKeyOf } from '../../../targeting.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_thumpy_1';
const S2 = 'skchr_thumpy_2';
const S3 = 'skchr_thumpy_3';
/** PRTS S3 备注: the conveyor carries 0.8 / 30 tile per frame ("传送带矢量"; conveyor_speed / 30) … */
const CONVEYOR_FRAMES = 30;
/** … and 卫戍协议 runs 20 frames per second ("如果游戏的实际运行帧率发生改变(例如卫戍协议为每秒20帧),位移量也将随之变化"). */
const CONVEYOR_FPS = 20;
/** PRTS S3 备注: a horizontal belt keeps its enemies 0.2 tile off the edge of an impassable tile. */
const BELT_EDGE = 0.2;
/** "占据4格" / "向前展开4格" when the blackboard has no max_cnt (it counts her own tile too: max_cnt − 1). */
const TILES = 4;
/** Her facing-RIGHT frame: right hand, left hand, front ([dRow, dCol]; row 0 is the bottom row — sim/dir.js). */
const RIGHT = Object.freeze([-1, 0]);
const LEFT = Object.freeze([1, 0]);
const FRONT = Object.freeze([0, 1]);
const SOLE = 'talent:thumpy:sole';
const BARRIER = 'talent:thumpy:barrier';
const BELT_BREAK = 'skill:thumpy:beltBreak';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0));
/**
* "可用地块" of the glue / belt: passable ground of the field with no obstacle on it (a 射击台 / mound / 阻隔工事 tile is no
* ground an enemy stands on; the mode's stages have no 假高台 / 坑洞 tiles).
*/
const usable = (battle, r, c) => battle.grid.groundPassable(r, c);
/**
* S2's glue tiles (PRTS 备注 order): her own tile when usable (not counted, never spreads), then `count` tiles — the front
* tile, its right, its left and its front (front unusable: her left, then her right), then spreading from the last sprayed
* tile that has not spread yet to its right, left and front, until `count` tiles or nothing left to spread. Absolute keys.
*/
function glueTiles(battle, unit, count) {
const keys = [];
const sprayed = new Set();
const list = [];
const abs = (lr, lc) => offsetTile(unit.tileR, unit.tileC, lr, lc, unit.dir);
const spray = (lr, lc) => {
if (list.length >= count || sprayed.has(`${lr},${lc}`)) return;
const [r, c] = abs(lr, lc);
if (!usable(battle, r, c)) return;
sprayed.add(`${lr},${lc}`);
list.push({ lr, lc, spread: false });
keys.push(r * COLS + c);
};
const near = ([lr, lc], [dr, dc]) => [lr + dr, lc + dc];
if (usable(battle, unit.tileR, unit.tileC)) { sprayed.add('0,0'); keys.push(unit.tileR * COLS + unit.tileC); }
const front = [0, 1];
if (usable(battle, ...abs(...front))) {
for (const d of [[0, 0], RIGHT, LEFT, FRONT]) spray(...near(front, d));
} else {
for (const d of [LEFT, RIGHT]) spray(...near([0, 0], d));
}
while (list.length < count) {
let at = null;
for (let i = list.length - 1; i >= 0; i--) if (!list[i].spread) { at = list[i]; break; }
if (!at) break;
at.spread = true;
for (const d of [RIGHT, LEFT, FRONT]) spray(...near([at.lr, at.lc], d));
}
return keys;
}
export default {
char_4235_thumpy: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const glueCount = Math.max(0, Math.round(num(b2.max_cnt, TILES + 1)) - 1);
const beltLen = Math.max(0, Math.round(num(b3.max_cnt, TILES + 1)) - 1);
const beltRate = num(b3.conveyor_speed) / CONVEYOR_FRAMES * CONVEYOR_FPS;
const massCap = num(b3.mass_level, 4);
/** S2: one glue pulse — 停顿 first, then the physical hit (PRTS 备注), on every ground enemy on the glue. */
const gluePulse = (battle, unit, glue) => {
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, glue) || enemyStealthed(e) || e.s.flags.invulnerable) continue;
battle.applyStatus(e, 'sluggish', { duration: num(b2.sluggish), source: unit });
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale), type: 'phys', isSkill: true, ignoreSelect: true, tags: ['skill', 'thumpy:glue'] });
}
};
/** S3: the enemies the belt damages — targetable ground enemies on her tile and the tiles ahead, and every one she blocks. */
const beltVictims = (battle, unit, belt) => {
const out = battle.enemiesInKeys(belt.hurtKeys, unit, { canHitFly: false });
for (const e of battle.blockedTargets(unit, unit.profile)) if (!out.includes(e)) out.push(e);
return out;
};
/** S3: the conveyor carries the unblocked ground enemies on its tiles towards her (after their own movement). */
const carry = (battle, unit, belt, dt) => {
const [fr, fc] = belt.fwd;
const step = beltRate * dt;
if (!(step > 0)) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || e.blockedBy || e.s.flags.selfBound || e.s.massLevel > massCap) continue;
if (!belt.carryKeys.has(tileKeyOf(e))) continue;
let nx = e.x - fc * step, ny = e.y - fr * step;
const r = Math.round(ny), c = Math.round(nx);
if (!usable(battle, r, c)) {
// never into an impassable tile; a horizontal belt keeps BELT_EDGE off its edge
const er = Math.round(e.y), ec = Math.round(e.x);
if (fc !== 0) nx = ec - fc * (0.5 - BELT_EDGE); else ny = er - fr * (0.5 - 1e-3);
if (fc !== 0 ? (nx - e.x) * -fc <= 0 : (ny - e.y) * -fr <= 0) continue;
}
e.x = nx; e.y = ny;
if (e.route) e.route.pts = null;
}
};
return {
skills: {
[S1]: {
kind: 'duration',
mods: nz({ atkPct: num(b1.atk), defPct: num(b1.def) }),
attack: {
hitAllBlocked: true,
onEachHit({ battle, unit, target }) {
if (target && target.alive && target.side === 'enemy') battle.push(target, num(b1['attack@force']), { from: unit });
},
},
},
[S2]: {
kind: 'duration',
mods: nz({ atkPct: num(b2.atk), defPct: num(b2.def) }),
onStart({ battle, unit }) {
const keys = glueTiles(battle, unit, glueCount);
unit.mem.thumpyGlue = { keys: new Set(keys), acc: 0 };
const mid = (f) => keys.reduce((s, k) => s + f(k), 0) / Math.max(1, keys.length);
battle.fx('zone', { x: mid((k) => k % COLS), y: mid((k) => Math.floor(k / COLS)), radius: 1.2, dur: num(skillRec(chess, S2)?.duration), id: unit.id, skill: 'thumpy:glue' });
gluePulse(battle, unit, unit.mem.thumpyGlue.keys);
},
onTick({ battle, unit, dt }) {
const g = unit.mem.thumpyGlue;
if (!g) return;
const iv = Math.max(0.05, num(b2.interval, 0.7));
g.acc += dt;
while (g.acc + 1e-9 >= iv && unit.alive) { g.acc -= iv; gluePulse(battle, unit, g.keys); }
},
onEnd({ unit }) { unit.mem.thumpyGlue = null; },
},
[S3]: {
kind: 'duration',
mods: nz({ atkPct: num(b3.atk), defPct: num(b3.def), blockCnt: num(b3.block_cnt) }),
attack: { hitAllBlocked: true },
onStart({ battle, unit }) {
const ahead = [];
for (let k = 1; k <= beltLen; k++) ahead.push(frontOf(unit.tileR, unit.tileC, unit.dir, k));
const inField = ahead.filter(([r, c]) => battle.grid.inRect(r, c));
const belt = {
fwd: dirVec(unit.dir), acc: 0,
carryKeys: new Set(inField.filter(([r, c]) => usable(battle, r, c)).map(([r, c]) => r * COLS + c)),
hurtKeys: [unit.tileR * COLS + unit.tileC, ...inField.map(([r, c]) => r * COLS + c)],
};
belt.hurtSet = new Set(belt.hurtKeys);
unit.mem.thumpyBelt = belt;
const [fr, fc] = belt.fwd, half = (beltLen + 1) / 2;
battle.fx('zone', { x: unit.x + fc * half, y: unit.y + fr * half, radius: half, dur: num(skillRec(chess, S3)?.duration), id: unit.id, skill: 'thumpy:belt' });
for (const e of beltVictims(battle, unit, belt)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'thumpy:belt'] });
},
onTick({ battle, unit, dt }) {
const belt = unit.mem.thumpyBelt;
if (!belt) return;
carry(battle, unit, belt, dt);
const iv = Math.max(0.05, num(b3.interval, 1));
belt.acc += dt;
while (belt.acc + 1e-9 >= iv && unit.alive) {
belt.acc -= iv;
for (const e of beltVictims(battle, unit, belt)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'thumpy:belt'] });
}
},
onEnd({ unit }) { unit.mem.thumpyBelt = null; },
},
},
talents: [
{ install(battle, unit) { // 探险理论: 元素损伤 taken ×ep_damage_scale; her physical damage ⇒ 侵蚀损伤 or the burst −duration_dec s
const taken = num(t0.ep_damage_scale, 1);
if (taken !== 1) {
battle.on('elementHit', (c) => {
if (c.target === unit && c.dmg?.type === 'element') c.dmg.mul *= taken;
}, { owner: unit, priority: 20 });
}
const ratio = num(t0['ep_damage_ratio[trigger]']), dec = num(t0.duration_dec);
battle.on('damaged', (c) => {
const t = c.target;
if (c.source !== unit || c.type !== 'phys' || !(c.amount > 0) || !t || t.side !== 'enemy' || !hasHp(t)) return;
const lock = t.findBuff('erosionBurst');
if (lock) { if (dec > 0) lock.timeLeft = Math.max(0, lock.timeLeft - dec); return; }
if (ratio > 0) battle.dealDamage(unit, t, { type: 'element', element: 'erosion', amount: unit.s.atk * ratio, tags: ['talent', 'thumpy:theory'] });
}, { owner: unit, priority: -10 });
} },
{ install(battle, unit) { // 坚硬脚板: her HP damage marks; a marked enemy's 侵蚀 burst ⇒ DEF (+ATK) stack and 屏障
const def = num(t1.def), atk = num(t1.atk), shield = num(t1.shield_value), cap = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1)));
const capHp = num(t1.scale, 3);
if (!(def || atk || shield)) return;
const marks = () => unit.mem.thumpyMarks ?? (unit.mem.thumpyMarks = new Set());
battle.on('deploy', (c) => { if (c.unit === unit) marks().clear(); }, { owner: unit });
battle.on('death', (c) => { if (c.unit === unit) marks().clear(); }, { owner: unit });
battle.on('damaged', (c) => {
if (c.source === unit && c.target && c.target.side === 'enemy' && c.type !== 'element' && c.amount > 0) marks().add(c.target);
}, { owner: unit, priority: 10 });
battle.on('elementBurst', (c) => {
const t = c.target;
if (c.element !== 'erosion' || !t || t.side !== 'enemy' || !up(unit) || !marks().has(t)) return;
if (def || atk) battle.addBuff(unit, { key: SOLE, refresh: 'stack', stacks: 1, maxStacks: cap, mods: nz({ defFlat: def, atkFlat: atk }), visible: true, tags: ['talent'] });
if (shield > 0) {
const max = capHp * unit.s.maxHp;
const cur = unit.findBuff(BARRIER);
if (cur) { cur.shield = Math.min(max, (cur.shield || 0) + shield); unit.markDirty(); }
else battle.addBuff(unit, { key: BARRIER, shield: Math.min(max, shield), visible: true, tags: ['talent'] });
}
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, talent: 'thumpy:sole' });
}, { owner: unit });
} },
],
install(battle, unit) {
// PRP-X: 阻挡敌人时,自身造成的元素损伤提升15%
const epScale = num(tb.ep_damage_scale, 1);
if (epScale !== 1) {
battle.on('elementHit', (c) => {
if (c.source === unit && c.dmg?.type === 'element' && c.target?.side === 'enemy' && unit.blocking.length > 0) c.dmg.mul *= epScale;
}, { owner: unit });
}
// S2: another operator's physical damage on an enemy in the glue ⇒ ep_damage_ratio[damage] × her ATK 侵蚀损伤
const rider = num(b2['ep_damage_ratio[damage]']);
if (rider > 0) {
battle.on('damaged', (c) => {
const g = unit.mem.thumpyGlue, s = c.source, t = c.target;
if (!g || !up(unit) || !s || s === unit || s.side !== 'ally' || s.kind !== 'op' || c.type !== 'phys' || !(c.amount > 0)) return;
if (!t || t.side !== 'enemy' || t.isFlying || !hasHp(t) || !bodyInKeys(t, g.keys)) return;
battle.dealDamage(unit, t, { type: 'element', element: 'erosion', amount: unit.s.atk * rider, tags: ['skill', 'thumpy:glue'] });
}, { owner: unit });
}
// S3: an enemy on the belt's tiles (or blocked by her) whose 侵蚀 bursts ⇒ DEF −30 more, for good
const breakDef = num(b3['thumpy[ep_break_water].def']);
if (breakDef) {
battle.on('elementBurst', (c) => {
const belt = unit.mem.thumpyBelt, t = c.target;
if (!belt || !up(unit) || c.element !== 'erosion' || !t || t.side !== 'enemy') return;
if (t.blockedBy !== unit && (t.isFlying || !bodyInKeys(t, belt.hurtSet))) return;
battle.addBuff(t, { key: BELT_BREAK, refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: breakDef }, source: unit });
}, { owner: unit });
}
},
};
},
};
+391
View File
@@ -0,0 +1,391 @@
// test/content/op_hsgma2.test.js — the 自选 operator kit of 斩业星熊 (char_1044_hsgma2, 6★ 驭法铁卫; kit
// server/sim/content/kits/ops/op-hsgma2.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 or AST-X
// 无迹 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_hsgma2.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 HSG = 'char_1044_hsgma2';
const FORMS = BACKUPS.units[HSG].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const ASTX = 'uniequip_002_hsgma2';
const S1 = 'skchr_hsgma2_1', S2 = 'skchr_hsgma2_2', S3 = 'skchr_hsgma2_3';
const TEXAS = 'chess_char_1_08_a', YAK = 'chess_char_1_02_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);
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_shooter: dummy('enemy_shooter', { atk: 300, bat: 1, range: 4 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, ASTX].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 = 2, 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', 'fatal', 'death'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: HSG, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const skillBuff = (u) => u.findBuff(`skill:${u.id}`);
const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
const mine = (h, u, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === u && c.target?.side === 'enemy');
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 3, melee ground-only, physical attacks while no skill runs (the data\'s arts is the skill-on type), 炎, no 特质', () => {
assert.equal(OPERATOR_KITS[HSG], KITS[HSG]);
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], [HSG, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, 15], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 'melee', false, 'phys', 1.6], `${label(f)}: 驭法铁卫`);
assert.equal(form.dmgType, 'arts', 'the data carries the skill-on type');
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2592 / 533 / 431, E2 Lv60 3228 / 622 / 517; AST-X +190 / +55 → +325 / +80
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], [2592, 533, 3228, 622]);
assert.deepEqual([modOf(5, ASTX).attr, modOf(6, ASTX).attr], [{ maxHp: 190, atk: 55 }, { maxHp: 325, atk: 80 }]);
});
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(HSG));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(HSG), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: HSG, skillIndex: 2, uniEquipId: ASTX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: HSG, skillIndex: 2, uniEquipId: ASTX } } });
});
test('triggers: S1 (AUTO, self buff) fires at full SP with no enemy (the kit\'s SP_FULL); S2 (AUTO) keeps the data DEFAULT; S3 (MANUAL) the data\'s ACTIVE_RANGE on its x-2 (rawRule TAKE_DAMAGE): an enemy two tiles ahead casts it, a shot from outside the x-2 does not', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const form = formOf(tier, elite);
assert.deepEqual(form.skills.map((s) => [s.skillType, s.trigger.rule, s.trigger.rawRule]), [['AUTO', 'DEFAULT', 'DEFAULT'], ['AUTO', 'DEFAULT', 'DEFAULT'], ['MANUAL', 'ACTIVE_RANGE', 'TAKE_DAMAGE']], `T${tier}: data`);
assert.deepEqual(form.skills[2].trigger.customRangeGrid, form.skills[2].rangeGrid, `T${tier}: the running x-2`);
// S1: no enemy at all
const a = field({ tier, elite, skill: 0 });
assert.deepEqual([a.u.skill.rule, a.u.skill.spType, a.u.skill.kind], ['SP_FULL', 'hurt', 'toggle'], `T${tier} S1`);
a.u.skill.gainSp(999);
assert.ok(a.h.runUntil(() => a.u.skill.active, 0.2), `T${tier} S1: cast at once, no enemy`);
done(a.h);
// S2: waits for an attack
const b = field({ tier, elite, skill: 1 });
assert.deepEqual([b.u.skill.rule, b.u.skill.spType], ['DEFAULT', 'attack'], `T${tier} S2`);
b.u.skill.gainSp(999);
b.h.run(2);
assert.equal(b.u.skill.activations, 0, `T${tier} S2: no target, no cast`);
b.h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(b.h.runUntil(() => b.u.skill.activations === 1, 2), `T${tier} S2: cast on her next attack`);
done(b.h);
// S3: the x-2
const c = field({ tier, elite, skill: 2 });
assert.equal(c.u.skill.rule, 'ACTIVE_RANGE', `T${tier} S3`);
c.h.spawn('enemy_shooter', { pos: [10, 9] });
c.u.skill.gainSp(999);
c.h.run(3);
assert.ok(c.h.hooksOf('damaged').some((x) => x.target === c.u), `T${tier} S3: she is being shot`);
assert.equal(c.u.skill.activations, 0, `T${tier} S3: a hit from outside the x-2 casts nothing`);
c.h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(c.h.runUntil(() => c.u.skill.activations === 1, 1), `T${tier} S3: an enemy two tiles ahead (outside her 1-1) casts it`);
done(c.h);
}
});
test('S1 恶业苦果 (持续时间无限): ATK / DEF +40 % / +55 %, arts attacks, every enemy attack she takes returns 100 % / 130 % ATK arts to the attacker (ranged too); it never ends on its own', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.def, sk.bb.atk_scale], [-1, 15, 0, elite ? 0.55 : 0.4, elite ? 0.55 : 0.4, elite ? 1.3 : 1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 0 });
const near = h.spawn('enemy_dummy', { pos: [10, 5] }), far = h.spawn('enemy_shooter', { pos: [10, 8] });
h.run(2);
const b0 = h.hooksOf('damaged').length;
h.b.dealDamage(near, u, { amount: 10, type: 'phys', isAttack: true });
assert.ok(!mine(h, u, b0).some((c) => c.dmg.tags?.includes('counter')), `T${tier}: no counter before the cast`);
assert.ok(mine(h, u).filter((c) => c.dmg.isAttack).every((c) => c.type === 'phys'), `T${tier}: physical attacks before`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 0.2), `T${tier}: cast`);
assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def }, `T${tier}: the skill buff`);
for (const src of [near, far]) {
const n = h.hooksOf('damaged').length;
h.b.dealDamage(src, u, { amount: 10, type: 'phys', isAttack: true });
const back = mine(h, u, n).filter((c) => c.target === src && c.dmg.tags?.includes('counter'));
assert.equal(back.length, 1, `T${tier}: a counter on ${src.defId}`);
assert.equal(back[0].type, 'arts');
approx(back[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
}
const n1 = h.hooksOf('damaged').length;
h.run(60);
assert.ok(u.skill.active, `T${tier}: still on after 60 s`);
const atk = mine(h, u, n1).filter((c) => c.dmg.isAttack && c.target === near);
assert.ok(atk.length > 10 && atk.every((c) => c.type === 'arts'), `T${tier}: arts attacks while it runs`);
done(h);
}
});
test('S2 无始无明 (AUTO, attack SP 9 / 7): the cast\'s attack throws the shield — 3 hits of 55 % / 70 % ATK arts on every blocked enemy; the shield then turns 365° at 90°/s (4.06 s) around her, no SP meanwhile, her attacks of the flight in arts; then plain physical attacks and SP again', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.spCost, sk.initSp, sk.bb['attack@atk_scale'], sk.bb.shield_atk_scale, sk.bb.sluggish, sk.bb.interval, sk.bb.heal_ratio],
[elite ? 7 : 9, 0, elite ? 0.7 : 0.55, elite ? 1.1 : 0.8, 1, 0.5, elite ? 0.15 : 0.1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
// two enemies on her own tile, blocked: the shield (1.2 tiles out, radius 1.0) never reaches her centre
const a = h.spawn('enemy_dummy', { pos: [10, 5] }), b = h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.blocking.length === 2, 1), `T${tier}: both blocked`);
u.skill.gainSp(999);
const n0 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
const t0 = h.b.time;
const throwHits = mine(h, u, n0).filter((c) => c.dmg.isAttack);
assert.equal(new Set(throwHits.map((c) => c.dmg.attackId)).size, 1, `T${tier}: one attack`);
for (const e of [a, b]) {
const hits = throwHits.filter((c) => c.target === e);
assert.equal(hits.length, 3, `T${tier}: 3 hits on each blocked enemy`);
for (const c of hits) { assert.equal(c.type, 'arts'); approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); }
}
const sp0 = u.skill.sp;
const n1 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => !u.skill.active, 10), `T${tier}: the turn ends it`);
approx(h.b.time - t0, 365 / 90, `T${tier}: 365° at 90°/s`, 0.02);
assert.equal(u.skill.sp, sp0, `T${tier}: no SP during the flight`);
const flight = mine(h, u, n1).filter((c) => c.dmg.isAttack);
assert.ok(flight.length >= 2 && flight.every((c) => c.type === 'arts'), `T${tier}: ${flight.length} arts attacks during it`);
assert.ok(!mine(h, u, n1).some((c) => c.dmg.tags?.includes('hsgma2:shield')), `T${tier}: nothing within 1.0 of the shield`);
const n2 = h.hooksOf('damaged').length;
h.run(4);
const after = mine(h, u, n2).filter((c) => c.dmg.isAttack);
assert.ok(after.length > 0 && after.every((c) => c.type === 'phys' && Math.abs(c.amount - Math.max(u.s.atk * 0.05, u.s.atk)) < 1e-6), `T${tier}: plain physical attacks after`);
assert.ok(u.skill.sp > 0, `T${tier}: attack SP again`);
done(h);
}
});
test('S2 shield: within 1.0 of it a targetable ground enemy slows it to 18°/s; every 0.5 s from the throw each such enemy takes 停顿 1 s and 80 % / 110 % ATK arts, and she heals 10 % / 15 % of the HP it lost (through her 禁疗 too); a flyer is untouched', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
const front = h.spawn('enemy_dummy', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [11, 5] });
h.b.addBuff(u, { key: 'test:healFree', flags: { healFree: true, noHeal: true } }); // her 禁疗 (as in 我执)
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
const t0 = h.b.time;
const atk = u.s.atk; // at full HP (no 坚忍): the hits and heals below use it
h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.tags?.includes('hsgma2:shield')), 1);
u.hp = 100; // from now on her heals show in her HP (坚忍 then raises her ATK: amounts are checked on the first hit)
h.runUntil(() => !u.skill.active, 15);
// slowed while the shield is within 1.0 of the enemy one tile ahead: |θ| ≤ acos(0.6) at both ends of the turn
const slow = (2 * Math.acos(0.6) * 180) / Math.PI - 0;
const startArc = (Math.acos(0.6) * 180) / Math.PI, endArc = 365 - (360 - startArc);
const want = startArc / 18 + endArc / 18 + (365 - startArc - endArc) / 90;
approx(h.b.time - t0, want, `T${tier}: ${want.toFixed(2)} s with the slowed arcs (${slow.toFixed(1)}°)`, 0.02);
const shield = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.tags?.includes('hsgma2:shield'));
assert.ok(shield.length >= 10 && shield.every((c) => c.target === front && c.type === 'arts'), `T${tier}: ${shield.length} shield hits, the front enemy only`);
approx(shield[0].amount, atk * sk.bb.shield_atk_scale, `T${tier}: ${sk.bb.shield_atk_scale * 100} % ATK`);
const sl = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.equal(sl.length, shield.length, `T${tier}: 停顿 with every hit`);
assert.ok(sl.every((c) => c.target === front && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 1 s`);
const heals = h.hooksOf('heal').filter((c) => c.target === u && c.source === u);
assert.ok(heals.length >= shield.length, `T${tier}: a heal per hit`);
approx(heals[0].amount, shield[0].amount * sk.bb.heal_ratio, `T${tier}: ${sk.bb.heal_ratio * 100} % of the HP lost`);
assert.ok(u.hp > 100, `T${tier}: healed through 禁疗`);
assert.ok(!h.hooksOf('damaged').some((c) => c.source === u && c.target === fly), `T${tier}: the flyer untouched`);
done(h);
}
});
test('S3 地狱变相 (32 s): range x-2, max HP +55 % / +70 %, ATK +150 % / +180 %, two arts hits on up to 2 enemies — air units too; back to 1-1 and physical after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.rangeId, sk.bb.max_hp, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.before_dead_duration],
[32, elite ? 50 : 55, elite ? 31 : 28, 'x-2', elite ? 0.7 : 0.55, elite ? 1.8 : 1.5, 2, 11], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2 });
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
const g1 = h.spawn('enemy_dummy', { pos: [9, 6] }), g2 = h.spawn('enemy_dummy', { pos: [11, 6] }), g3 = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
assert.deepEqual(skillBuff(u).mods, { hpPct: sk.bb.max_hp, atkPct: sk.bb.atk }, `T${tier}: the skill buff`);
approx(u.s.maxHp, u.base.maxHp * (1 + sk.bb.max_hp), `T${tier}: max HP`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2 while it runs`);
const n0 = h.hooksOf('damaged').length;
h.run(6);
const atk = mine(h, u, n0).filter((c) => c.dmg.isAttack);
const byAttack = new Map();
for (const c of atk) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c]);
assert.ok(byAttack.size >= 2, `T${tier}: attacks`);
for (const [, list] of byAttack) {
const targets = new Set(list.map((c) => c.target.id));
assert.equal(targets.size, 2, `T${tier}: two of the four enemies on her x-2 per attack`);
for (const id of targets) assert.equal(list.filter((c) => c.target.id === id).length, 2, `T${tier}: two hits each`);
assert.ok(list.every((c) => c.type === 'arts'), `T${tier}: arts`);
}
// with the ground enemies gone, the flyer on her x-2 is struck (技能期间攻击可对空)
for (const g of [g1, g2, g3]) h.b.kill(g, null);
const n1 = h.hooksOf('damaged').length;
h.run(3);
assert.ok(mine(h, u, n1).some((c) => c.target === fly && c.dmg.isAttack && c.type === 'arts'), `T${tier}: air units too`);
u.skill.extend(-999);
h.step();
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1`);
approx(u.s.maxHp, u.base.maxHp, `T${tier}: max HP back`);
done(h);
}
});
test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes it): 11 s with the bonuses kept, four hits, 阻回 + 沉默 + 不死; an allied operator on her range survives at 1 HP and the excess is her 流失, one outside falls; then she leaves the field; a plain stop or the time running out does nothing of it', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 2, others: [{ uid: 2, chessId: TEXAS, row: 11, col: 6 }, { uid: 3, chessId: YAK, row: 12, col: 9 }] });
const tex = h.unit(2), yak = h.unit(3);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
h.run(2);
u.skill.end('manual');
assert.ok(u.skill.active && u.mem.hsDying, `T${tier}: the 临死模式 keeps the skill running`);
approx(u.skill.timeLeft, 11, `T${tier}: 11 s`);
assert.deepEqual(u.findBuff('skill:hsgma2:dying')?.flags, { noSp: true, silence: true }, `T${tier}: 阻回 + 沉默`);
assert.equal(u.skill.spec.attack.hits, 4, `T${tier}: four hits`);
assert.ok(skillBuff(u), `T${tier}: the bonuses stay`);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, `T${tier}: no second cast`);
// 不死 for her …
h.b.dealDamage(e, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive, `T${tier}: she cannot be knocked out`);
// … and for 德克萨斯 on her x-2 (the excess becomes her 流失); 角峰 outside falls
const over = 500;
h.b.dealDamage(e, tex, { amount: tex.hp + over, type: 'true' });
assert.deepEqual([tex.alive, tex.hp], [true, 1], `T${tier}: 德克萨斯 at 1 HP`);
const loss = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.tags?.includes('hsgma2:shoulder'));
assert.equal(loss.length, 1, `T${tier}: one 流失`);
approx(loss[0].amount, over, `T${tier}: the excess`);
assert.ok(u.alive && u.mem.hsEgo, `T${tier}: (she is in 我执 since the blow above; 不死 holds her)`);
h.b.dealDamage(e, yak, { amount: yak.hp + 10, type: 'true' });
assert.equal(yak.alive, false, `T${tier}: 角峰 (off her range) falls`);
h.run(11.1);
const out = h.hooksOf('death').filter((c) => c.unit === u);
assert.deepEqual(out.map((c) => [c.reason, c.dying]), [['retreat', true]], `T${tier}: she leaves the field after 11 s`);
done(h);
// a plain stop / the duration: no 临死模式
for (const how of ['stop', 'time']) {
const g = field({ tier, elite, skill: 2 });
g.h.spawn('enemy_dummy', { pos: [10, 6] });
g.u.skill.gainSp(999);
assert.ok(g.h.runUntil(() => g.u.skill.active, 1));
if (how === 'stop') g.u.skill.stop(); else g.h.runUntil(() => !g.u.skill.active, 40);
assert.deepEqual([g.u.skill.active, !!g.u.mem.hsDying, g.u.alive], [false, false, true], `T${tier} ${how}: no 临死模式`);
done(g.h);
}
}
});
test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 5 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.max_minus_hp_ratio, t0['hsgma2_t_1[heal].interval'], t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']], [2, 5, 0.05], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] });
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
h.step();
const max = u.s.maxHp;
h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' });
assert.ok(u.alive && u.mem.hsEgo, `T${tier}: in 我执`);
assert.equal(u.hp, 1, `T${tier}: the engine's HP floor`);
approx(u.mem.hsEgo.pool, 300, `T${tier}: the excess`);
assert.deepEqual(u.findBuff('talent:hsgma2:ego')?.flags, { noHeal: true, healFree: true }, `T${tier}: 禁疗`);
const sp = u.skill.sp;
h.b.dealDamage(e, u, { amount: 500, type: 'true' });
assert.deepEqual([u.hp, Math.round(u.mem.hsEgo.pool), u.skill.sp], [1, 800, sp + 1], `T${tier}: into the pool, +1 受击回复 SP`);
assert.equal(h.b.heal(h.unit(2), u, 1000), 0, `T${tier}: no heal from others`);
// every HP change goes to the negative HP first: a heal that passes 禁疗 (S2's) lowers it, a 流失 raises it
h.b.heal(u, u, 200, { self: true, ignoreHealFree: true });
h.step();
assert.deepEqual([u.hp, Math.round(u.mem.hsEgo.pool)], [1, 600], `T${tier}: a heal that ignores 禁疗 clears negative HP first`);
h.b.loseHp(u, 200, { source: e });
assert.deepEqual([u.alive, u.hp, Math.round(u.mem.hsEgo.pool)], [true, 1, 800], `T${tier}: a 流失 adds to it`);
// quiet: the regeneration starts at 5 s and clears the pool at 5 % max HP / s
h.run(4.9);
assert.equal(u.findBuff('talent:hsgma2:egoRegen'), null, `T${tier}: not before 5 s`);
h.run(0.2);
assert.deepEqual(u.findBuff('talent:hsgma2:egoRegen')?.mods, { hpRegenRatio: 0.05 }, `T${tier}: 生命回复速度(百分比)`);
const t1 = h.b.time;
assert.ok(h.runUntil(() => !u.mem.hsEgo, 30), `T${tier}: out of 我执`);
approx(h.b.time - t1, 800 / (0.05 * max), `T${tier}: 800 at ${0.05 * max} / s`, 0.03);
assert.ok(!u.findBuff('talent:hsgma2:ego') && !u.findBuff('talent:hsgma2:egoRegen'), `T${tier}: 禁疗 / regeneration gone`);
// again into 我执, then 200 %
h.b.dealDamage(e, u, { amount: u.hp + 100, type: 'true' });
assert.ok(u.mem.hsEgo, `T${tier}: 我执 again`);
h.b.dealDamage(e, u, { amount: 2 * max - 100 - 1, type: 'true' });
assert.ok(u.alive, `T${tier}: just under 200 %`);
h.b.dealDamage(e, u, { amount: 5, type: 'true' });
assert.equal(u.alive, false, `T${tier}: 200 % knocks her out`);
assert.equal(u.removeReason, 'killed');
done(h);
}
});
test('T2 鬼之架势 坚忍: RES up to +50 and ATK up to +35 % by the HP lost, full at 70 % lost; AST-X stage 3: full at 50 % lost and then ATK +15 % more', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x3 = elite && mod === ASTX && tier === 6;
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const tb = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual(tb, x3 ? { ...t1, min_hp_ratio: 0.5, atk: 0.15 } : t1, `${label(f)}: data`);
const lo = x3 ? 0.5 : 0.3;
for (const r of [1, 0.85, lo, 0.1]) {
u.hp = r * u.s.maxHp;
h.step();
const fac = Math.min(1, (1 - r) / (1 - lo));
const b = u.findBuff('talent:hsgma2:berserk');
if (fac === 0) { assert.equal(b, null, `${label(f)} ${r}: nothing at full HP`); continue; }
approx(b.mods.resFlat, 50 * fac, `${label(f)} ${r}: RES`);
approx(b.mods.atkPct, 0.35 * fac + (x3 && fac >= 1 ? 0.15 : 0), `${label(f)} ${r}: ATK`);
}
approx(u.s.res, 15 + 50, `${label(f)}: RES 65 at the most`);
done(h);
}
});
test('AST-X 无迹 trait (stages 1 and 3, skill or not): every damage instance of her attacks adds 10 % ATK arts (no dodge) on that enemy; every enemy attack she takes returns 10 % ATK arts; none without the module', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x = elite && mod === ASTX;
if (x) assert.deepEqual(modOf(tier, ASTX).traitOverride.bb, { atk_scale: 0.1 }, label(f));
const { h, u } = field({ tier, elite, mod, skill: 2 });
noSp(h, u);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(3.5);
const atk = mine(h, u).filter((c) => c.dmg.isAttack);
const extra = mine(h, u).filter((c) => c.dmg.tags?.includes('hsgma2:astx'));
assert.ok(atk.length >= 2, `${label(f)}: attacks`);
assert.equal(extra.length, x ? atk.length : 0, `${label(f)}: one extra per attack hit`);
if (x) for (const c of extra) { assert.deepEqual([c.type, c.dmg.canDodge, c.dmg.isAttack], ['arts', false, false]); approx(c.amount, 0.1 * u.s.atk, `${label(f)}: 10 % ATK`); }
const n = h.hooksOf('damaged').length;
h.b.dealDamage(e, u, { amount: 10, type: 'phys', isAttack: true });
const back = mine(h, u, n).filter((c) => c.target === e && c.dmg.tags?.includes('hsgma2:astx'));
assert.equal(back.length, x ? 1 : 0, `${label(f)}: returned when attacked`);
if (x) approx(back[0].amount, 0.1 * u.s.atk, `${label(f)}: 10 % ATK back`);
done(h);
}
});
+290
View File
@@ -0,0 +1,290 @@
// test/content/op_nian.test.js — the 自选 operator kit of 年 (char_2014_nian, 6★ 铁卫; kit
// server/sim/content/kits/ops/op-nian.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, PRO-X
// 混沌阵列 or PRO-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_nian.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 NIAN = 'char_2014_nian';
const FORMS = BACKUPS.units[NIAN].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const PROX = 'uniequip_002_nian', PROY = 'uniequip_003_nian';
const S1 = 'skchr_nian_1', S2 = 'skchr_nian_2', S3 = 'skchr_nian_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);
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: 2000, bat: 1 }), enemy_shooter: dummy('enemy_shooter', { atk: 300, bat: 1, range: 3 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PROX, PROY].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 = 2, 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: NIAN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const skillBuff = (u) => u.findBuff(`skill:${u.id}`);
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 3 (PRO-Y: 4), physical melee ground-only, 炎, no 特质', () => {
assert.equal(OPERATOR_KITS[NIAN], KITS[NIAN]);
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], [NIAN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.blockCnt],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 3 + (m?.attr.blockCnt ?? 0)], `${label(f)}: stats`);
assert.equal(u.s.blockCnt, mod === PROY ? 4 : 3, `${label(f)}: 铁卫 blocks 3, “请勿叩门” 4`);
assert.deepEqual([u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], ['melee', false, 'phys', 1.5], `${label(f)}: 铁卫`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2737 / 513 / 529, E2 Lv60 3375 / 583 / 660; PRO-X +200 HP / +60 DEF →
// +375 / +50 / +80, PRO-Y +320 / +35 / +21 / block +1 → +550 / +70 / +30 / block +1
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/1/4/0'].stats.def, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.def], [2737, 513, 529, 3375, 660]);
assert.deepEqual([modOf(5, PROX).attr, modOf(6, PROX).attr, modOf(5, PROY).attr, modOf(6, PROY).attr],
[{ maxHp: 200, def: 60 }, { maxHp: 375, atk: 50, def: 80 }, { maxHp: 320, atk: 35, def: 21, blockCnt: 1 }, { maxHp: 550, atk: 70, def: 30, blockCnt: 1 }]);
});
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(NIAN));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NIAN), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: NIAN, skillIndex: 2, uniEquipId: PROY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: NIAN, skillIndex: 2, uniEquipId: PROY } } });
});
test('triggers: every skill is MANUAL with the data\'s DEFAULT (the owner\'s 重装 cast-in-range exception; rawRule TAKE_DAMAGE) — a ranged hit from outside her 1-1 casts nothing, an enemy on her front tile casts; a flyer there neither draws an attack nor a cast', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
for (const skill of [0, 1, 2]) {
const sk = formOf(tier, elite).skills[skill];
assert.deepEqual([sk.skillType, sk.trigger.rule, sk.trigger.rawRule], ['MANUAL', 'DEFAULT', 'TAKE_DAMAGE'], `T${tier} S${skill + 1}: data`);
const { h, u } = field({ tier, elite, skill });
assert.equal(u.skill.rule, 'DEFAULT', `T${tier} S${skill + 1}`);
h.spawn('enemy_shooter', { pos: [10, 8] });
h.spawn('enemy_fly', { pos: [10, 6] });
u.skill.gainSp(999);
h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source?.defId === 'enemy_shooter'), `T${tier} S${skill + 1}: she is being shot`);
assert.equal(u.skill.activations, 0, `T${tier} S${skill + 1}: no cast on a hit (no TAKE_DAMAGE) nor for the flyer in her range`);
assert.equal(u.stats.attacks, 0, `T${tier} S${skill + 1}: ground-only — the flyer on her front tile is no target`);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier} S${skill + 1}: cast with a ground enemy in her range`);
done(h);
}
}
});
test('S1 锡灼 (23 / 26 s): DEF +40 % / +55 %, ATK +20 % / +30 %, her normal attacks deal arts damage; physical again after', () => {
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.duration, sk.spCost, sk.initSp, sk.bb.def, sk.bb.atk], [elite ? 26 : 23, 30, 10, elite ? 0.55 : 0.4, elite ? 0.3 : 0.2], `T${tier}`);
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
assert.deepEqual(skillBuff(u).mods, { defPct: sk.bb.def, atkPct: sk.bb.atk }, `T${tier}: the skill buff`);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
const n0 = u.stats.attacks;
h.run(4);
const mine = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.isAttack);
assert.ok(u.stats.attacks > n0 && mine.length > 0 && mine.every((c) => c.type === 'arts'), `T${tier}: arts attacks during the skill`);
approx(mine[mine.length - 1].amount, u.s.atk, `T${tier}: 100 % ATK (RES 0)`);
u.skill.extend(-999);
h.run(3);
assert.ok(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack).slice(-1)[0].type === 'phys', `T${tier}: physical once it ends`);
assert.equal(skillBuff(u), null, `T${tier}: buff gone`);
done(h);
}
});
test('S2 铜印 (31 / 32 s): stops attacking; DEF +80 % / +100 %, block +1; every enemy attack she takes returns 60 % / 70 % ATK arts to the attacker (ranged ones too, a direct pick) and silences it 3 s; nothing for a non-attack hit, nothing once it ends', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, PROY]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.deepEqual([sk.duration, sk.spCost, sk.bb.def, sk.bb.block_cnt, sk.bb.atk_scale, sk.bb.silence], [elite ? 32 : 31, 50, elite ? 1 : 0.8, 1, elite ? 0.7 : 0.6, 3], `T${tier}`);
const near = h.spawn('enemy_dummy', { pos: [10, 6] });
const far = h.spawn('enemy_shooter', { pos: [10, 8] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
assert.deepEqual(skillBuff(u).mods, { defPct: sk.bb.def, blockCnt: 1 }, `T${tier}: the skill buff`);
assert.equal(u.s.blockCnt, (mod === PROY ? 4 : 3) + 1, `T${tier}: block +1`);
const n0 = u.stats.attacks;
h.run(3);
assert.equal(u.stats.attacks, n0, `T${tier}: no attack while it runs`);
for (const src of [near, far]) {
const before = h.hooksOf('damaged').length;
h.b.dealDamage(src, u, { amount: 100, type: 'phys', isAttack: true });
const back = h.hooksOf('damaged').slice(before).filter((c) => c.source === u && c.target === src);
assert.equal(back.length, 1, `T${tier}: one counter on ${src.defId}`);
assert.equal(back[0].type, 'arts', `T${tier}: arts`);
approx(back[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
const sil = h.hooksOf('statusApplied').filter((c) => c.source === u && c.target === src && c.status === 'silence').slice(-1)[0];
assert.ok(sil && Math.abs(sil.duration - 3) < 1e-9, `T${tier}: ${src.defId} silenced 3 s`);
}
const before = h.hooksOf('damaged').length;
h.b.dealDamage(near, u, { amount: 100, type: 'phys' });
assert.ok(!h.hooksOf('damaged').slice(before).some((c) => c.source === u), `T${tier}: a non-attack hit returns nothing`);
u.skill.extend(-999);
h.run(0.1);
const after = h.hooksOf('damaged').length;
h.b.dealDamage(near, u, { amount: 100, type: 'phys', isAttack: true });
assert.ok(!h.hooksOf('damaged').slice(after).some((c) => c.source === u), `T${tier}: no counter once it ended`);
assert.equal(u.s.blockCnt, mod === PROY ? 4 : 3, `T${tier}: block back`);
done(h);
}
});
test('S3 铁御 (40 s): ATK +65 % / +80 % (her block +0); every other allied operator on her x-2 gets DEF +40 % / +50 %, block +1 and 抵抗 (0.5) while it runs, one outside nothing; all gone after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.rangeId, sk.bb['nian_s_3[self].atk'], sk.bb['nian_s_3[self].block_cnt'], sk.bb['nian_s_3[ally].def'], sk.bb['nian_s_3[ally].block_cnt'], sk.bb.one_minus_status_resistance],
[40, 85, elite ? 56 : 48, 'x-2', elite ? 0.8 : 0.65, 0, elite ? 0.5 : 0.4, 1, -0.5], `T${tier}`);
// 德克萨斯 one row up and one tile ahead (inside x-2), 角峰 two rows up and three tiles ahead (outside)
const { h, u } = field({ tier, elite, skill: 2, others: [{ uid: 2, chessId: TEXAS, row: 11, col: 6 }, { uid: 3, chessId: YAK, row: 12, col: 8 }] });
const tex = h.unit(2), yak = h.unit(3);
h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb['nian_s_3[self].atk'] }, `T${tier}: her ATK`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: her attack range stays 1-1 (x-2 is the skill's area)`);
h.run(1);
assert.deepEqual(tex.findBuff('skill:nian:ironGuard')?.mods, { defPct: sk.bb['nian_s_3[ally].def'], blockCnt: 1 }, `T${tier}: 德克萨斯 inside x-2`);
assert.equal(tex.s.blockCnt, tex.base.blockCnt + 1, `T${tier}: his block +1`);
assert.equal(h.b.resistOf(tex), 0.5, `T${tier}: 抵抗`);
assert.deepEqual([yak.findBuff('skill:nian:ironGuard'), h.b.resistOf(yak)], [null, 0], `T${tier}: 角峰 outside x-2`);
assert.equal(u.findBuff('skill:nian:ironGuard'), null, `T${tier}: not herself ("其他")`);
h.runUntil(() => !u.skill.active, 60);
assert.equal(tex.findBuff('skill:nian:ironGuard'), null, `T${tier}: the DEF / block buff ends with the skill`);
h.run(0.3);
assert.equal(h.b.resistOf(tex), 0, `T${tier}: the 抵抗 lapses`);
done(h);
}
});
test('T1 积甲成山: every 重装 operator of her team (her included) max HP +16 % for the whole battle — +21 % with PRO-Y stage 3; a 先锋 nothing', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, others: [{ uid: 2, chessId: YAK, row: 11, col: 3 }, { uid: 3, chessId: TEXAS, row: 11, col: 7 }] });
const yak = h.unit(2), tex = h.unit(3);
const want = elite && mod === PROY && tier === 6 ? 0.21 : 0.16;
assert.equal(u.def.raw.talents.find((t) => t.index === 0).bb.max_hp, want, `${label(f)}: data`);
for (const a of [u, yak]) assert.deepEqual(a.findBuff('talent:nian:armor')?.mods, { hpPct: want }, `${label(f)}: ${a.defId}`);
assert.equal(tex.findBuff('talent:nian:armor'), null, `${label(f)}: 德克萨斯 (先锋)`);
assert.ok(yak.findBuff('talent:nian:armor').persist, `${label(f)}: kept through a knock-out (编入队伍时)`);
if (!(elite && mod === PROY && tier === 6)) approx(yak.s.maxHp, yak.base.maxHp * (1 + want), `${label(f)}: 角峰's max HP`);
done(h);
}
});
test('T2 干明可鉴: 3 护盾 layers at every deployment, each negating one damage instance; PRO-X stage 3: each broken layer ATK / DEF +7 % (≤ 3 stacks) and +3 SP; stage 1 and the other forms none', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5 });
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
const x3 = elite && mod === PROX && tier === 6;
assert.deepEqual(t1, x3 ? { times: 3, atk: 0.07, def: 0.07, sp: 3, max_stack_cnt: 3 } : { times: 3 }, `${label(f)}: data`);
assert.equal(u.findBuff('talent:nian:shield')?.shieldHits, 3, `${label(f)}: 3 layers`);
const e = h.spawn('enemy_dummy', { pos: [9, 8] });
assert.ok(u.s.maxHp > 0); // (stats are lazy: settle the HP ratio before reading the HP)
const hp0 = u.hp;
u.skill.sp = 0;
for (let i = 1; i <= 3; i++) {
const sp = u.skill.sp;
h.b.dealDamage(e, u, { amount: 1500, type: 'phys', isAttack: true });
assert.equal(u.hp, hp0, `${label(f)}: hit ${i} negated`);
assert.equal(u.findBuff('talent:nian:shield')?.shieldHits ?? 0, 3 - i, `${label(f)}: ${3 - i} layers left`);
assert.equal(u.findBuff('talent:nian:shieldBreak')?.stacks ?? 0, x3 ? i : 0, `${label(f)}: stacks`);
approx(u.skill.sp - sp, x3 ? 3 : 0, `${label(f)}: SP of layer ${i}`);
}
if (x3) assert.deepEqual(u.findBuff('talent:nian:shieldBreak').mods, { atkPct: 0.07, defPct: 0.07 }, 'per stack (×3)');
if (x3) approx(u.s.atk, u.base.atk * (1 + 0.21), `${label(f)}: ATK +21 %`);
h.b.dealDamage(e, u, { amount: 1500, type: 'phys', isAttack: true });
assert.ok(u.hp < hp0, `${label(f)}: the 4th hit lands`);
assert.equal(u.findBuff('talent:nian:shieldBreak')?.stacks ?? 0, x3 ? 3 : 0, `${label(f)}: no 4th stack`);
// a new deployment: 3 new layers, the stacks of the last one are gone
h.b.retreat(u);
h.b.redeploy(u);
h.step();
assert.equal(u.findBuff('talent:nian:shield')?.shieldHits, 3, `${label(f)}: 3 layers again`);
assert.equal(u.findBuff('talent:nian:shieldBreak'), null, `${label(f)}: stacks reset`);
done(h);
}
});
test('PRO-X 混沌阵列 trait: DEF +20 % while she blocks an enemy (stages 1 and 3); none without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const x = elite && mod === PROX;
if (x) assert.deepEqual(u.def.raw.trait.bb, { def: 0.2 }, label(f));
h.run(0.5);
assert.equal(u.findBuff('trait:nian:block'), null, `${label(f)}: nothing blocked`);
h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.blocking.length > 0, 2), `${label(f)}: blocking`);
h.step();
assert.deepEqual(u.findBuff('trait:nian:block')?.mods ?? null, x ? { defPct: 0.2 } : null, `${label(f)}: DEF +20 %`);
done(h);
}
});
test('PRO-Y “请勿叩门”: blocks 4 at both stages; stage 3 only: per 重装 operator on the field (her included, ≤ 3) max HP +4 % and healing received ×(1 + 0.04 n)', () => {
for (const [tier, n] of [[5, 4], [6, 1], [6, 2], [6, 3], [6, 4]]) {
const yaks = Array.from({ length: n - 1 }, (_, i) => ({ uid: 2 + i, chessId: YAK, row: 12, col: 3 + 2 * i }));
const { h, u } = field({ tier, elite: true, mod: PROY, skill: 0, others: [...yaks, { uid: 9, chessId: TEXAS, row: 9, col: 9 }] });
h.step();
assert.equal(u.s.blockCnt, 4, `T${tier}: blocks 4`);
const tb = u.def.raw.trait.bb;
if (tier === 5) {
assert.deepEqual(tb, {}, 'stage 1: no trait blackboard');
assert.equal(u.findBuff('trait:nian:fortify'), null, 'stage 1: nothing per 重装');
done(h);
continue;
}
assert.deepEqual(tb, { max_hp: 0.04, heal_scale: 1, max_stack_cnt: 3, heal_scale_addition: 0.04, heal_scale_max_value: 1.12 }, 'stage 3');
const k = Math.min(3, n);
const fort = u.findBuff('trait:nian:fortify');
approx(fort.mods.hpPct, 0.04 * k, `${n} 重装: max HP`);
approx(fort.mods.healingTakenMul, 1 + 0.04 * k, `${n} 重装: healing received`);
approx(u.s.maxHp, u.base.maxHp * (1 + 0.21 + 0.04 * k), `${n} 重装: with 积甲成山's 21 %`);
// a heal of 1000 lands as 1000 × (1 + 0.04 k)
u.hp = 1;
const got = h.b.heal(h.unit(9), u, 1000);
approx(got, 1000 * (1 + 0.04 * k), `${n} 重装: heal`);
if (n === 2) { // a 重装 leaving the field takes its stack away
h.b.retreat(h.unit(2));
h.step();
approx(u.findBuff('trait:nian:fortify').mods.hpPct, 0.04, 'back to 1 stack');
}
done(h);
}
});
+356
View File
@@ -0,0 +1,356 @@
// test/content/op_thumpy.test.js — the 自选 operator kit of 珊比 (char_4235_thumpy, 6★ 本源铁卫; kit
// server/sim/content/kits/ops/op-thumpy.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 or PRP-X
// 出发的勇气 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_thumpy.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 THUMPY = 'char_4235_thumpy';
const FORMS = BACKUPS.units[THUMPY].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const PRPX = 'uniequip_002_thumpy';
const S1 = 'skchr_thumpy_1', S2 = 'skchr_thumpy_2', S3 = 'skchr_thumpy_3';
const 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);
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_m1: dummy('enemy_m1', { mass: 1 }),
enemy_m4: dummy('enemy_m4', { mass: 4 }), enemy_m5: dummy('enemy_m5', { mass: 5 }),
enemy_shooter: dummy('enemy_shooter', { atk: 300, bat: 1, range: 3 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PRPX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const K = (r, c) => r * 21 + c;
const sortN = (a) => [...a].sort((x, y) => x - y);
/** A battle with 珊比 as uid 1 at (row, col) facing `dir` (RIGHT), plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, dir = 'RIGHT', others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'elementBurst'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: THUMPY, skillIndex: skill, uniEquipId: mod }, elite, row, col, dir }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const skillBuff = (u) => u.findBuff(`skill:${u.id}`);
/** Keep her own SP (and so her casts) out of the way (阻回). */
const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
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, range 0-1 (her own tile), blocks 3, physical melee ground-only, no 特质', () => {
assert.equal(OPERATOR_KITS[THUMPY], KITS[THUMPY]);
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], [THUMPY, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, 10], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 'melee', false, 'phys', 1.6], `${label(f)}: 本源铁卫`);
assert.deepEqual(u.liveRangeGrid, [[0, 0]], `${label(f)}: 0-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[THUMPY].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2565 / 536 / 430, E2 Lv60 3194 / 625 / 516; PRP-X +250 / +50 → +450 / +80
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], [2565, 536, 3194, 625]);
assert.deepEqual([modOf(5, PRPX).attr, modOf(6, PRPX).attr], [{ maxHp: 250, atk: 50 }, { maxHp: 450, atk: 80 }]);
});
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(THUMPY));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(THUMPY), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: THUMPY, skillIndex: 1, uniEquipId: PRPX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: THUMPY, skillIndex: 1, uniEquipId: PRPX } } });
});
test('triggers: every skill is MANUAL with the data\'s DEFAULT (the owner\'s 重装 cast-in-range exception; rawRule TAKE_DAMAGE) — shot from afar she casts nothing, an enemy on her tile casts; a flyer on it is no target', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
for (const skill of [0, 1, 2]) {
const sk = formOf(tier, elite).skills[skill];
assert.deepEqual([sk.skillType, sk.trigger.rule, sk.trigger.rawRule], ['MANUAL', 'DEFAULT', 'TAKE_DAMAGE'], `T${tier} S${skill + 1}: data`);
const { h, u } = field({ tier, elite, skill });
assert.equal(u.skill.rule, 'DEFAULT');
h.spawn('enemy_shooter', { pos: [10, 7] });
h.spawn('enemy_fly', { pos: [10, 5] });
u.skill.gainSp(999);
h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source?.defId === 'enemy_shooter'), `T${tier} S${skill + 1}: she is being shot`);
assert.deepEqual([u.skill.activations, u.stats.attacks], [0, 0], `T${tier} S${skill + 1}: no cast, no attack on the flyer`);
h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier} S${skill + 1}: cast with an enemy on her tile`);
done(h);
}
}
});
test('S1 “还不走?” (30 s): ATK / DEF +28 % / +37 %; she strikes every enemy she blocks at once and pushes each one radially with 小力 (weight 0: 1.7 tiles; weight 1: 0.44)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.duration, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb['attack@force']], [30, elite ? 34 : 37, elite ? 0.37 : 0.28, elite ? 0.37 : 0.28, 0], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 0 });
const right = h.spawn('enemy_dummy', { pos: [10, 5.4] }), left = h.spawn('enemy_dummy', { pos: [10, 4.6] }), upE = h.spawn('enemy_m1', { pos: [10.4, 5] });
assert.ok(h.runUntil(() => u.blocking.length === 3, 1), `T${tier}: blocks all three`);
const at = [right, left, upE].map((e) => [e.x, e.y]);
u.skill.gainSp(999);
const n0 = h.hooksOf('damaged').length;
// the cast comes as she is about to attack: that attack is the first skill attack (same tick)
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def }, `T${tier}: the skill buff`);
const hit = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg.isAttack && c.dmg.isSkill);
assert.equal(new Set(hit.map((c) => c.dmg.attackId)).size, 1, `T${tier}: one attack`);
assert.deepEqual(sortN(hit.map((c) => c.target.id)), sortN([right.id, left.id, upE.id]), `T${tier}: every blocked enemy struck at once`);
approx(right.x - at[0][0], 1.7, `T${tier}: weight 0 pushed right 1.7`);
approx(at[1][0] - left.x, 1.7, `T${tier}: weight 0 pushed left 1.7`);
approx(upE.y - at[2][1], 0.44, `T${tier}: weight 1 pushed up 0.44`);
done(h);
}
});
test('S2 “慢慢走~” glue: her tile + 4 passable tiles in PRTS\'s order — open ground: the cross on her front tile; front blocked (facing the field edge): left, right, then spreading', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.bb.max_cnt, sk.bb.interval, sk.bb.sluggish], [5, 0.7, 0.5], `T${tier}`);
const a = field({ tier, elite, skill: 1 });
noSp(a.h, a.u);
a.u.skill.addCharge(1);
a.u.skill.activate('test');
// facing RIGHT (row 0 is the bottom row: her right hand is row − 1): own (10,5), front (10,6), its right (9,6), its
// left (11,6), its front (10,7)
assert.deepEqual(sortN(a.u.mem.thumpyGlue.keys), sortN([K(10, 5), K(10, 6), K(9, 6), K(11, 6), K(10, 7)]), `T${tier}: open ground`);
done(a.h);
// facing UP on the top lane (row 12): the front (13,5) is no ground ⇒ her left (12,4), right (12,6), then from (12,6):
// its right (12,7), then from (12,7): (12,8)
const b = field({ tier, elite, skill: 1, row: 12, col: 5, dir: 'UP' });
noSp(b.h, b.u);
b.u.skill.addCharge(1);
b.u.skill.activate('test');
assert.deepEqual(sortN(b.u.mem.thumpyGlue.keys), sortN([K(12, 5), K(12, 4), K(12, 6), K(12, 7), K(12, 8)]), `T${tier}: front blocked`);
done(b.h);
}
});
test('S2 “慢慢走~” (20 s): ATK / DEF +48 % / +57 %; from the cast every 0.7 s each ground enemy on the glue (untargetable ones too) takes 停顿 0.5 s, then 37 % / 43 % ATK physical; none off the glue, no flyer, no 隐匿 one', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.duration, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb.atk_scale], [20, elite ? 27 : 30, elite ? 0.57 : 0.48, elite ? 0.57 : 0.48, elite ? 0.43 : 0.37], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
noSp(h, u);
const on = h.spawn('enemy_dummy', { pos: [11, 6] }), front = h.spawn('enemy_dummy', { pos: [10, 7] });
const off = h.spawn('enemy_dummy', { pos: [10, 8] }), fly = h.spawn('enemy_fly', { pos: [9, 6] });
const ghost = h.spawn('enemy_dummy', { pos: [11, 6] }), hidden = h.spawn('enemy_dummy', { pos: [10, 7] });
h.b.addBuff(ghost, { key: 'test:untargetable', flags: { untargetable: true } });
h.b.addBuff(hidden, { key: 'test:stealth', flags: { stealth: true } });
h.step();
u.skill.addCharge(1);
u.skill.activate('test');
assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def }, `T${tier}: the skill buff`);
h.runUntil(() => !u.skill.active, 25);
const glue = (e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.tags?.includes('thumpy:glue') && c.type === 'phys');
for (const e of [on, front, ghost]) {
const hits = glue(e);
assert.equal(hits.length, 29, `T${tier}: ${e.tileR},${e.tileC} — a pulse at 0, 0.7 … 19.6 s`);
const sl = h.hooksOf('statusApplied').filter((c) => c.source === u && c.target === e && c.status === 'sluggish');
assert.equal(sl.length, 29, `T${tier}: 停顿 each pulse`);
assert.ok(sl.every((c) => Math.abs(c.duration - 0.5) < 1e-9), `T${tier}: 0.5 s`);
}
// the hit is atk_scale × her ATK during the skill (DEF 0); the first lands in the cast's tick
const ref = glue(on)[0];
approx(ref.amount, u.base.atk * (1 + sk.bb.atk) * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
for (const e of [off, fly, hidden]) assert.equal(glue(e).length, 0, `T${tier}: ${e.defId} at ${e.tileR},${e.tileC} untouched`);
assert.equal(u.mem.thumpyGlue, null, `T${tier}: the glue is gone with the skill`);
done(h);
}
});
test('S2 rider: while the glue lies, another operator\'s physical damage to an enemy on it adds 15 % of 珊比\'s ATK 侵蚀损伤 (from her); off the glue, arts, and 无来源 damage add none', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.equal(sk.bb['ep_damage_ratio[damage]'], 0.15, `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] });
const tex = h.unit(2);
noSp(h, u);
const on = h.spawn('enemy_dummy', { pos: [11, 6] }), off = h.spawn('enemy_dummy', { pos: [11, 8] });
h.step();
u.skill.addCharge(1);
u.skill.activate('test');
const ero = (e) => e.elem.erosion;
const e0 = ero(on);
h.b.dealDamage(tex, on, { amount: 100, type: 'phys' });
approx(ero(on) - e0, 0.15 * u.s.atk, `T${tier}: +15 % of her ATK`);
const burstless = h.hooksOf('damaged').filter((c) => c.type === 'element' && c.target === on).slice(-1)[0];
assert.equal(burstless.source, u, `T${tier}: the 侵蚀损伤 is hers`);
const e1 = ero(on);
h.b.dealDamage(tex, on, { amount: 100, type: 'arts' });
h.b.dealDamage(tex, on, { amount: 100, type: 'phys', sourceless: true });
assert.equal(ero(on), e1, `T${tier}: arts / 无来源 add none`);
h.b.dealDamage(tex, off, { amount: 100, type: 'phys' });
assert.equal(ero(off), 0, `T${tier}: off the glue none`);
u.skill.extend(-999);
h.step();
const e2 = ero(on);
h.b.dealDamage(tex, on, { amount: 100, type: 'phys' });
assert.equal(ero(on), e2, `T${tier}: none once the glue is gone`);
done(h);
}
});
test('S3 “不准走!” (60 s): block +2, strikes every blocked enemy, ATK +65 % / +80 %, DEF +75 % / +105 %; the belt carries unblocked ground enemies of 重量 ≤ 4 on the 4 tiles ahead towards her at 0.8 × 20 / 30 tile/s; heavier ones, flyers and blocked ones stay', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.duration, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb.block_cnt, sk.bb.conveyor_speed, sk.bb.mass_level, sk.bb.max_cnt, sk.bb.interval],
[60, elite ? 57 : 58, elite ? 0.8 : 0.65, elite ? 1.05 : 0.75, 2, 0.8, 4, 5, 1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2, col: 4 });
noSp(h, u);
const m4 = h.spawn('enemy_m4', { pos: [10, 7] }), m5 = h.spawn('enemy_m5', { pos: [10, 8] });
const fly = h.spawn('enemy_fly', { pos: [10, 6] }), past = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
u.skill.addCharge(1);
u.skill.activate('test');
assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def, blockCnt: 2 }, `T${tier}: the skill buff`);
assert.equal(u.s.blockCnt, 5, `T${tier}: block 3 + 2`);
assert.deepEqual(sortN(u.mem.thumpyBelt.carryKeys), sortN([K(10, 5), K(10, 6), K(10, 7), K(10, 8)]), `T${tier}: the belt`);
const x0 = m4.x;
h.run(1);
approx(x0 - m4.x, (0.8 * 20) / 30, `T${tier}: 0.533 tile in a second`, 1e-3);
assert.deepEqual([m5.x, fly.x, past.x], [8, 6, 9], `T${tier}: 重量 5, the flyer and the enemy past the belt stay`);
assert.ok(h.runUntil(() => m4.blockedBy === u, 6), `T${tier}: carried until she blocks it`);
const xb = m4.x;
h.run(1);
assert.equal(m4.x, xb, `T${tier}: a blocked enemy is no longer carried`);
done(h);
}
});
test('S3 belt damage: every second from the cast, each targetable ground enemy on her tile and the 4 tiles ahead and each one she blocks takes 27 % / 33 % ATK physical; an enemy whose 侵蚀 bursts there loses 30 DEF more (stacking, for good)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.bb.atk_scale, sk.bb['thumpy[ep_break_water].def']], [elite ? 0.33 : 0.27, -30], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2, col: 4 });
noSp(h, u);
const own = h.spawn('enemy_m5', { pos: [10, 4] }), ahead = h.spawn('enemy_m5', { pos: [10, 8] });
const beyond = h.spawn('enemy_m5', { pos: [10, 9] }), fly = h.spawn('enemy_fly', { pos: [10, 7] }), side = h.spawn('enemy_m5', { pos: [11, 5] });
h.step();
u.skill.addCharge(1);
u.skill.activate('test');
h.run(5.05);
const belt = (e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.tags?.includes('thumpy:belt'));
for (const e of [own, ahead]) {
const hits = belt(e);
assert.equal(hits.length, 6, `T${tier}: ${e.tileR},${e.tileC} — at 0, 1 … 5 s`);
assert.equal(hits[0].type, 'phys');
approx(hits[0].amount, u.base.atk * (1 + sk.bb.atk) * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
}
for (const e of [beyond, fly, side]) assert.equal(belt(e).length, 0, `T${tier}: ${e.defId} at ${e.tileR},${e.tileC} spared`);
// 侵蚀 bursts: on the belt ⇒ −30 more; off it ⇒ the burst's own −120 only
for (const e of [ahead, side]) h.b.dealDamage(null, e, { type: 'element', element: 'erosion', amount: 1000 });
assert.deepEqual([ahead.findBuff('skill:thumpy:beltBreak')?.mods, side.findBuff('skill:thumpy:beltBreak')], [{ defFlat: -30 }, null], `T${tier}: the belt's extra cut`);
assert.equal(ahead.s.def, Math.max(0, 0 - 120 - 30), `T${tier}: DEF 0 floor`);
assert.equal(ahead.findBuff('skill:thumpy:beltBreak').timeLeft, Infinity, `T${tier}: for good`);
h.run(8.1); // the burst lock (8 s) ends: a second burst stacks
h.b.dealDamage(null, ahead, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(ahead.findBuff('skill:thumpy:beltBreak').stacks, 2, `T${tier}: stacking`);
done(h);
}
});
test('T1 探险理论: 元素损伤 she takes ×0.85; her physical damage adds 10 % ATK 侵蚀损伤 (not her arts), or — while the target\'s 侵蚀 burst runs — shortens that burst by 1 s instead', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 9 });
noSp(h, u);
assert.deepEqual(u.def.raw.talents.find((t) => t.index === 0).bb, { ep_damage_scale: 0.85, duration_dec: 1, 'ep_damage_ratio[trigger]': 0.1 }, label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
h.step();
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 100 });
approx(u.elem.burn, 85, `${label(f)}: 元素损伤 taken ×0.85`);
h.b.dealDamage(u, e, { amount: 100, type: 'phys' });
approx(e.elem.erosion, 0.1 * u.s.atk, `${label(f)}: 10 % ATK 侵蚀损伤 (not blocking: no module bonus)`);
const e0 = e.elem.erosion;
h.b.dealDamage(u, e, { amount: 100, type: 'arts' });
assert.equal(e.elem.erosion, e0, `${label(f)}: not on arts damage`);
h.b.dealDamage(null, e, { type: 'element', element: 'erosion', amount: 1000 });
const lock = e.findBuff('erosionBurst');
assert.ok(lock, `${label(f)}: bursting`);
const left = lock.timeLeft;
h.b.dealDamage(u, e, { amount: 100, type: 'phys' });
approx(lock.timeLeft, left - 1, `${label(f)}: the burst 1 s shorter`);
done(h);
}
});
test('T2 坚硬脚板: an enemy she has damaged whose 侵蚀 bursts gives her DEF +8 (PRP-X stage 3: DEF +12, ATK +5; ≤ 30 stacks) and 250 (400) 屏障 added to one barrier, ≤ 300 % max HP; unmarked enemies nothing; the marks go when she leaves', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 9 });
noSp(h, u);
const x3 = elite && mod === PRPX && tier === 6;
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual(t1, x3 ? { def: 12, atk: 5, shield_value: 400, max_stack_cnt: 30, scale: 3 } : { def: 8, atk: 0, shield_value: 250, max_stack_cnt: 30, scale: 3 }, label(f));
const marked = h.spawn('enemy_dummy', { pos: [10, 8] }), other = h.spawn('enemy_dummy', { pos: [11, 8] });
h.step();
h.b.dealDamage(null, other, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(u.findBuff('talent:thumpy:sole'), null, `${label(f)}: an unmarked enemy's burst`);
h.b.dealDamage(u, marked, { amount: 10, type: 'arts' });
h.b.dealDamage(null, marked, { type: 'element', element: 'erosion', amount: 1000 });
assert.deepEqual(u.findBuff('talent:thumpy:sole')?.mods, x3 ? { defFlat: 12, atkFlat: 5 } : { defFlat: 8 }, `${label(f)}: the stack`);
assert.equal(u.findBuff('talent:thumpy:barrier')?.shield, t1.shield_value, `${label(f)}: 屏障`);
h.run(8.1);
h.b.dealDamage(null, marked, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(u.findBuff('talent:thumpy:sole').stacks, 2, `${label(f)}: two stacks`);
approx(u.s.def, u.base.def + 2 * t1.def, `${label(f)}: DEF`);
assert.equal(u.findBuff('talent:thumpy:barrier').shield, 2 * t1.shield_value, `${label(f)}: the same barrier grows`);
u.findBuff('talent:thumpy:barrier').shield = 3 * u.s.maxHp - 10;
h.run(8.1);
h.b.dealDamage(null, marked, { type: 'element', element: 'erosion', amount: 1000 });
approx(u.findBuff('talent:thumpy:barrier').shield, 3 * u.s.maxHp, `${label(f)}: capped at 300 % max HP`);
// she leaves the field: the marks go; back on it, the enemy's burst gives nothing until she damages it again
h.b.retreat(u);
h.b.redeploy(u);
h.step();
h.run(8.1);
h.b.dealDamage(null, marked, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(u.findBuff('talent:thumpy:sole'), null, `${label(f)}: marks cleared when she left`);
done(h);
}
});
test('PRP-X 出发的勇气 trait: every 元素损伤 she deals ×1.15 while she blocks (stages 1 and 3); ×1 when blocking nobody or without the module', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
noSp(h, u);
const x = elite && mod === PRPX;
if (x) assert.deepEqual(u.def.raw.trait.bb, { ep_damage_scale: 1.15 }, label(f));
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
h.step();
h.b.dealDamage(u, far, { amount: 100, type: 'phys' });
approx(far.elem.erosion, 0.1 * u.s.atk, `${label(f)}: blocking nobody`);
const near = h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => near.blockedBy === u, 1), `${label(f)}: blocking`);
const g0 = far.elem.erosion;
h.b.dealDamage(u, far, { amount: 100, type: 'phys' });
approx(far.elem.erosion - g0, 0.1 * u.s.atk * (x ? 1.15 : 1), `${label(f)}: while blocking`);
done(h);
}
});
+308 -104
View File
@@ -1974,8 +1974,8 @@
"snaps": "8eac4d32 fa5c2376 bc1e9184 84fb1db6 b4b3b807 cc6f6399 c665e4ff 261094cd 5d5c61fd 1bf27396 b526ffa6 e433a3a2 7d8bd521 d7eb687d 4d18ecc9 f640c5fd 0912a7d6 cc22df71 1a833e56 d877d28b 8a887ef8 8014c7dd"
},
"diy-049": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
"fast": true,
"about": "5_diy2_b=nian/S1/003_nian 5_diy1_b=nian/S2/003_nian 5_diy2_b=nian/S3/003_nian 5_diy1_a=nian/S1 5_diy2_a=nian/S2 5_diy1_a=nian/S3 5_diy2_b=nian/S1 5_diy1_b=nian/S2 5_diy2_b=nian/S3 5_diy1_b=nian/S1/002_nian 5_diy2_b=nian/S2/002_nian 5_diy1_b=nian/S3/002_nian",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m05",
"modeId": "mode_multi_normal",
@@ -1985,122 +1985,115 @@
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 2,
"killed": 0,
"total": 60,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":2,"total":60,"perfect":false,"leaks":58,"leaksUncounted":0,"coins":0,"dmg":267738,"bossDmg":0,"heal":59968,"deaths":6,"layerGains":{}}},
"players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":81326,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,76,0,304,6384,0,22,1],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,75,0,652,8724,0,41,2],
["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,6,0,21282,21790,0,137,3],
["p1",4,"op","chess_char_5_diy2_b",2,"none",3334,1,8,2,22607,38177,0,115,4],
["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,10,0,2272,0,0,99,2],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,11,0,2916,0,0,108,3],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,1,0,0,0,0,0,0],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,3,0,97722,0,0,178,4],
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,5,0,0,0,29529,69,4],
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,7,0,0,0,30439,60,4],
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,9,0,56880,0,0,144,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,12,0,63104,0,0,136,3]
["p1",1,"op","chess_char_5_diy2_b",0,"uniequip_003_nian",0,0,78,0,28088,8572,0,59,2],
["p1",2,"op","chess_char_5_diy1_b",1,"uniequip_003_nian",3523.05,1,81,0,14152,5049,0,38,2],
["p1",3,"op","chess_char_5_diy2_b",2,"uniequip_003_nian",0,0,63,0,9053,8572,0,47,2],
["p1",4,"op","chess_char_5_diy1_a",0,null,0,0,71,0,7541,6350,0,41,1],
["p1",5,"op","chess_char_5_diy2_a",1,null,3174.92,1,82,0,4412,6350,0,16,1],
["p1",6,"op","chess_char_5_diy1_a",2,null,0,0,76,0,590,9525,0,23,0],
["p1",7,"op","chess_char_5_diy2_b",0,"none",590.11,1,80,0,17490,11155,0,50,2],
["p1",8,"op","chess_char_5_diy1_b",1,"none",3915,1,1,0,0,0,0,0,0],
["p1",9,"op","chess_char_5_diy2_b",2,"none",3915,1,3,0,0,0,0,0,0],
["p1",10,"op","chess_char_5_diy1_b",0,"uniequip_002_nian",4147,1,5,0,0,0,0,0,0],
["p1",11,"op","chess_char_5_diy2_b",1,"uniequip_002_nian",4147,1,7,0,0,0,0,0,0],
["p1",12,"op","chess_char_5_diy1_b",2,"uniequip_002_nian",4147,1,9,0,0,0,0,0,0]
],
"enemies": {"enemy_1045_hammer_2":[12,0,11,37471,77249],"enemy_1061_zomshd":[48,2,45,37605,269489]},
"hooks": [1,76,6900,1205,1205,3051,3051,29902,8,64,33,29,0,37113,285,23,60,56,1,8,0,0,0],
"events": {"atk":1205,"deploy":16,"die":8,"dmg":3051,"heal":129,"leak":56,"skill":62,"spawn":72,"status":555},
"fx": {},
"snaps": "c6bdc18a 6fb12fe1 c3c94d6f f42e9bba 6ea4c93a ff3506ac f551dbb2 248e7a29 e877982d 0b8ac60e d21ffc26 89eda7b5 7ef13ac0 2d2fce6f 32484e38 3407d0b5 1b59f452 55141007 fc87be97 6239dcab e11e96f9 250fe5b6 a75b766c"
"enemies": {"enemy_1045_hammer_2":[12,0,12,22394,28372],"enemy_1061_zomshd":[48,0,41,33180,156554]},
"hooks": [1,82,6900,590,590,3223,3223,20288,14,67,10,9,0,15635,505,70,60,53,1,14,0,0,0],
"events": {"atk":590,"deploy":22,"die":14,"dmg":3175,"fx":67,"leak":53,"skill":19,"spawn":72,"status":142},
"fx": {"buff":2,"counter":43,"shield":22},
"snaps": "1ad2183e 31436dc6 162db746 33388938 61688d2f 0472b4ef 94c39e4b 95f61859 3a70fec9 11ddf128 538ff406 c6a3a4be fadf11ea 33b4744d 57e75bf3 8594a4d7 e618d366 40e9e04d 5a0b2dbb 92e0c87e 6e8be006 cf3a4f90 e2c51214"
},
"diy-050": {
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc",
"about": "6_diy2_b=nian/S1/003_nian 6_diy1_b=nian/S2/003_nian 6_diy2_b=nian/S3/003_nian 6_diy1_a=nian/S1 6_diy2_a=nian/S2 6_diy1_a=nian/S3 6_diy2_b=nian/S1 6_diy1_b=nian/S2 6_diy2_b=nian/S3 6_diy1_b=nian/S1/002_nian 6_diy2_b=nian/S2/002_nian 6_diy1_b=nian/S3/002_nian",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m06",
"modeId": "mode_multi_hard",
"round": 12,
"round": 13,
"seed": 574294847,
"waveId": "act1autochess_h05",
"time": 214.8,
"ticks": 6444,
"waveId": "act1autochess_h06",
"time": 210.47,
"ticks": 6314,
"reason": "cleared",
"killed": 0,
"total": 60,
"total": 105,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 115,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":157435,"bossDmg":0,"heal":12039,"deaths":23,"layerGains":{}}},
"players": {"p1":{"killed":0,"total":105,"perfect":false,"leaks":105,"leaksUncounted":0,"coins":0,"dmg":42730,"bossDmg":0,"heal":0,"deaths":19,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,82,0,28,5208,0,2,0],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3371,1,6,0,0,0,0,0,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,9,0,20856,0,0,0,7],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,72,0,174,5712,0,6,0],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,67,0,13430,5276,0,29,1],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,78,0,61051,12366,0,48,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,85,0,698,3816,0,13,1],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,84,0,2755,4761,0,39,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,52,0,19051,2744,0,7,1],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,51,0,39393,3498,0,7,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,47,0,0,2448,1260,3,0],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,54,0,0,9324,10779,8,1]
["p1",1,"op","chess_char_6_diy2_b",0,"uniequip_003_nian",0,0,126,0,3258,15072,0,11,1],
["p1",2,"op","chess_char_6_diy1_b",1,"uniequip_003_nian",4867,1,7,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_diy2_b",2,"uniequip_003_nian",4867,1,9,0,0,0,0,0,0],
["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,119,0,1898,9525,0,17,0],
["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,75,0,3848,6350,0,0,2],
["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,73,0,795,6350,0,12,0],
["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,68,0,1284,7830,0,18,1],
["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,96,0,14307,7830,0,7,1],
["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,109,0,7919,7830,0,17,1],
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_nian",0,0,1,0,2264,4350,0,4,1],
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_nian",0,0,3,0,4561,4350,0,0,1],
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_nian",0,0,5,0,2595,4350,0,3,1]
],
"enemies": {"enemy_1045_hammer_2":[12,0,12,15694,22503],"enemy_1061_zomshd":[48,0,48,39458,134932]},
"hooks": [1,85,6444,245,245,412,396,10112,23,83,15,15,0,16629,0,28,60,60,1,26,0,0,16],
"events": {"atk":263,"deploy":25,"die":23,"dmg":396,"fx":86,"heal":23,"leak":60,"skill":30,"spawn":72,"status":14},
"fx": {"aoe":5,"dodge":16,"dp":7,"slash":56,"spGain":2},
"snaps": "580b238a aa91295b 3af047b3 a833e612 f6a0b595 1eba1563 0c047aab 7838a66c 34fc5adc 627a4814 4d7c5d84 41a4b79a 721f8563 f6d5a347 76cda6c2 74e73fdb 220b1dd6 2f7b90e4 d3a2ce26 6a84c6a3 1aae6574 0e8ef572"
"enemies": {"enemy_1165_duhond":[51,0,51,4082,30703],"enemy_1174_duholy_2":[24,0,24,20164,2589],"enemy_1175_dushdo_2":[30,0,30,49591,9438]},
"hooks": [1,126,6314,340,340,402,402,0,19,124,9,9,0,11443,375,58,105,105,1,19,0,0,0],
"events": {"atk":340,"deploy":21,"die":19,"dmg":345,"fx":94,"leak":105,"skill":18,"spawn":117,"status":285},
"fx": {"buff":2,"counter":62,"shield":21,"shieldBreak":9},
"snaps": "32c508d1 7fb27f91 1e862c88 ada7c060 c2899984 383b70f2 24fae586 47ebbe16 4b5a8eda 97692424 140fe8ca e3a306f9 4356fe33 a86a479a 3db4401e 2f7b03f7 27739027 66be9939 f23fd6ef 8db2c4d4 a3bacd91"
},
"diy-051": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo",
"about": "5_diy1_a=hsgma2/S1 5_diy2_a=hsgma2/S2 5_diy1_a=hsgma2/S3 5_diy2_b=hsgma2/S1 5_diy1_b=hsgma2/S2 5_diy2_b=hsgma2/S3 5_diy1_b=hsgma2/S1/002_hsgma2 5_diy2_b=hsgma2/S2/002_hsgma2 5_diy1_b=hsgma2/S3/002_hsgma2 6_diy1_a=hsgma2/S1 6_diy2_a=hsgma2/S2 6_diy1_a=hsgma2/S3",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m07",
"modeId": "mode_single_normal",
"round": 13,
"round": 12,
"seed": 3864737019,
"waveId": "act1autochess_h06",
"time": 204.8,
"ticks": 6144,
"reason": "cleared",
"killed": 197,
"total": 290,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 94,
"total": 99,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 282,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":197,"total":290,"perfect":false,"leaks":93,"leaksUncounted":0,"coins":0,"dmg":444885,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
"players": {"p1":{"killed":94,"total":99,"perfect":false,"leaks":5,"leaksUncounted":0,"coins":0,"dmg":1212423,"bossDmg":0,"heal":47022,"deaths":10,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,4,7,21881,2604,0,65,1],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,7,11,35637,3431,0,87,2],
["p1",3,"op","chess_char_5_diy1_a",0,null,1560.61,1,279,11,28493,2169,0,91,2],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",1890.36,1,277,51,98505,2858,0,110,3],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,241,13,39114,3058,0,54,3],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,163,43,73993,3808,0,55,4],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,285,5,26351,8078,0,87,0],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,3,7,19,0,0,16,0],
["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,5,10,60,0,0,60,3],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",2339.65,1,8,13,64107,1191,0,82,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,6,10,18397,0,0,78,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,9,16,38328,0,0,93,2],
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,4,5,57503,2592,0,54,1],
["p1",2,"op","chess_char_5_diy2_a",1,null,2592,1,113,1,21703,2592,1585,18,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,2592,1,109,1,52103,3726,531,33,1],
["p1",4,"op","chess_char_5_diy2_b",0,"none",4.41,1,10,19,140956,3227,6424,81,1],
["p1",5,"op","chess_char_5_diy1_b",1,"none",503.66,1,12,16,277078,20953,22603,105,7],
["p1",6,"op","chess_char_5_diy2_b",2,"none",96.92,1,93,15,189776,7945,0,76,2],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_hsgma2",6.33,1,75,14,130898,6835,63,79,1],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_hsgma2",1,1,3,4,70786,6544,6884,42,4],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_hsgma2",0,0,5,2,78006,4045,0,43,2],
["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,118,0,1485,5186,1404,43,0],
["p1",11,"op","chess_char_6_diy2_a",1,null,2592,1,119,2,60768,2594,6237,41,2],
["p1",12,"op","chess_char_6_diy1_a",2,null,2592,1,120,15,131360,3304,1291,81,2],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {
"enemy_1195_sfyin": [39,30,9,4143,113683],
"enemy_1196_msfyin": [60,32,28,0,65],
"enemy_1197_sfshu": [48,38,10,10130,184299],
"enemy_1198_msfshu": [114,78,36,0,243],
"enemy_1199_sfjin": [18,11,7,12925,146349],
"enemy_1200_msfjin": [11,8,3,0,246]
},
"hooks": [1,314,6144,1470,1470,1969,2082,0,212,305,25,30,0,33234,6218,91,290,93,1,212,0,0,11],
"events": {"atk":1512,"deploy":22,"die":212,"dmg":2076,"fx":273,"leak":93,"skill":64,"spawn":304,"status":488},
"fx": {"aoe":56,"displace":1,"dodge":22,"dp":63,"slash":52,"summon":79},
"snaps": "cf051c06 587dcba3 94ed6adb bba4fd81 815661ad cbf17744 a8d0c19b 32b88916 1e8bde83 51a1d669 b023b185 df725cf4 bf4681aa 2c372e04 1a001766 3d81d3d6 7f8effe5 11826d80 b4fb05d0 0eed6d6c 65f6503d"
"enemies": {"enemy_1010_demon":[42,42,0,19638,489888],"enemy_1320_wdrrl_2":[12,7,4,46424,337623],"enemy_1422_lrsldr_2":[45,45,0,3481,384912]},
"hooks": [1,120,6900,1503,1503,3200,3200,3103,104,108,24,22,0,11731,719,105,99,4,1,104,0,0,0],
"events": {"atk":1606,"deploy":19,"die":104,"dmg":2878,"fx":1024,"heal":549,"leak":4,"skill":46,"spawn":113,"status":281},
"fx": {"counter":257,"explode":10,"strike":719,"undying":24,"zone":14},
"snaps": "36780204 dac8d98e 6f2e981c 459ada1b 042719cc cf606764 a0f56997 4c425c7e b6159d94 a81a5366 644837d2 641691ef 2ba0a786 a89e77f0 a93a6da4 5a12d51a 6f311289 85cea690 c2c18a05 573f95c3 e900e46c 68d5d9a9 c98071e3"
},
"diy-052": {
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo",
"about": "6_diy1_b=hsgma2/S1 6_diy2_b=hsgma2/S2 6_diy1_b=hsgma2/S3 6_diy2_b=hsgma2/S1/002_hsgma2 6_diy1_b=hsgma2/S2/002_hsgma2 6_diy2_b=hsgma2/S3/002_hsgma2 5_diy1_a=thumpy/S1 5_diy2_a=thumpy/S2 5_diy1_a=thumpy/S3 5_diy2_b=thumpy/S1 5_diy1_b=thumpy/S2 5_diy2_b=thumpy/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m01",
@@ -2108,35 +2101,246 @@
"round": 13,
"seed": 1210502478,
"waveId": "act1autochess_h06",
"time": 218.57,
"ticks": 6557,
"reason": "cleared",
"killed": 0,
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 23,
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 81,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":76446,"bossDmg":0,"heal":2762,"deaths":33,"layerGains":{}}},
"players": {"p1":{"killed":23,"total":123,"perfect":false,"leaks":100,"leaksUncounted":0,"coins":0,"dmg":780056,"bossDmg":0,"heal":14644,"deaths":16,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,6,0,2101,1865,0,8,0],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,9,0,4660,2494,0,8,0],
["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,157,0,13058,11540,0,21,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,156,0,24836,14840,0,17,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,151,0,3559,2544,0,18,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,3,0,11692,1643,0,23,0],
["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,4,0,9700,1372,0,5,1],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,11,0,1022,1819,0,1,0],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,86,0,0,4120,2092,4,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,74,0,0,4018,670,2,0],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,1,0,1487,1160,0,3,1],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,5,0,4330,1450,0,7,1]
["p1",1,"op","chess_char_6_diy1_b",0,"none",1,1,94,5,209942,6455,0,74,1],
["p1",2,"op","chess_char_6_diy2_b",1,"none",2547.98,1,122,6,167130,15078,12998,55,3],
["p1",3,"op","chess_char_6_diy1_b",2,"none",1,1,133,11,214459,8531,1646,64,2],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_hsgma2",1857.31,1,143,1,36153,5249,0,22,1],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_hsgma2",3553,1,144,0,7024,7106,0,6,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_hsgma2",0,0,5,0,127199,6040,0,18,1],
["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,75,0,472,5130,0,2,0],
["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,1,0,10133,2565,0,2,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,2,0,2068,2565,0,2,1],
["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,6,0,556,3194,0,1,1],
["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,74,0,1625,6388,0,5,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,66,0,3295,6388,0,7,0]
],
"enemies": {"enemy_1019_jshoot_2":[57,0,57,29550,4113],"enemy_1299_ymkilr_2":[27,0,27,14576,26756],"enemy_1422_lrsldr_2":[39,0,39,4739,45577]},
"hooks": [1,157,6557,277,277,283,291,7,33,156,4,24,0,6272,192,29,123,123,1,33,0,0,0],
"events": {"atk":278,"deploy":34,"die":33,"dmg":269,"fx":19,"heal":7,"leak":123,"skill":39,"spawn":135,"status":92},
"fx": {"aoe":2,"displace":15,"spGain":2},
"snaps": "21be44ba 23e43266 29788d55 2f1037e9 363ad836 051606c5 26acdfeb 8dc54607 7a7c6a86 7ebe59b3 d1661fdb 203d434c 2e82423b f7e9ffc1 7a1afa0e 8998df31 0ca38cb6 f3ac5677 87bcc7cf 0f5f3879 0e082cac 62241cef"
"enemies": {"enemy_1019_jshoot_2":[57,16,40,20398,366623],"enemy_1299_ymkilr_2":[27,0,27,43104,118193],"enemy_1422_lrsldr_2":[39,7,31,11187,295240]},
"hooks": [1,144,6900,930,930,1743,1805,496,39,137,11,9,0,5236,271,65,123,98,1,39,0,1,0],
"events": {"atk":995,"deploy":21,"die":39,"dmg":1523,"fx":401,"heal":160,"leak":98,"skill":20,"spawn":135,"status":168},
"fx": {"burst":1,"counter":153,"shield":1,"strike":230,"undying":11,"zone":5},
"snaps": "2bd122f0 4ac3385c 96a39114 a5893cd8 051e791a 5a5a93c5 e35983bb 9ff770c5 06c88612 31239379 f4bf2aa3 13c07a44 7ad6caf0 01eeb1c6 a69add99 acbe0984 39667970 60ffd978 4b7d264c 01ee3f5c 0db9891d 0cf72c7f 2513055f"
},
"diy-053": {
"about": "5_diy1_b=thumpy/S1/002_thumpy 5_diy2_b=thumpy/S2/002_thumpy 5_diy1_b=thumpy/S3/002_thumpy 6_diy1_a=thumpy/S1 6_diy2_a=thumpy/S2 6_diy1_a=thumpy/S3 6_diy2_b=thumpy/S1 6_diy1_b=thumpy/S2 6_diy2_b=thumpy/S3 6_diy1_b=thumpy/S1/002_thumpy 6_diy2_b=thumpy/S2/002_thumpy 6_diy1_b=thumpy/S3/002_thumpy",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m02",
"modeId": "mode_multi_funny",
"round": 13,
"seed": 2850577873,
"waveId": "act1autochess_h06",
"time": 209.6,
"ticks": 6288,
"reason": "cleared",
"killed": 114,
"total": 114,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":114,"total":114,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":959040,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_thumpy",3444,1,3,0,0,0,0,0,0],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_thumpy",3444,1,4,0,0,0,0,0,0],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_thumpy",3444,1,5,15,133079,0,0,9,1],
["p1",4,"op","chess_char_6_diy1_a",0,null,2239.4,1,7,0,15316,326,0,16,1],
["p1",5,"op","chess_char_6_diy2_a",1,null,2551.8,1,11,32,259439,13,0,34,3],
["p1",6,"op","chess_char_6_diy1_a",2,null,2235,1,13,37,282521,330,0,95,2],
["p1",7,"op","chess_char_6_diy2_b",0,"none",2784.8,1,14,12,115470,409,0,130,3],
["p1",8,"op","chess_char_6_diy1_b",1,"none",3167.6,1,8,6,40542,26,0,2,1],
["p1",9,"op","chess_char_6_diy2_b",2,"none",2886,1,12,12,112674,308,0,92,2],
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_thumpy",3644,1,9,0,0,0,0,0,0],
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_thumpy",3644,1,10,0,0,0,0,0,0],
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_thumpy",3644,1,6,0,0,0,0,0,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1381_winman":[27,27,0,436,150336],"enemy_1381_winman_2":[48,48,0,581,359424],"enemy_1387_winshd":[39,39,0,396,449280]},
"hooks": [1,128,6288,1005,1005,3140,5232,0,114,114,13,13,0,18520,726,165,114,0,1,114,0,187,0],
"events": {"atk":1225,"deploy":12,"die":114,"dmg":4689,"fx":657,"skill":26,"spawn":128,"status":1799},
"fx": {"burst":187,"displace":52,"shield":409,"zone":9},
"snaps": "e16d0b90 705560d0 873d70c8 8336cfb8 6756edb4 df4b2178 eb458684 ddf18b37 9e09661f 83a30c0d 851d3cf5 2772710d 3183643a db6cc3d2 43b11e92 1b642500 ac4b6665 577c73aa 4d053ae7 13261199 fbb19022"
},
"diy-054": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
"fast": true,
"kind": "normal",
"stageId": "act2autochess_m03",
"modeId": "mode_single_hard",
"round": 12,
"seed": 196461732,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 8,
"total": 96,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":8,"total":96,"perfect":false,"leaks":88,"leaksUncounted":0,"coins":0,"dmg":276105,"bossDmg":0,"heal":70718,"deaths":5,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,1765.97,1,5,1,10566,7022,0,160,3],
["p1",2,"op","chess_char_5_diy2_b",2,"none",2498.09,1,7,1,8600,6809,0,170,3],
["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,12,1,10379,28340,0,142,3],
["p1",4,"op","chess_char_5_diy2_b",2,"none",2946.6,1,14,2,34779,29705,0,147,4],
["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,9,1,33524,0,0,215,3],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,11,0,45208,0,0,217,4],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,13,1,41204,0,0,97,3],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,15,1,75980,0,0,155,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,4,0,0,0,30020,74,4],
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,10,0,0,0,40697,76,4],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,114,0,6399,2796,0,17,1],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,111,0,9466,2262,0,23,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1381_winman":[27,4,23,1661,85135],"enemy_1381_winman_2":[39,3,32,20045,100512],"enemy_1387_winshd":[30,1,25,55228,90458]},
"hooks": [1,115,6900,2302,2302,2152,2152,150,13,93,36,31,0,39947,40,20,96,80,1,13,0,0,0],
"events": {"atk":2302,"deploy":16,"die":13,"dmg":2152,"heal":150,"leak":80,"skill":67,"spawn":111,"status":57},
"fx": {},
"snaps": "0c70223b 8876b977 95ebcf88 847125ea b16e9e48 a5d17a72 3b727b05 d0175d34 5b032c82 e9d4a061 9150ccd1 acdabdc7 127f7345 999513d8 e6ebe999 1ec971ba f9df8603 43b4e06a b437c3f8 dc0ec48d b5221902 7eff9446 8f5e1779"
},
"diy-055": {
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m04",
"modeId": "mode_multi_normal",
"round": 12,
"seed": 1597707536,
"waveId": "act1autochess_h05",
"time": 221.63,
"ticks": 6649,
"reason": "cleared",
"killed": 100,
"total": 117,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 918,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":100,"total":117,"perfect":false,"leaks":17,"leaksUncounted":0,"coins":0,"dmg":583978,"bossDmg":0,"heal":54030,"deaths":14,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,2370.15,1,7,0,0,3387,0,0,0],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",1996.1,1,8,0,0,17917,0,0,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,6,8,66172,0,0,0,7],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,124,25,98772,7106,0,0,4],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,128,0,0,11659,0,0,0],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",1762.4,1,137,0,0,24076,0,0,0],
["p1",7,"op","chess_char_5_diy1_a",2,null,1272,1,3,25,71286,0,0,67,2],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,4,31,290058,0,0,174,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,132,5,27138,5423,0,8,1],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,129,6,30554,6749,0,7,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,1224,1,5,0,0,0,46602,70,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,140,0,0,7071,7429,18,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai":[51,51,0,0,97463],"enemy_1005_yokai_2":[54,49,5,22120,193886],"enemy_1005_yokai_3":[12,0,12,61268,292630]},
"hooks": [1,140,6649,1115,1115,1769,1686,155,114,131,21,20,0,25143,0,0,117,17,1,114,0,0,83],
"events": {"atk":1297,"deploy":21,"die":114,"dmg":1686,"fx":237,"heal":143,"leak":17,"skill":41,"spawn":131,"status":22},
"fx": {"aoe":15,"dodge":83,"dp":44,"slash":83,"spGain":12},
"snaps": "728b67d6 6f920f9a 52d3550a f16ded50 a007fb81 ba536139 34a8d590 97c71e46 16103e6c 81b44abd c395e7f3 1f072f37 1d6605ca 8786f693 5801287c fa9752ad 1f691d2b 04d081ed 01cb5c62 161d0061 093d41d6 bedcd071 9c3113e6"
},
"diy-056": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m01",
"modeId": "mode_multi_hard",
"round": 13,
"seed": 3238296381,
"waveId": "act1autochess_h06",
"time": 220.87,
"ticks": 6626,
"reason": "cleared",
"killed": 33,
"total": 135,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 302,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":33,"total":135,"perfect":false,"leaks":102,"leaksUncounted":0,"coins":0,"dmg":318418,"bossDmg":0,"heal":0,"deaths":30,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,7,2,19515,2604,0,46,1],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,84,0,2684,6862,0,14,0],
["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,118,0,8569,3730,0,17,0],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,13,1,4817,2374,0,8,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,142,0,3639,4587,0,9,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,160,4,23276,5712,0,13,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,167,3,38504,9232,0,43,0],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,171,4,43499,10136,0,51,0],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,10,0,18610,2638,0,13,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,82,4,35108,7062,0,22,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,4,7,52118,0,0,133,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,5,8,68077,0,0,144,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {
"enemy_1195_sfyin": [39,6,33,11472,101857],
"enemy_1196_msfyin": [12,10,2,0,22],
"enemy_1197_sfshu": [48,8,40,17435,164002],
"enemy_1198_msfshu": [24,9,15,0,35],
"enemy_1207_sfji": [12,0,12,26031,52502]
},
"hooks": [1,171,6626,771,771,938,935,0,63,165,10,24,0,15263,9924,53,135,102,1,72,0,0,10],
"events": {"atk":771,"deploy":33,"die":63,"dmg":898,"fx":100,"leak":102,"skill":49,"spawn":150,"status":763},
"fx": {"aoe":16,"displace":27,"dodge":22,"dp":6,"slash":15,"summon":14},
"snaps": "f8b5dfe8 d9e0d502 6e94838a 6b3d7d80 a2825999 335bbab4 b4e559aa be3de37a 09ef0915 8b5a719c 69701297 92c2ff26 ac137f70 8765e11d 7ea1daac 8d484ded 36bbc03f 599f28a5 b6a7e5c0 87e341e6 8f9d34b4 730c99d2"
},
"diy-057": {
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m02",
"modeId": "mode_single_normal",
"round": 13,
"seed": 583790378,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 20,
"total": 147,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1548,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":20,"total":147,"perfect":false,"leaks":127,"leaksUncounted":0,"coins":0,"dmg":606525,"bossDmg":0,"heal":80261,"deaths":15,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,994.83,1,173,6,29904,4600,0,46,1],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",1334.76,1,160,4,70625,3653,0,103,2],
["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,172,0,2842,5770,0,77,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,171,1,19763,8078,0,82,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,5,1,54315,0,0,217,3],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,8,2,100440,0,0,204,3],
["p1",7,"op","chess_char_6_diy1_a",2,null,1372,1,6,2,59142,0,0,62,2],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,9,1,235300,0,0,82,3],
["p1",9,"op","chess_char_6_diy1_a",2,null,1224,1,10,0,0,26739,39996,77,3],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,11,0,0,24521,40265,84,3],
["p1",11,"op","chess_char_6_diy1_a",2,null,1160,1,12,2,23266,28343,0,146,4],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,151,1,10929,2900,0,54,1]
],
"enemies": {"enemy_1166_dusbr_2":[39,9,30,25663,188457],"enemy_1169_duphlx_2":[57,7,49,65001,214974],"enemy_1170_dushld_2":[51,4,47,13941,203094]},
"hooks": [1,173,6900,1600,1600,2888,2989,283,35,161,25,31,0,33493,9368,33,147,126,1,35,0,0,0],
"events": {"atk":1976,"deploy":26,"die":35,"dmg":2971,"fx":62,"heal":248,"leak":126,"skill":70,"spawn":159,"status":1082},
"fx": {"aoe":19,"displace":11,"dodge":32},
"snaps": "52c111b1 b3888005 54228bd9 fed55d11 15be21e1 03270ab2 a7ceabe9 58cb89e8 89d98da8 ec18ec96 9bb38715 662b8da7 1b544744 213682e7 2ed156db c40d1e3b ec410ed5 00453988 dfb63b8f 0136ea36 7616f3d2 69a25d94 f45e4116"
}
}
}