feedback5 O15: 自选 operator kits 森蚺, 斥罪 and 黍 — every skill, talent, trait and module (stage 1 / 3) at every form

- kits/ops/op-zumama.js (森蚺, 决战者): trait 阻回 unless blocking (charpack SpRecoveryStopped / SpRecovery); HES-X / HES-Y /
  RA-A turn it into an SP-recovery ratio (−0.8 / −0.999 / −1) while unblocked, HES-Y ATK / DEF +15 % blocking; RA-A's
  生息演算-only parts N/A (愈战愈勇 read from talentsBase); 勇冠三军 polled every 0.1 s (攻击力倍率 / 庇护); 愈战愈勇; S1 passive;
  S2 blockee stun past 抵抗; S3 hpRegenRatio + 5 s self-stun.
- kits/ops/op-judge.js (斥罪, 不屈者): 律法卫士 barrier (deploy / kill / S3, live 300 % cap, S2 ×(1 + shield_scale)); 荆棘环身
  thorns while barrier holds (the breaking hit too); UNY-X ×0.85 from blocked units, UNY-Y +8 % alone; S1 蓄力 (all SP per
  cast, ×1.8 + 3 s stun, attack-cycle reset); S2 no attack, 庇护, x-4 pulses at 0.9 + n s; S3 barrier / ATK / taunt.
- kits/ops/op-shu.js (黍, 守护者): 百谷长青 sown tiles (hpRegen + 庇护, S2 ×scale, GUA-X stage 3 born tile, cleared when she
  leaves); 天有四时 (professions, 4 【岁】 squad ATK + SP / 4 s); S1 as 塞雷娅 S1 (DEFAULT + an ally of x-4 at ≤ half); S2 heals
  of 2 on x-1; S3 range x-2 with Attack-else-Heal, the harvest aura and the 播种标记 teleport; GUA-X ×1.15 at ≤ 50 %. Her S3
  data rule TRY_SEARCH_ALLY_SKILL (no engine rule) is mapped onto SKILL_RANGE + allies on x-2 (reported).
- 庇护 in all three through battle.applyStrongest(unit, 'protect') (同名效果取最高 across these kits).
- kits/index.js: the three files appended to OPERATOR_KIT_FILES.
- test/content/op_zumama.test.js (8), op_judge.test.js (8), op_shu.test.js (9).
- docs/research/03-operators.md: 黍's trigger row (a 自选 pick now, the kit's mapping).
- golden: only diy moved — diy-049 … diy-052 now field 森蚺 / 斥罪 / 黍 and diy-053 … diy-058 are new (the 12-per-battle
  packing appends the new configurations; the prototype / reserve battles of 049 … 052 move to 055 … 058); diy-001 … 048
  and every other family unchanged.
This commit is contained in:
sganggs committed 2026-10-06 01:30:05 +08:00
1 parent fef4ea3d4b
commit 10bb226382
9 files changed
+1926 -99

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@ Meaning (names are from the data; semantics partly inferred — the official wor
- `CUSTOM_RANGE_SEARCH_ENEMY` (6 chess): SP full AND an enemy inside the `skillRangeDict` range (listed below). Pool: 乌尔比安 S3 (6-1), 维娜·维多利亚 S3 (x-4), 余 S3 (3-15), 远牙 S3 (6-1), 玛恩纳 S3 (3-18), 圣聆初雪 S3 (x-2). Not in pool: 塑心 demetr, 司霆惊蛰 leizi2, 林 lin, 猎蜂? headb2.
- `GDGLOW_SKILL_2` (2 chess: 荒芜拉普兰德 whitw2 S3, 纯烬艾雅法拉 agoat2 S3; 澄闪 gdglow not in pool): named after 澄闪 S3 whose skill places/targets a zone; [ASSUMED] cast when SP full and an enemy is inside the attack range, choosing the target tile/enemy with the most enemies around it.
- `MLYSS_WTRMAN` (缪尔赛思, skillIndex -1): AI rule for her summon 流形 (token_10030_mlyss_wtrman) placement/behaviour [ASSUMED: token copies/follows the nearest allied operator as in the base game]; her S3 itself uses the tactician ALWAYS rule.
- `MARCILS2` (玛露西尔 S2) and `TRY_SEARCH_ALLY_SKILL` (黍 S3) are for operators not in this season's pool.
- `MARCILS2` (玛露西尔 S2) and `TRY_SEARCH_ALLY_SKILL` (黍 S3) are for operators not in this season's chess pool. 黍 is a 自选 pick since 0.2.0: PRTS 卫戍协议/帮助 words her row 「技能范围内存在可治疗的我方单位时释放技能」; the engine has no such rule (an unknown rule acts as DEFAULT), so her kit (`kits/ops/op-shu.js`) maps it onto SKILL_RANGE + `allies` on the skill's x-2.
- Redeployment (PRTS 卫戍协议/帮助 §作战阶段 单位部署, quoted in research 01 §4.3): a knocked-out operator auto-redeploys on the tile it lies on when its `respawnTime` has elapsed AND there is enough DP for its `cost` (or instantly via effects). So **cost and respawnTime matter** in battle; see the battle-rules research for the DP economy.
skillRangeDict (custom trigger ranges; `O` = operator tile, `#` = trigger tile, facing right):
+3
View File
@@ -103,6 +103,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-ifrit.js',
'op-aphris.js',
'op-lin.js',
'op-zumama.js',
'op-judge.js',
'op-shu.js',
]);
/**
+196
View File
@@ -0,0 +1,196 @@
// server/sim/content/kits/ops/op-judge.js — 斥罪 (char_4065_judge) 自选 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_4065_judge, 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); PRTS 斥罪 (律法卫士 备注:
// "自身产生的屏障生效优先级为-1000,为单一BUFF,每次“获得”实际效果为补充屏障的吸收量…本天赋的屏障上限仅限制自身通过天赋和技能产生的
// 屏障量…上限会实时计算"; 荆棘环身 备注 "反伤效果会直接选中伤害来源,但仅对阵营为敌方的来源造成伤害"; S1 备注 on 蓄力; S2 备注
// "技能期间,自身丢失全部视野"; S3 备注 "技能获取的屏障为补充天赋屏障的吸收量,且不会令屏障总量超过天赋描述下的屏障获取上限"),
// gamedata_const ba.charged 蓄力 ("技力达到上限可继续回复,回复至上限2倍时进入蓄力状态,此时开启技能会触发额外效果(任何时候开启均消耗
// 全部技力)"); the client's battle data: buff templates judge_t_1 / judge_t_2 / judge_e_002_tr / judge_s_1_enhance_checker /
// judge_s_1[damage] / judge_s_1[mode] / judge_s_2[aoe] / judge_s_2[shield_scale] / judge_s_3[shield], the skill prefabs
// skchr_judge_1..3 and the module prefabs judge_equip_{1,2}_*; Arknights Terra Wiki "Penance".
// - Trait (不屈者) "无法被友方角色治疗": the profession default (professions.js unyield `noHeal`; the charpack's 'unhealable');
// an HP-regen attribute (黍's sown tiles, 吟游者) still reaches her (SIM.md §4). Melee physical, ground-only, block 3, 1-1.
// Module UNY-X “通往未来的荆棘之路” "受到来自自身阻挡单位的伤害降低15%" (trait bb damage_scale; judge_e_002_tr): every damage
// whose source she blocks ×damage_scale. Module UNY-Y “无罪” "周围8格没有友方干员时攻击力和防御力+8%" (trait bb atk / def; the
// prefab's checker counts operators only — professionMask 639): ATK / DEF + while no allied operator stands on the 8
// tiles around her.
// - T1 律法卫士 (judge_t_1): one barrier buff BARRIER. At every deployment +born_hp_ratio × max HP; every enemy she knocks
// out +kill_hp_ratio × max HP; S3 +hp_ratio × max HP — each gain × the shield scale (1, + S2's shield_scale while S2
// runs: judge_s_2[shield_scale]) and only while the barrier is below the cap max_hp_ratio × the CURRENT max HP, then
// capped there (a max-HP change leaves the barrier as it is). The buff ends when its absorb runs out (the client keeps
// an empty buff; nothing else depends on it here). [ASSUMED] it is spent in the engine's oldest-first order among
// several barriers (the client's priority −1000 puts it last); damage reductions (庇护, UNY-X) act before it as the
// client's LOW_PRIORITY. UNY-X stage 3: 70 % / 12 % (module talent change). higher_effect_hp_ratio only sizes the
// barrier's effect (judge_t_1[effect]) — no gameplay.
// - T2 荆棘环身 (judge_t_2, ON_TAKE_DAMAGE): every damage instance an enemy source deals her while she holds barrier (its
// judge_shield[mark]: the absorb before that hit — the hit that breaks it still counts, t_1 runs LOW_PRIORITY) deals
// atk_scale × her ATK arts back to that source (a 普通伤害, InverseDamage `attackType` NORMAL; × the 蓄力 攻击力倍率 while S1's
// 蓄力 attack is out — PRTS 备注), never for a 流失, an element 损伤, a 无来源 hit or reflected damage (tag 'counter'),
// undodgeable [ASSUMED like 星熊's counter]. UNY-Y stage 3: 58 %.
// - S1 一锤定音 (AUTO, time SP, data DEFAULT): the next attack adds atk_scale_2 × ATK arts (a 普通伤害 skill hit, after the
// physical one). 蓄力 (maxChargeTime 2): SP runs on to twice the cost; casting from there (the client's "available count
// ≥ 1" after the cast) also stuns the target `stun` s (before the arts hit) and raises every damage she deals until the
// attack is done ×judge_s_1_enhance_checker.atk_scale (an 攻击力倍率: atkScaleMul). Every cast spends ALL the SP
// (ClearCharacterSp). The moment the SP reaches the 蓄力 mark her attack cycle restarts (judge_s_1[mode] SwitchMode with
// restartFSM, "无关乎能否开启技能"; PRTS 备注 "存在可攻击目标且未被控制的情况下切换后会立刻触发技能"): atkCd = 0. The refund of a
// cast whose target died (_recoverSpIfTargetDead) is not modelled [ASSUMED]: a melee cast and its attack resolve in one
// tick here.
// - S2 坚心苦修 (MANUAL; data DEFAULT — the owner's 重装 exception of 2026-10-05, rawRule TAKE_DAMAGE): `duration` s, no
// attacks (她丢失全部视野), 庇护 damage_resistance (the shared applyStrongest key PROTECT, for the skill's time), and from
// S2_FIRST s on every S2_EVERY s (judge_s_2[aoe]: firstTriggerInterval 0.9, triggerInterval 1) atk_scale × ATK arts (×
// any 攻击力倍率) to every ground enemy she can select on the skill range x-4 (AOEDamage WALK_ONLY, attackType NORMAL); T1's
// gains ×(1 + shield_scale) meanwhile.
// - S3 披荆斩棘 (MANUAL, hurt SP; data DEFAULT, rawRule TAKE_DAMAGE): at once barrier +hp_ratio × max HP (topping T1's,
// capped), then `duration` s ATK +atk, attack interval +base_attack_time s (a flat +0.9 on 1.6 s), taunt +taunt_level.
import { num, traitBb, talentBb, skillRec, toggleBuff, batMod, up, enemiesInGrid } from '../shared/tier1.js';
import { isHpLoss } from '../../../damage.js';
const S1 = 'skchr_judge_1';
const S2 = 'skchr_judge_2';
const S3 = 'skchr_judge_3';
const BARRIER = 'talent:judge:barrier';
const CHARGED = 'skill:judge:charged';
/** S2's 技能范围 when the data carries none (x-4: her tile and the 8 around it). */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
/** judge_s_2[aoe]: firstTriggerInterval 0.9 s, then triggerInterval 1 s. */
const S2_FIRST = 0.9;
const S2_EVERY = 1;
/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest). */
const PROTECT = 'protect';
const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const tagged = (d, tag) => !!(d && Array.isArray(d.tags) && d.tags.includes(tag));
/** Her barrier left (0 without one). */
const barrierOf = (u) => u.findBuff(BARRIER)?.shield ?? 0;
export default {
char_4065_judge: (bb, chess) => {
const tb = traitBb(chess);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const capRatio = num(t0.max_hp_ratio, 3);
/** S2's 庇护 (damage_resistance[inf] for the skill's time). */
const protect = (battle, unit, skill) => {
const dr = num(b2.damage_resistance);
if (dr > 0) battle.applyStrongest(unit, PROTECT, { duration: Math.max(0.05, skill.timeLeft), value: dr, mods: protectMods, source: unit });
};
/** judge_t_1's shield_scale: 1, + S2's shield_scale while S2 runs. */
const shieldScale = (unit) => 1 + (unit.skill?.active && unit.skill.id === S2 ? num(b2.shield_scale) : 0);
/** Add `amount` to her barrier while it is below the cap (the CURRENT max HP), capped there. Returns the gain. */
const gain = (battle, unit, amount) => {
if (!up(unit) || !(amount > 0)) return 0;
const cap = unit.s.maxHp * capRatio;
const cur = unit.findBuff(BARRIER);
const have = cur ? cur.shield : 0;
if (!(cap > have + 1e-9)) return 0;
const next = Math.min(cap, have + amount);
if (cur) { cur.shield = next; unit.markDirty(); } else battle.addBuff(unit, { key: BARRIER, shield: next, visible: true, tags: ['talent'] });
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
return next - have;
};
return {
skills: {
[S1]: {
kind: 'charges',
attack: {
onHit({ battle, unit, target }) {
if (!target || !target.alive) return;
if (unit.mem.judgeCharged) battle.applyStatus(target, 'stun', { duration: num(b1.stun), source: unit });
if (!target.alive) return;
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale_2) * unit.s.atkScaleMul, type: 'arts', isSkill: true, tags: ['skill', 'judge:gavel'] });
},
},
onStart({ battle, unit, skill }) {
// 蓄力: the cast took one charge of a full stack; every cast spends ALL the SP (ClearCharacterSp)
const charged = skill.maxCharges > 1 && skill.charges >= skill.maxCharges - 1;
skill.charges = 0;
skill.sp = 0;
unit.mem.judgeCharged = charged;
if (charged) {
battle.addBuff(unit, { key: CHARGED, mods: { atkScaleMul: num(b1['judge_s_1_enhance_checker.atk_scale'], 1) }, tags: ['skill'] });
battle.fx('crit', { x: unit.x, y: unit.y, id: unit.id });
}
},
onEnd({ battle, unit }) { unit.mem.judgeCharged = false; battle.removeBuff(unit, CHARGED); },
},
[S2]: {
kind: 'duration',
attack: { noAttack: true },
onStart({ battle, unit, skill }) {
unit.mem.judgeS2Next = S2_FIRST;
protect(battle, unit, skill);
},
onTick({ battle, unit, skill, dt }) {
protect(battle, unit, skill);
unit.mem.judgeS2Next -= dt;
while (unit.mem.judgeS2Next <= 1e-9 && up(unit)) {
unit.mem.judgeS2Next += S2_EVERY;
const foes = enemiesInGrid(battle, unit, s2?.rangeGrid?.length ? s2.rangeGrid : X4, { canHitFly: false });
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'judge:atonement' });
const amount = unit.s.atk * num(b2.atk_scale) * unit.s.atkScaleMul;
for (const e of foes) if (e.alive) battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', 'judge:atonement'] });
}
},
onEnd({ battle, unit }) { if (unit.findBuff(PROTECT)?.source === unit) battle.removeBuff(unit, PROTECT); },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess), taunt: num(b3.taunt_level) },
onStart({ battle, unit }) { gain(battle, unit, unit.s.maxHp * num(b3.hp_ratio) * shieldScale(unit)); },
},
},
talents: [
{ install(battle, unit) { // 律法卫士: barrier at every deployment and per enemy she knocks out (≤ max_hp_ratio × max HP)
battle.on('deploy', (ctx) => {
if (ctx.unit === unit) gain(battle, unit, unit.s.maxHp * num(t0.born_hp_ratio) * shieldScale(unit));
}, { owner: unit });
battle.on('kill', ({ killer, victim }) => {
if (killer === unit && victim && victim.side === 'enemy') gain(battle, unit, unit.s.maxHp * num(t0.kill_hp_ratio) * shieldScale(unit));
}, { owner: unit });
} },
{ install(battle, unit) { // 荆棘环身: while she holds barrier, every damage from an enemy source reflects atk_scale × ATK arts
const scale = num(t1.atk_scale);
if (!(scale > 0)) return;
const armed = new WeakSet();
battle.on('hit', (c) => { if (c.target === unit && c.dmg && barrierOf(unit) > 1e-9) armed.add(c.dmg); }, { owner: unit });
battle.on('damaged', (c) => {
const d = c.dmg, src = c.source;
if (c.target !== unit || !d || !armed.has(d) || !src || src.side !== 'enemy' || !src.alive) return;
if (isHpLoss(d) || c.type === 'element' || tagged(d, 'counter')) return;
battle.dealDamage(unit, src, { amount: unit.s.atk * scale * unit.s.atkScaleMul, type: 'arts', canDodge: false, tags: ['talent', 'counter', 'judge:thorns'] });
battle.fx('thorns', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
} },
],
install(battle, unit) {
// S1 蓄力: the moment the SP reaches twice the cost her attack cycle restarts (judge_s_1[mode], restartFSM)
if (unit.skill?.id === S1) {
let full = false;
battle.on('tick', () => {
const sk = unit.skill;
const now = !!sk && up(unit) && sk.maxCharges > 1 && sk.charges >= sk.maxCharges;
if (now && !full) unit.atkCd = 0;
full = now;
}, { owner: unit });
}
// UNY-X: 受到来自自身阻挡单位的伤害降低15%
const cut = num(tb.damage_scale, 1);
if (cut !== 1) battle.on('hit', (c) => { if (c.target === unit && c.source && c.source.blockedBy === unit) c.dmg.mul *= cut; }, { owner: unit });
// UNY-Y: 周围8格没有友方干员时攻击力和防御力+8% (operators only, her own tile aside)
const ma = num(tb.atk), md = num(tb.def);
if (ma || md) {
const alone = () => !battle.allyUnits.some((a) => a !== unit && a.kind === 'op' && a.alive && a.deployed && !a.hidden
&& Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC)) === 1);
toggleBuff(battle, unit, 'trait:judge:alone', alone, { atkPct: ma, defPct: md });
}
},
};
},
};
+289
View File
@@ -0,0 +1,289 @@
// server/sim/content/kits/ops/op-shu.js — 黍 (char_2025_shu) 自选 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_2025_shu, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, GUA-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); PRTS 黍 (百谷长青 备注
// "本天赋效果为永久地块效果,自身退场时清空所有地块的播种效果,不对装置职业的目标生效。生命恢复的提供方式为增加位于地块上干员的
// “生命回复速度”属性,不受治疗加成和禁疗影响"; 天有四时 修正 "在场时" / "编入队伍且"; S1 修正 "小于等于一半"; S2 备注 "实际效果为技能
// 期间普通攻击改为治疗友方单位(受攻速和缴械影响,非停止攻击),“特殊间隔”指攻击间隔+1.3"; S3 备注 "技能持续期间视野扩大至治疗范围,
// 自身普通攻击同时触发治疗,无攻击目标时普通攻击可直接进行治疗", the 【播种标记】 and its teleport, "攻击力/攻击速度提升效果无视孤立");
// PRTS 卫戍协议/帮助 §技能操作 (黍【离离枯荣】 "技能范围内存在可治疗的我方单位时释放技能"); the client's battle data: buff templates
// shu_t_1 / shu_t_1[char] / shu_farm_buff[char] / shu_s_2 / shu_s_3 / shu_s_3[in_range] / shu_s_3[heal_in_attack] /
// shu_s_3[enemy][enter|leave|mark] / heal_scale_up[hpratio][LE], her charpack (the S2 / S3 modes' Attack / Heal
// abilities, DoFarm / EnemyListener tile abilities and their selectors, the 天有四时 auras and deck part) and the skill
// prefabs skchr_shu_1..3; Arknights Terra Wiki "Shu".
// - Trait (守护者) "技能可以治疗友方单位": her normal attack is a melee physical strike (ground-only, block 3, range 0-1 — her own
// tile); only her skills heal. Module GUA-X “钦天司时” "治疗生命值低于50%的友方单位时治疗量提升15%" (trait bb heal_scale /
// hp_ratio): every heal of hers on a target at or below hp_ratio ×heal_scale (the client template is heal_scale_up
// [hpratio][LE]: at or below — the text says 低于), never an HP-regen tick.
// - T1 百谷长青 (shu_t_1): every heal she outputs (S1 / S2 / S3; never a regen tick) sows the healed unit's tile and the four
// next to it (DoFarm, range x-5) — tiles that can be deployed on or walked (the tile selector: buildable or passable). A
// sown tile stays sown until she leaves the field (death / retreat clears them all). Every allied unit standing on one —
// operators and summons, not 装置 (professionMask 767) nor a 孤立 unit (no ignoreTargetFree) — has 生命回复速度
// +hp_recovery_per_sec (an hpRegen buff, checklist 11: 禁疗 / 无法被友方治疗 do not stop it — 斥罪 too) and 庇护
// damage_resistance (the shared applyStrongest key PROTECT), refreshed every FARM_EVERY s (and at once on a sowing and at
// S2's start / end); both × S2's extra_extend_scale while S2 runs (shu_s_2 sets DoFarm's extra_extend_scale). Two 黍 of a
// shared field: the stronger regeneration and 庇护 hold. GUA-X stage 3: 80 / s, 15 % and "部署时立即给所处地块播种该效果"
// (bbStr born_range_id 0-1: her own tile at every deployment).
// - T2 天有四时: while she is deployed, ≥ PROF_NEEDED different professions among the allied operators on the field ⇒ every
// allied operator's max HP +max_hp; ≥ PROF_NEEDED operators of one profession ⇒ ASPD +attack_speed (charpack auras:
// operators only, 孤立 included); if her squad (her player's operators, deployed or not) holds ≥ SUI_NEEDED 【岁】 operators
// (character_table groupId 'sui': 年 夕 令 重岳 黍 余 望) ⇒ every operator of the squad ATK +atk for the battle and +sp SP
// every `interval` s of each deployment (modify_sp[trigger]; an SP gift — a 决战者's 阻回 refuses it), whether she is
// deployed or not (Terra). The counts 3 / 4 exist in the text and the charpack only (minCount / minCntToTrig).
// - S1 化被草木 (AUTO, time SP, 可充能 1 / 2 次; data DEFAULT): the client prefab is 塞雷娅 S1 急救's (a SkillTrigger on an
// ally of HP ≤ half — _maxHpRatio 0.5 — _cancelIfSearchTargetFailed), whose PRTS 备注 reads "此技能仅在周围有符合血量条件的友方
// 单位时可触发,触发时会替换当次攻击": the engine's DEFAULT with `allies` / `hpAtMost` on the skill range x-4 — at her attack,
// with an ally of x-4 at ≤ half HP, that attack heals the lowest such ally for heal_scale × ATK instead (as 塞雷娅's kit).
// - S2 嘉禾盈仓 (MANUAL; data DEFAULT — the owner's 重装 exception of 2026-10-05, rawRule TAKE_DAMAGE): `duration` s, her
// attacks become heals of up to attack@max_target injured allied units on the skill range x-1 (her range meanwhile:
// _rangeIdModeIndex 1) for ATK, ATK +atk, block +block_cnt, attack interval +base_attack_time s (a flat +1.3 on her 1.2
// s; ASPD and 缴械 still count), T1 ×extra_extend_scale.
// - S3 离离枯荣 (MANUAL): her range becomes the skill range x-2 (_rangeIdModeIndex 2; "视野扩大至治疗范围"), ATK +atk; every
// attack on an enemy also heals the most injured allied unit of that range other than herself for ATK
// (shu_s_3[heal_in_attack]: the Heal selector excluding her); with no enemy to attack her attack heals the most injured
// allied unit of the range, herself included (the mode's Attack-else-Heal ability). While a ground enemy stands on a
// sown tile every allied operator of her range — 孤立 ones too — ATK +e_atk and ASPD +e_attack_speed (× min(enemies,
// e_max_stack_cnt)); a ground enemy that enters a sown tile is marked with that tile (once) and each time it then enters
// a tile at a Manhattan distance ≥ max_distance from it, it is put back on that tile's centre (unblocked, its route
// re-planned from there; waypoints already passed stay passed), stealth or 无敌 notwithstanding; a 消失 (hidden) enemy loses
// its mark and every mark ends with the skill (the client attaches it to shu_s_3). No enemy of the mode holds
// 免疫传送 (data otherImmunities). Trigger: the data rule is TRY_SEARCH_ALLY_SKILL (her own row, PRTS "技能范围内存在可治疗的
// 我方单位时释放技能"), which the engine does not know (an unknown rule acts as DEFAULT): this kit maps it onto the engine's
// SKILL_RANGE + `allies` on x-2 (an injured, healable allied unit of the skill range, herself included; 古米 S1's option).
import { num, traitBb, skillRec, batMod, up, giveSp } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { effectiveProfile, acquireTargets, performAttack } from '../../../ai.js';
const S1 = 'skchr_shu_1';
const S2 = 'skchr_shu_2';
const S3 = 'skchr_shu_3';
/** The skill ranges when the data carries none: x-4 (S1, 周围), x-1 (S2), x-2 (S3) — range_table. */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
const X1 = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
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]]);
/** shu_t_1 sows the healed unit's tile and the four next to it (range x-5, symmetric). */
const SOW = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]);
/** Range ids a talent's bbStr may name (GUA-X stage 3 born_range_id '0-1': her own tile). */
const RANGE_IDS = Object.freeze({ '0-1': Object.freeze([[0, 0]]) });
/** S1 "血量不足一半" — PRTS 修正 小于等于, the prefab's _maxHpRatio 0.5. */
const HALF_HP = 0.5;
/** T1's tile effect refresh and hold. */
const FARM_EVERY = 0.25;
const FARM_HOLD = 0.35;
/** T2 "三名不同 / 相同职业干员", "四名【岁】干员" (the charpack's minCount / minCntToTrig; no blackboard key). */
const PROF_NEEDED = 3;
const SUI_NEEDED = 4;
/** 【岁】: character_table groupId 'sui' (data/backups.json diy.operators powers carry it for the 自选 picks). */
const SUI = new Set(['char_2014_nian', 'char_2015_dusk', 'char_2023_ling', 'char_2024_chyue', 'char_2025_shu', 'char_2026_yu', 'char_2027_wang']);
/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest). */
const PROTECT = 'protect';
const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
const FARM = 'talent:shu:farm';
const HARVEST = 'skill:shu:harvest';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const gridOf = (chess, id, fallback) => { const g = skillRec(chess, id)?.rangeGrid; return g?.length ? g : fallback; };
/** The talent record with data index `i` (null when absent). */
const talentRec = (chess, i) => (chess?.talents ?? []).find((t) => t && t.index === i) ?? null;
/** The tile key of a unit's position (an enemy: its 判定中心). */
const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
/** An allied unit her heals and tiles may take: on the field, not a device. */
const onField = (a) => !!a && a.alive && a.deployed && !a.hidden && a.kind !== 'device';
export default {
char_2025_shu: (bb, chess) => {
const tb = traitBb(chess);
const r0 = talentRec(chess, 0), t0 = r0?.bb ?? {}, t1 = talentRec(chess, 1)?.bb ?? {};
const born = RANGE_IDS[r0?.bbStr?.born_range_id] ?? null;
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1);
const g1 = gridOf(chess, S1, X4), g2 = gridOf(chess, S2, X1), g3 = gridOf(chess, S3, X2);
/** The sown tiles of her current deployment (a Set of tile keys). */
const sown = (unit) => {
if (unit.mem.shuSownSeq !== unit.deploySeq || !unit.mem.shuSown) { unit.mem.shuSown = new Set(); unit.mem.shuSownSeq = unit.deploySeq; }
return unit.mem.shuSown;
};
/** T1's tile effect on every allied unit standing on a sown tile (× S2's extra_extend_scale while it runs). */
const farm = (battle, unit) => {
if (!up(unit)) return;
const tiles = sown(unit);
if (!tiles.size) return;
const scale = unit.skill?.active && unit.skill.id === S2 ? num(b2.extra_extend_scale, 1) : 1;
const hp = num(t0.hp_recovery_per_sec) * scale, dr = num(t0.damage_resistance) * scale;
for (const a of battle.allyUnits) {
if (!onField(a) || !tiles.has(a.tileR * COLS + a.tileC) || !battle.allySelectable(a, unit)) continue;
const cur = a.findBuff(FARM);
if (hp > 0 && !(cur && cur.source !== unit && (cur.data?.v ?? 0) > hp && cur.timeLeft > 0.05)) {
battle.addBuff(a, { key: FARM, duration: FARM_HOLD, mods: { hpRegen: hp }, source: unit, data: { v: hp }, tags: ['talent'] });
}
if (dr > 0) battle.applyStrongest(a, PROTECT, { duration: FARM_HOLD, value: dr, mods: protectMods, source: unit });
}
};
/** Sow the tiles of `grid` around (r, c) — only tiles that can be deployed on or walked. Returns the new ones. */
const sow = (battle, unit, r, c, grid) => {
const tiles = sown(unit);
let n = 0;
for (const [dr, dc] of grid) {
const rr = r + dr, cc = c + dc;
if (!battle.grid.inRect(rr, cc)) continue;
const t = battle.grid.tile(rr, cc);
if (!t || (t.build === 'NONE' && t.pass !== 'ALL')) continue;
const k = rr * COLS + cc;
if (!tiles.has(k)) { tiles.add(k); n++; }
}
if (n > 0) { battle.fx('healField', { x: c, y: r, id: unit.id }); farm(battle, unit); }
return n;
};
/** S3: the most injured healable allied unit of her current range (`others`: not herself). */
const healTarget = (battle, unit, others) => {
const list = battle.injuredAlliesInKeys(unit.rangeKeySet || new Set(unit.rangeKeys || []), unit);
return others ? list.find((a) => a !== unit) ?? null : list[0] ?? null;
};
return {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
trigger: { rule: 'DEFAULT', grid: g1, allies: true, hpAtMost: HALF_HP },
targeting: { rangeGrid: g1 },
attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(b1.heal_scale, 1), projectile: 'none' },
},
[S2]: {
kind: 'duration',
mods: { atkPct: num(b2.atk), blockCnt: num(b2.block_cnt), batPct: batMod(b2.base_attack_time, chess) },
targeting: { rangeGrid: g2, maxTargets: Math.max(1, Math.floor(num(b2['attack@max_target'], 2))) },
attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: 1, projectile: 'none' },
onStart({ battle, unit }) { farm(battle, unit); },
onEnd({ battle, unit }) { farm(battle, unit); },
},
[S3]: {
kind: 'duration',
trigger: { rule: 'SKILL_RANGE', grid: g3, allies: true },
mods: { atkPct: num(b3.atk) },
targeting: { rangeGrid: g3 },
onStart({ unit }) { unit.mem.shuMarks = new Map(); },
onAttack({ battle, unit, targets }) {
// shu_s_3[heal_in_attack]: an attack on an enemy also heals the most injured allied unit of her range but her
if (!targets?.length || targets[0].side !== 'enemy') return;
const a = healTarget(battle, unit, true);
if (a) battle.heal(unit, a, unit.s.atk * unit.s.atkScaleMul);
},
onTick({ battle, unit }) {
if (!up(unit)) return;
const tiles = sown(unit);
// 有地面敌人处于播种地块时: the allied operators of her range ATK / ASPD + (孤立 ones too)
let onSown = 0;
for (const e of battle.enemies) if (e.alive && !e.hidden && !e.isFlying && tiles.has(tileKey(e))) onSown++;
const stacks = Math.min(onSown, Math.max(1, Math.floor(num(b3.e_max_stack_cnt, 1))));
if (stacks > 0) {
const keys = unit.rangeKeySet || new Set(unit.rangeKeys || []);
for (const a of battle.allyUnits) {
if (a.kind !== 'op' || !onField(a) || !keys.has(a.tileR * COLS + a.tileC)) continue;
battle.addBuff(a, { key: HARVEST, duration: 0.1, mods: { atkPct: num(b3.e_atk) * stacks, aspd: num(b3.e_attack_speed) * stacks }, source: unit, tags: ['skill'] });
}
}
// 【播种标记】: a ground enemy entering a sown tile records it; ≥ max_distance (Manhattan) away it is put back there
const marks = unit.mem.shuMarks ?? (unit.mem.shuMarks = new Map());
const far = Math.max(1, num(b3.max_distance, 4));
for (const e of battle.enemies) {
const m = marks.get(e.id);
if (!e.alive || e.hidden) { if (m) marks.delete(e.id); continue; }
const k = tileKey(e);
if (!m) {
if (!e.isFlying && tiles.has(k)) marks.set(e.id, { r: Math.round(e.y), c: Math.round(e.x), last: k });
continue;
}
if (k === m.last) continue;
m.last = k;
const dist = Math.abs(Math.floor(k / COLS) - m.r) + Math.abs((k % COLS) - m.c);
if (dist < far - 1e-9) continue;
e.x = m.c;
e.y = m.r;
battle._unblock(e);
if (e.route) e.route.pts = null;
m.last = m.r * COLS + m.c;
battle.fx('teleport', { x: m.c, y: m.r, id: e.id, src: unit.id });
}
// no enemy to attack: her attack heals the most injured allied unit of her range (herself included)
if (unit.atkCd > 0 || !unit.canAct || unit.s.flags.disarm || !unit.profile) return;
const prof = effectiveProfile(unit);
if (acquireTargets(battle, unit, prof).length) return;
const a = healTarget(battle, unit, false);
if (!a) return;
performAttack(battle, unit, { ...prof, dmgType: 'heal', heal: { mode: 'single' }, atkScale: 1, healScale: 1, projectile: 'none', splashRadius: 0, chain: null, hits: 1 }, [a]);
unit.atkCd = Math.max(unit.atkCd, unit.s.interval);
},
onEnd({ unit }) { unit.mem.shuMarks = null; },
},
},
talents: [
{ install(battle, unit) { // 百谷长青: every heal she outputs sows the healed unit's x-5; the sown tiles regenerate / 庇护
battle.on('heal', (c) => {
if (c.source !== unit || !up(unit) || c.opts?.regen || !(c.amount > 0) || !onField(c.target)) return;
sow(battle, unit, c.target.tileR, c.target.tileC, SOW);
}, { owner: unit, priority: -20 });
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit) return;
sown(unit).clear();
if (born) sow(battle, unit, unit.tileR, unit.tileC, born); // GUA-X stage 3: 部署时立即给所处地块播种该效果
}, { owner: unit });
battle.on('death', (ctx) => { // 自身退场时清空所有地块的播种效果
if (ctx.unit !== unit) return;
sown(unit).clear();
for (const a of battle.allyUnits) if (a.findBuff(FARM)?.source === unit) battle.removeBuff(a, FARM);
}, { owner: unit });
battle.every(FARM_EVERY, () => farm(battle, unit), { owner: unit, immediate: true });
} },
{ install(battle, unit) { // 天有四时
const hpUp = num(t1.max_hp), asUp = num(t1.attack_speed);
battle.every(FARM_EVERY, () => {
if (!up(unit)) return;
const ops = battle.allyUnits.filter((a) => a.kind === 'op' && onField(a));
const per = new Map();
for (const a of ops) { const p = a.def?.profession ?? '?'; per.set(p, (per.get(p) ?? 0) + 1); }
const many = per.size >= PROF_NEEDED, same = Math.max(0, ...per.values()) >= PROF_NEEDED;
for (const a of ops) {
if (many && hpUp) battle.addBuff(a, { key: 'talent:shu:seasons:hp', duration: FARM_HOLD, mods: { hpPct: hpUp }, source: unit, tags: ['talent'] });
if (same && asUp) battle.addBuff(a, { key: 'talent:shu:seasons:aspd', duration: FARM_HOLD, mods: { aspd: asUp }, source: unit, tags: ['talent'] });
}
}, { owner: unit, immediate: true });
// 编入队伍且编队中有四名【岁】干员: her squad's operators ATK + for the battle, +sp SP every `interval` s deployed
const squadOf = () => battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId);
const atk = num(t1.atk), sp = num(t1.sp), iv = num(t1.interval);
const spBuff = (a) => {
if (sp > 0 && iv > 0) battle.addBuff(a, { key: 'talent:shu:suiSp', interval: iv, onTick: ({ unit: x }) => giveSp(x, sp), source: unit, tags: ['talent'] });
};
const apply = () => {
if (unit.mem.shuSuiDone) return;
unit.mem.shuSuiDone = true;
const squad = squadOf();
if (squad.filter((a) => SUI.has(a.def?.charId)).length < SUI_NEEDED) return;
const members = new Set(squad);
for (const a of squad) {
if (atk) battle.addBuff(a, { key: 'talent:shu:sui', mods: { atkPct: atk }, persist: true, allowDead: true, source: unit, tags: ['talent'] });
if (onField(a)) spBuff(a);
}
battle.on('deploy', (ctx) => { if (members.has(ctx.unit)) spBuff(ctx.unit); }, { owner: unit });
};
if (battle.started) apply();
else battle.on('battleStart', apply, { owner: unit });
} },
],
install(battle, unit) {
// GUA-X: 治疗生命值低于50%的友方单位时治疗量提升15% (heal_scale_up[hpratio][LE]: at or below hp_ratio)
const hs = num(tb.heal_scale, 1), hr = num(tb.hp_ratio);
if (hs !== 1 && hr > 0) {
battle.on('heal', (c) => {
if (c.source === unit && !c.opts?.regen && c.target && c.target.hpRatio <= hr + 1e-9) c.amount *= hs;
}, { owner: unit });
}
},
};
},
};
+125
View File
@@ -0,0 +1,125 @@
// server/sim/content/kits/ops/op-zumama.js — 森蚺 (char_416_zumama) 自选 operator kit: 6★ 决战者 (重装), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and all three modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_416_zumama, 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); PRTS 森蚺 (勇冠三军 备注
// "天赋具有0.1秒的检测周期;高于一半的效果实为在计算伤害时提升本次伤害的攻击力倍率"; S2 备注 "造成的晕眩效果无视目标可选性,持续
// 时间不会因抵抗等效果被缩短或延长"; S3 备注 "生命恢复的提供方式为增加“生命回复速度(百分比)”属性…不受治疗加成和禁疗影响");
// PRTS 分支特性信息 §决战者 ("常态持有阻回(停止任意形式的技力回复,技力也无法自然回复);阻挡敌人期间暂时免疫此效果"; "此分支的部分
// 干员拥有可以移除此阻回效果的模组,但会增加“技力回复速度倍率降低”的限制"); the client's battle data: her charpack (trait abilities
// SpRecoveryStopped / SpRecovery, the S2 mode's stun_ability), buff templates zumama_t_1 / zumama_t_1[atk] / zumama_s_3 /
// zumama_s_3[buff], the module prefabs zumama_equip_{1,2,3}_* and battle_equip_table `validInGameTag`; Arknights Terra
// Wiki "Eunectes" (S2: "unaffected by … Status Resistance and remains even if the target becomes invulnerable or
// untargetable").
// - Trait (决战者) "只有阻挡敌人时才能够回复技力": the charpack's trait — 阻回 (sp_recovery_stopped, overridden while she
// blocks at least one enemy): flag `noSp` while she blocks nobody — no SP of any kind then (time, gifts; skills.js), her
// initSp included at deployment ('init' passes it). The modules replace that 阻回 by a 技力回复速度倍率 (trait bb
// sp_recover_ratio, the equip prefab's attribute 30 on 'sp_e_recovery_stopped', overridden while blocking): HES-X
// “祖玛玛的工具箱” "缓慢回复技力,只有阻挡敌人时恢复技力回复速度" ×(1 − 0.8), HES-Y “小小的丑东西” "不阻挡敌人时无法自我回复技力"
// ×(1 − 0.999) — SP recovery ×(1 + ratio) while she blocks nobody (spRecoveryMul; SP gifts still land) —, RA-A
// 森蚺特限证章 ×(1 − 1) (its TRAIT part carries no validInGameTag: it holds in every mode). HES-Y also "阻挡敌人时…攻击力
// +15%,防御力+15%" (trait bb atk / def) while she blocks.
// - Module RA-A: every other part is 生息演算-only (battle_equip_table validInGameTag 'sandbox': the hidden talent's
// attraction / block / 物理脆弱 and 愈战愈勇's +0.4/s; trait text "在生息演算中…") — N/A here; its HP / ATK are in the stats. The
// composed record merges the sandbox 愈战愈勇 part (0.4) into `talents`, so this kit reads 愈战愈勇 from `talentsBase` under
// RA-A (+0.2/s, the module's own text "阻挡敌人时技力回复速度+0.2/秒;在生息演算中…").
// - T1 勇冠三军 (zumama_t_1, polled every T1_PERIOD s): HP ratio above hp_ratio ⇒ every damage she deals ×atk_scale as an
// 攻击力倍率 (atkScaleMul: applied to the ATK before DEF — PRTS 备注); at or below it ⇒ damage_resistance 庇护 (ba.protect
// "受到的物理和法术伤害降低相应比例(同名效果取最高)": the shared applyStrongest key PROTECT). HES-Y stage 3: 123 % / 28 % (the module
// talent change).
// - T2 愈战愈勇 "阻挡敌人时技力回复速度+0.2/秒": spRecoveryFlat while she blocks. HES-X stage 3: +0.55/s.
// - S1 轻型挂斧 (PASSIVE): ATK / DEF +atk / +def from every deployment.
// - S2 震慑劈砍 (MANUAL; data DEFAULT — the owner's 重装 exception of 2026-10-05, rawRule TAKE_DAMAGE): `duration` s, ATK
// +atk, attack interval +base_attack_time s (a flat +0.4 on her 1.6 s), and every enemy she blocks is stunned while it is
// blocked and the skill runs (the charpack's _buffsToBlockee 'stun'): a stun pulse of STUN_PULSE s renewed every tick
// past 抵抗 (`resistApplied`: "持续时间不会因抵抗等效果被缩短或延长") and refused only by a 晕眩 immunity — so it ends at most
// STUN_PULSE s after the block or the skill. [ASSUMED] a blockee that turns 无敌 and 无法选中 at once loses it after one pulse
// (the engine refuses statuses on such a unit; Terra: the official one stays).
// - S3 钢铁意志 (MANUAL; data DEFAULT, rawRule TAKE_DAMAGE): `duration` s, ATK / DEF +atk / +def, block +block_cnt,
// 生命回复速度 +hp_recovery_per_sec_by_max_hp_ratio × max HP (hpRegenRatio — checklist 11: no heal, 禁疗-proof); when it ends
// she is stunned `stun` s (zumama_s_3 ON_BUFF_FINISH 'stun'; a stunned operator blocks nobody).
// - Melee physical, ground-only (data canHitFly false), block 1, range 1-1; ground enemies target her (no 起飞 / 迷彩).
import { num, traitBb, skillRec, toggleBuff, batMod, up } from '../shared/tier1.js';
const S1 = 'skchr_zumama_1';
const S2 = 'skchr_zumama_2';
const S3 = 'skchr_zumama_3';
/** PRTS 勇冠三军 备注 "天赋具有0.1秒的检测周期". */
const T1_PERIOD = 0.1;
/** 庇护 refresh: a little longer than the check period, so it never lapses while it holds. */
const PROTECT_HOLD = T1_PERIOD + 0.05;
/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest). */
const PROTECT = 'protect';
const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
/** S2's blockee stun, renewed every tick while it holds (three ticks long). */
const STUN_PULSE = 0.1;
const VALOR = 'talent:zumama:valor';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** The record's active module, or null. */
const activeModule = (chess) => (chess?.module?.active ? (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null : null);
/** A module whose extra parts work 在生息演算中 only (RA-A): its talent changes do not apply here. */
const sandboxOnly = (mod) => /生息演算/.test(String(mod?.traitOverride?.moduleDesc ?? ''));
/** Blackboard of the talent with data index `i` in `list` ({} when absent). */
const talentAt = (list, i) => (list ?? []).find((t) => t && t.index === i)?.bb ?? {};
export default {
char_416_zumama: (bb, chess) => {
const mod = activeModule(chess);
const talents = mod && sandboxOnly(mod) ? chess.talentsBase : chess?.talents;
const t0 = talentAt(talents, 0), t1 = talentAt(talents, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
return {
skills: {
[S1]: { kind: 'passive', mods: { atkPct: num(b1.atk), defPct: num(b1.def) } },
[S2]: {
kind: 'duration',
mods: { atkPct: num(b2.atk), batPct: batMod(b2.base_attack_time, chess) },
onTick({ battle, unit }) {
for (const e of unit.blocking) {
if (e.alive && e.blockedBy === unit) battle.applyStatus(e, 'stun', { duration: STUN_PULSE, source: unit, resistApplied: true });
}
},
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), defPct: num(b3.def), blockCnt: num(b3.block_cnt), hpRegenRatio: num(b3.hp_recovery_per_sec_by_max_hp_ratio) },
onEnd({ battle, unit }) {
if (up(unit) && num(b3.stun) > 0) battle.applyStatus(unit, 'stun', { duration: num(b3.stun), source: unit });
},
},
},
talents: [
{ install(battle, unit) { // 勇冠三军: above hp_ratio ×atk_scale (攻击力倍率), at or below it 庇护 — checked every T1_PERIOD s
const ratio = num(t0.hp_ratio, 0.5), scale = num(t0.atk_scale, 1), dr = num(t0.damage_resistance);
const check = () => {
const high = up(unit) && unit.hpRatio > ratio + 1e-9;
const has = unit.findBuff(VALOR);
if (high && !has && scale !== 1) battle.addBuff(unit, { key: VALOR, mods: { atkScaleMul: scale }, tags: ['talent'] });
else if (!high && has) battle.removeBuff(unit, VALOR);
if (up(unit) && !high && dr > 0) battle.applyStrongest(unit, PROTECT, { duration: PROTECT_HOLD, value: dr, mods: protectMods, source: unit });
};
battle.every(T1_PERIOD, check, { owner: unit, immediate: true });
battle.on('deploy', (ctx) => { if (ctx.unit === unit) check(); }, { owner: unit });
} },
{ install(battle, unit) { // 愈战愈勇: SP recovery +sp_recovery_per_sec while she blocks
const sp = num(t1.sp_recovery_per_sec);
if (sp > 0) toggleBuff(battle, unit, 'talent:zumama:fight', () => unit.blocking.length > 0, { spRecoveryFlat: sp });
} },
],
install(battle, unit) {
// 决战者: 阻回 while she blocks nobody; a module turns it into ×(1 + sp_recover_ratio) SP recovery instead
const notBlocking = () => unit.blocking.length === 0;
if (tb.sp_recover_ratio == null) toggleBuff(battle, unit, 'trait:zumama:duel', notBlocking, null, { flags: { noSp: true } });
else toggleBuff(battle, unit, 'trait:zumama:duel', notBlocking, { spRecoveryMul: Math.max(0, 1 + num(tb.sp_recover_ratio)) });
// HES-Y: 阻挡敌人时攻击力+15%,防御力+15%
const ma = num(tb.atk), md = num(tb.def);
if (ma || md) toggleBuff(battle, unit, 'trait:zumama:block', () => unit.blocking.length > 0, { atkPct: ma, defPct: md });
},
};
},
};
+314
View File
@@ -0,0 +1,314 @@
// test/content/op_judge.test.js — the 自选 operator kit of 斥罪 (char_4065_judge, 6★ 不屈者; kit
// server/sim/content/kits/ops/op-judge.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, UNY-X
// “通往未来的荆棘之路” or UNY-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_judge.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } 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 JUDGE = 'char_4065_judge';
const FORMS = BACKUPS.units[JUDGE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const UNYX = 'uniequip_002_judge', UNYY = 'uniequip_003_judge';
const S1 = 'skchr_judge_1', S2 = 'skchr_judge_2', S3 = 'skchr_judge_3';
const BARRIER = 'talent:judge:barrier';
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_frail: dummy('enemy_frail', { hp: 10 }),
};
const ALLIES = { t_mate: chessRec({ id: 't_mate', skill: null, stats: { maxHp: 10000, atk: 0, def: 0, blockCnt: 0 } }),
t_medic: chessRec({ id: 't_medic', profession: 'MEDIC', skill: null, stats: { maxHp: 10000, atk: 500, def: 0, blockCnt: 0 } }) };
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, UNYX, UNYY].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, setup = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 900, autoFinish: false, seed, setup,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'kill'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: JUDGE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const barrier = (u) => u.findBuff(BARRIER)?.shield ?? 0;
const t0Of = (u) => u.def.raw.talents.find((t) => t.index === 0).bb;
const t1Of = (u) => u.def.raw.talents.find((t) => t.index === 1).bb;
const thorns = (h, u, target = null) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags ?? []).includes('judge:thorns') && (!target || c.target === target));
/** Knock an enemy out with damage credited to her. */
function killOne(h, u) {
const e = h.spawn('enemy_frail', { pos: [12, 9] });
h.b.dealDamage(u, e, { amount: 1e6, type: 'true' });
assert.ok(!e.alive, 'knocked out');
}
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, 叙拉古, no 特质; 不屈者: no heal from others; triggers from the data', () => {
assert.equal(OPERATOR_KITS[JUDGE], KITS[JUDGE]);
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, others: [{ uid: 2, chessId: 't_medic', row: 11, col: 5 }] });
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], [JUDGE, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.noHeal, u.base.bat], [3, 'melee', 'phys', false, true, 1.6], `${label(f)}: 不屈者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['siracusaShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
const sk = form.skills[skill];
if (skill === 0) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.maxCharges], ['DEFAULT', 'DEFAULT', 'AUTO', 2], `${label(f)}: S1 AUTO`);
else assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType], ['DEFAULT', 'TAKE_DAMAGE', 'MANUAL'], `${label(f)}: S${skill + 1} trigger (the 重装 exception)`);
assert.equal(u.skill.spType, skill === 2 ? 'hurt' : 'time', `${label(f)}: SP type`);
// 无法被友方角色治疗: a heal from another unit does nothing
u.hp = u.s.maxHp * 0.5;
assert.equal(h.b.heal(h.unit(2), u, 500), 0, `${label(f)}: no heal from others`);
done(h);
}
}
// zh_CN numbers: E2 Lv1 2960 / 683 / 474, E2 Lv60 3686 / 811 / 568; UNY-X +150/+35/+15 → +300/+50/+33, UNY-Y +60/+48 → +85/+70
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], [2960, 683, 3686, 811]);
assert.deepEqual([modOf(5, UNYX).attr, modOf(6, UNYX).attr, modOf(5, UNYY).attr, modOf(6, UNYY).attr],
[{ maxHp: 150, atk: 35, def: 15 }, { maxHp: 300, atk: 50, def: 33 }, { atk: 60, def: 48 }, { atk: 85, def: 70 }]);
});
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(JUDGE));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(JUDGE), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: JUDGE, skillIndex: 0, uniEquipId: UNYY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: JUDGE, skillIndex: 0, uniEquipId: UNYY } } });
});
test('T1 律法卫士: barrier 50 % max HP at every deployment (70 % UNY-X stage 3), +10 % per enemy she knocks out (12 %), never past 300 % of the current max HP; it absorbs before HP', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const t0 = t0Of(u);
const x3 = elite && mod === UNYX && tier === 6;
assert.deepEqual([t0.born_hp_ratio, t0.kill_hp_ratio, t0.max_hp_ratio], x3 ? [0.7, 0.12, 3] : [0.5, 0.1, 3], label(f));
approx(barrier(u), u.s.maxHp * t0.born_hp_ratio, `${label(f)}: at the deployment`);
killOne(h, u);
approx(barrier(u), u.s.maxHp * (t0.born_hp_ratio + t0.kill_hp_ratio), `${label(f)}: +1 kill`);
// it absorbs: HP untouched until it runs out
const hp0 = u.hp, e = h.spawn('enemy_dummy', { pos: [12, 9] });
h.b.dealDamage(e, u, { amount: 300, type: 'true' });
assert.equal(u.hp, hp0, `${label(f)}: absorbed`);
approx(barrier(u), u.s.maxHp * (t0.born_hp_ratio + t0.kill_hp_ratio) - 300, `${label(f)}: −300`);
// the cap: 300 % of the current max HP
u.findBuff(BARRIER).shield = u.s.maxHp * 2.95;
killOne(h, u);
approx(barrier(u), u.s.maxHp * 3, `${label(f)}: capped`);
killOne(h, u);
approx(barrier(u), u.s.maxHp * 3, `${label(f)}: no gain at the cap`);
// a max-HP change keeps the barrier; the cap follows the current max HP
h.b.addBuff(u, { key: 'test:hp', mods: { hpPct: -0.5 } });
const kept = barrier(u);
killOne(h, u);
assert.equal(barrier(u), kept, `${label(f)}: above the new cap: no gain, no cut`);
h.b.removeBuff(u, 'test:hp');
// a new deployment: a new barrier
h.b.retreat(u);
h.b.redeploy(u);
h.step();
approx(barrier(u), u.s.maxHp * t0.born_hp_ratio, `${label(f)}: again at the redeployment`);
done(h);
}
});
test('T2 荆棘环身: while she holds barrier, every damage from an enemy deals 50 % ATK arts back (58 % UNY-Y stage 3) — the hit that breaks it too; none without barrier, for a 流失, a 无来源 hit or an ally', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 't_mate', row: 12, col: 3 }] });
const t1 = t1Of(u);
assert.equal(t1.atk_scale, elite && mod === UNYY && tier === 6 ? 0.58 : 0.5, label(f));
const e = h.spawn('enemy_dummy', { pos: [12, 9] });
h.b.dealDamage(e, u, { amount: 100, type: 'phys' });
const t = thorns(h, u, e);
assert.equal(t.length, 1, `${label(f)}: one reflection`);
approx(t[0].amount, u.s.atk * t1.atk_scale, `${label(f)}: ${t1.atk_scale * 100} % ATK`);
assert.deepEqual([t[0].type, t[0].dmg.isAttack, t[0].dmg.canDodge], ['arts', false, false], `${label(f)}: arts, not an attack, undodgeable`);
h.b.loseHp(u, 50, { source: e });
h.b.dealDamage(e, u, { amount: 50, type: 'true', sourceless: true });
h.b.dealDamage(h.unit(2), u, { amount: 50, type: 'true' });
assert.equal(thorns(h, u).length, 1, `${label(f)}: no reflection for a 流失, a 无来源 hit or an ally`);
// the breaking hit still reflects; then none
u.findBuff(BARRIER).shield = 10;
h.b.dealDamage(e, u, { amount: 200, type: 'true' });
assert.equal(barrier(u), 0, `${label(f)}: broken`);
assert.equal(thorns(h, u).length, 2, `${label(f)}: the breaking hit reflects`);
h.b.dealDamage(e, u, { amount: 50, type: 'true' });
assert.equal(thorns(h, u).length, 2, `${label(f)}: no barrier, no thorns`);
done(h);
}
});
test('S1 一锤定音 (AUTO, data DEFAULT): the next attack adds 160 % / 180 % ATK arts; every cast spends all the SP; 蓄力 (twice the cost) also stuns 3 s and raises all her damage ×1.8 (thorns included), and reaching it restarts her attack cycle', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
let probe = null;
const { h, u } = field({ tier, elite, skill: 0, setup: (b) => {
// during her 蓄力 attack an enemy hits her: the reflection carries the ×1.8
b.on('hit', (c) => {
if (!probe || probe.done || c.source !== probe.u || !c.dmg?.isAttack) return;
probe.done = true;
b.dealDamage(probe.e, probe.u, { amount: 10, type: 'true' });
});
} });
assert.deepEqual([sk.spCost, sk.initSp, sk.maxChargeTime, sk.bb.atk_scale_2, sk.bb['judge_s_1_enhance_checker.atk_scale'], sk.bb.stun], elite ? [4, 0, 2, 1.8, 1.8, 3] : [5, 0, 2, 1.6, 1.8, 3], `T${tier}`);
// one charge: a plain cast
u.skill.gainSp(sk.spCost, 'init');
assert.equal(u.skill.charges, 1);
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC + 1] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier}: cast at her attack`);
assert.deepEqual([u.skill.charges, u.skill.sp], [0, 0], `T${tier}: all SP spent`);
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
h.step(2);
const gavel = hits().filter((c) => (c.dmg?.tags ?? []).includes('judge:gavel'));
assert.equal(gavel.length, 1, `T${tier}: one arts rider`);
approx(gavel[0].amount, u.s.atk * sk.bb.atk_scale_2, `T${tier}: ${sk.bb.atk_scale_2 * 100} % ATK arts`);
assert.deepEqual([gavel[0].type, gavel[0].dmg.isSkill], ['arts', true]);
assert.ok(!h.hooksOf('statusApplied').some((c) => c.target === e && c.status === 'stun'), `T${tier}: no stun without 蓄力`);
// 蓄力: the SP runs on to twice the cost; the attack cycle restarts the moment it gets there
h.b.kill(e, u);
h.step(2);
u.atkCd = 1.2;
u.skill.setSpTotal(2 * sk.spCost - 0.02);
assert.equal(u.skill.charges, 1);
assert.ok(h.runUntil(() => u.skill.charges === 2, 1), `T${tier}: the stack fills`);
h.step();
assert.equal(u.atkCd, 0, `T${tier}: attack cycle restarted`);
const e2 = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC + 1] });
probe = { u, e: e2, done: false };
assert.ok(h.runUntil(() => u.skill.activations === 2, 1), `T${tier}: 蓄力 cast`);
assert.deepEqual([u.skill.charges, u.skill.sp], [0, 0], `T${tier}: all SP spent`);
h.step(2);
const all = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e2);
const phys = all.find((c) => c.dmg?.isAttack);
const rider = all.find((c) => (c.dmg?.tags ?? []).includes('judge:gavel'));
approx(phys.amount, u.s.atk * 1.8, `T${tier}: the attack ×1.8`);
approx(rider.amount, u.s.atk * sk.bb.atk_scale_2 * 1.8, `T${tier}: the rider ×1.8`);
const th = thorns(h, u, e2);
assert.ok(th.length >= 1, `T${tier}: a reflection during the attack`);
approx(th[0].amount, u.s.atk * t1Of(u).atk_scale * 1.8, `T${tier}: thorns ×1.8`);
const st = h.hooksOf('statusApplied').find((c) => c.target === e2 && c.status === 'stun' && c.source === u);
assert.ok(st, `T${tier}: stun`);
approx(st.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
assert.equal(u.s.atkScaleMul, 1, `T${tier}: ×1.8 gone after the attack`);
done(h);
}
});
test('S2 坚心苦修 (data DEFAULT): 20 s without attacks, 40 % / 50 % 庇护, 90 % / 110 % ATK arts to every ground enemy on her x-4 at 0.9 s and every second after (20 pulses), barrier gains ×1.6 / ×1.8 meanwhile', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([sk.duration, sk.bb.damage_resistance, sk.bb.atk_scale, sk.bb.shield_scale, sk.rangeId], elite ? [20, 0.5, 1.1, 0.8, 'x-4'] : [20, 0.4, 0.9, 0.6, 'x-4'], `T${tier}`);
u.skill.gainSp(999, 'init');
const inside = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [11, 4] }), h.spawn('enemy_dummy', { pos: [9, 5] })];
const fly = h.spawn('enemy_fly', { pos: [9, 6] }), out = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in range`);
const t0 = h.hooksOf('skillStart').find((c) => c.unit === u).t;
const pb = u.findBuff('protect');
approx(pb.data.value, sk.bb.damage_resistance, `T${tier}: 庇护`);
const n0 = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).length;
// the barrier gain of a kill ×(1 + shield_scale)
const b0 = barrier(u);
killOne(h, u);
approx(barrier(u) - b0, u.s.maxHp * t0Of(u).kill_hp_ratio * (1 + sk.bb.shield_scale), `T${tier}: kill barrier ×${1 + sk.bb.shield_scale}`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1), `T${tier}: ends`);
const pulses = h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags ?? []).includes('judge:atonement'));
const times = [...new Set(pulses.map((c) => Math.round((c.t - t0) * 100) / 100))];
assert.equal(times.length, 20, `T${tier}: 20 pulses`);
approx(times[0], 0.9, `T${tier}: first at 0.9 s`, 0.05);
approx(times[1] - times[0], 1, `T${tier}: then every second`, 0.05);
for (const e of inside) assert.equal(pulses.filter((c) => c.target === e).length, 20, `T${tier}: ${e.tileR},${e.tileC} hit 20 times`);
for (const c of pulses.slice(0, 3)) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.deepEqual([c.type, c.dmg.isSkill, c.dmg.isAttack], ['arts', true, false]);
}
assert.ok(!pulses.some((c) => c.target === fly || c.target === out), `T${tier}: no flyer, nothing outside x-4`);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.t > t0 + 0.01 && c.t < t0 + sk.duration - 0.01).length - n0, 0, `T${tier}: no attack while it runs`);
assert.equal(u.findBuff('protect'), null, `T${tier}: 庇护 gone`);
done(h);
}
});
test('S3 披荆斩棘 (data DEFAULT, 受击回复 27 / 24 SP — absorbed hits count): barrier +70 % / +100 % max HP (to the cap), 30 s ATK +260 % / +320 %, interval 1.6 + 0.9 s, taunt +1', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([sk.duration, sk.bb.hp_ratio, sk.bb.atk, sk.bb.base_attack_time, sk.bb.taunt_level, sk.spCost, sk.initSp, sk.spType], elite ? [30, 1, 3.2, 0.9, 1, 24, 0, 'INCREASE_WHEN_TAKEN_DAMAGE'] : [30, 0.7, 2.6, 0.9, 1, 27, 0, 'INCREASE_WHEN_TAKEN_DAMAGE'], `T${tier}`);
const src = h.spawn('enemy_dummy', { pos: [12, 9] });
for (let i = 0; i < sk.spCost - 1; i++) h.b.dealDamage(src, u, { amount: 1, type: 'true' });
assert.equal(u.skill.sp, sk.spCost - 1, `T${tier}: one SP per hit, the barrier absorbing`);
assert.equal(u.hp, u.s.maxHp, `T${tier}: HP untouched`);
h.b.dealDamage(src, u, { amount: 1, type: 'true' });
assert.ok(u.skill.ready, `T${tier}: ready`);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy in range, no cast`);
const b0 = barrier(u);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in range`);
approx(barrier(u), Math.min(u.s.maxHp * 3, b0 + u.s.maxHp * sk.bb.hp_ratio), `T${tier}: +${sk.bb.hp_ratio * 100} % max HP`, 1e-3);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 2.5, `T${tier}: 1.6 + 0.9 s`);
assert.equal(u.s.taunt, 1, `T${tier}: taunt +1`);
// the cap holds for the skill's barrier too
h.b.retreat(u);
h.b.redeploy(u);
h.step();
u.findBuff(BARRIER).shield = u.s.maxHp * 2.5;
u.skill.gainSp(999, 'init');
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(barrier(u), u.s.maxHp * 3, `T${tier}: capped at 300 %`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1));
approx(u.s.interval, 1.6, `T${tier}: interval back`);
assert.equal(u.s.taunt, 0, `T${tier}: taunt back`);
done(h);
}
});
test('UNY-X “通往未来的荆棘之路”: damage from an enemy she blocks ×0.85 (stages 1 and 3); UNY-Y “无罪”: ATK / DEF +8 % with no allied operator on the 8 tiles around her (a summon does not count)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const others = [{ uid: 2, chessId: 't_mate', row: 12, col: 8 }, { uid: 3, chessId: 'chess_char_3_19_a', row: 12, col: 3 },
{ uid: 4, kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 3, row: 11, col: 6 }];
const { h, u } = field({ tier, elite, mod, skill: 2, others });
const x = elite && mod === UNYX, y = elite && mod === UNYY;
if (x) assert.equal(u.def.raw.trait.bb.damage_scale, 0.85, label(f));
if (y) assert.deepEqual(u.def.raw.trait.bb, { atk: 0.08, def: 0.08 }, label(f));
u.findBuff(BARRIER).shield = 0;
const blocked = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => blocked.blockedBy === u, 1));
const free = h.spawn('enemy_dummy', { pos: [12, 9] });
const take = (src) => { u.hp = u.s.maxHp; return h.b.dealDamage(src, u, { amount: 500, type: 'true' }); };
approx(take(blocked), x ? 425 : 500, `${label(f)}: from the blocked enemy`);
approx(take(free), 500, `${label(f)}: from another`);
// the wolf (a summon) is on the 8 around her: still alone
h.step(2);
assert.equal(!!u.findBuff('trait:judge:alone'), y, `${label(f)}: alone (a summon next to her)`);
if (y) approx(u.s.atk, u.base.atk * 1.08, `${label(f)}: ATK +8 %`);
h.b.relocate(h.unit(2), 9, 4); // an operator on her diagonal
h.step(2);
assert.equal(u.findBuff('trait:judge:alone'), null, `${label(f)}: not alone`);
done(h);
}
});
+370
View File
@@ -0,0 +1,370 @@
// test/content/op_shu.test.js — the 自选 operator kit of 黍 (char_2025_shu, 6★ 守护者; kit
// server/sim/content/kits/ops/op-shu.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 GUA-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_shu.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
import { COLS } from '../../server/sim/constants.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 SHU = 'char_2025_shu';
const FORMS = BACKUPS.units[SHU].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const GUAX = 'uniequip_002_shu';
const S1 = 'skchr_shu_1', S2 = 'skchr_shu_2', S3 = 'skchr_shu_3';
const FARM = 'talent:shu:farm';
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_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 1, mass: 0 }),
};
const ally = (id, o = {}) => chessRec({ id, skill: null, ...o, stats: { maxHp: 10000, atk: 0, def: 0, blockCnt: 0, respawnTime: 5, ...(o.stats || {}) } });
const ALLIES = {
t_a: ally('t_a'), t_b: ally('t_b'), t_c: ally('t_c'), t_d: ally('t_d'),
t_war: ally('t_war', { profession: 'WARRIOR' }), t_war2: ally('t_war2', { profession: 'WARRIOR' }), t_war3: ally('t_war3', { profession: 'WARRIOR' }),
t_med: ally('t_med', { profession: 'MEDIC' }),
t_sp: chessRec({ id: 't_sp', profession: 'WARRIOR', stats: { maxHp: 10000, atk: 0, def: 0, blockCnt: 0 },
skill: { skillId: 'sk_t_sp', spType: 'INCREASE_WHEN_ATTACK', spCost: 99, initSp: 0, duration: 5 } }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, GUAX].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, enemies = [] } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 900, autoFinish: false, seed, enemies,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SHU, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const healsBy = (h, u, f = () => true) => h.hooksOf('heal').filter((c) => c.source === u && !c.opts?.regen && f(c));
const sownOf = (u) => u.mem.shuSown ?? new Set();
const key = (r, c) => r * COLS + c;
/** GUA-X on this form: ×heal_scale on a target at or below hp_ratio. */
const guax = (u, ratio) => (u.def.raw.trait.bb.heal_scale && ratio <= u.def.raw.trait.bb.hp_ratio + 1e-9 ? u.def.raw.trait.bb.heal_scale : 1);
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, blocks 3, melee ground-only, 炎, no 特质; triggers (S3: TRY_SEARCH_ALLY_SKILL → SKILL_RANGE + allies on x-2)', () => {
assert.equal(OPERATOR_KITS[SHU], KITS[SHU]);
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], [SHU, 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 + (m?.attr.def ?? 0), 10], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [3, 'melee', 'phys', false, 1.2], `${label(f)}: 守护者`);
assert.deepEqual(u.liveRangeGrid, [[0, 0]], `${label(f)}: 0-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, `${label(f)}: ground enemies target her`);
const sk = form.skills[skill];
if (skill === 0) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.triggerAllies, u.skill.triggerHpAtMost], ['DEFAULT', 'DEFAULT', 'AUTO', true, 0.5], `${label(f)}: S1`);
if (skill === 1) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.triggerAllies], ['DEFAULT', 'TAKE_DAMAGE', 'MANUAL', false], `${label(f)}: S2 (the 重装 exception)`);
if (skill === 2) {
assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule, sk.skillType], ['TRY_SEARCH_ALLY_SKILL', 'TRY_SEARCH_ALLY_SKILL', 'MANUAL'], `${label(f)}: S3 data rule`);
assert.deepEqual([u.skill.rule, u.skill.triggerAllies, u.skill.triggerGrid], ['SKILL_RANGE', true, sk.rangeGrid], `${label(f)}: S3 engine rule`);
}
done(h);
}
}
// zh_CN numbers: E2 Lv1 2313 / 392 / 493, E2 Lv60 2910 / 450 / 565; GUA-X +160/+30/+30 → +270/+50/+50
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], [2313, 392, 2910, 450]);
assert.deepEqual([modOf(5, GUAX).attr, modOf(6, GUAX).attr], [{ maxHp: 160, atk: 30, def: 30 }, { maxHp: 270, atk: 50, def: 50 }]);
});
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(SHU));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SHU), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: SHU, skillIndex: 2, uniEquipId: GUAX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: SHU, skillIndex: 2, uniEquipId: GUAX } } });
});
test('S1 化被草木 (AUTO, DEFAULT + an ally of x-4 at ≤ half HP): her attack heals the lowest such ally for 130 % / 150 % ATK instead (×1.15 GUA-X); 1 / 2 charges; nothing for allies above half or outside x-4', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, GUAX]]) {
const sk = skillOf(tier, elite, S1);
const others = [{ uid: 2, chessId: 't_a', row: 11, col: 4 }, { uid: 3, chessId: 't_b', row: 9, col: 5 }, { uid: 4, chessId: 't_c', row: 12, col: 5 }];
const { h, u } = field({ tier, elite, mod, skill: 0, others });
assert.deepEqual([sk.bb.heal_scale, sk.maxChargeTime, sk.spCost, sk.rangeId], elite ? [1.5, 2, 5, 'x-4'] : [1.3, 1, 5, 'x-4'], `T${tier}`);
const [a, b, c] = [h.unit(2), h.unit(3), h.unit(4)];
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
u.skill.gainSp(999, 'init');
a.hp = a.s.maxHp * 0.6; b.hp = b.s.maxHp * 0.55; c.hp = c.s.maxHp * 0.1;
h.run(3);
assert.equal(u.skill.activations, 0, `T${tier}: nobody of x-4 at ≤ half (the one at 10 % is outside)`);
assert.ok(h.hooksOf('damaged').some((x) => x.source === u && x.target === e), `T${tier}: she attacks meanwhile`);
a.hp = a.s.maxHp * 0.3; b.hp = b.s.maxHp * 0.45;
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier}: cast at her attack`);
const first = healsBy(h, u)[0];
assert.equal(first.target, a, `T${tier}: the lowest`);
approx(first.amount, u.s.atk * sk.bb.heal_scale * guax(u, 0.3), `T${tier}: ${sk.bb.heal_scale * 100} % ATK`, 1e-3);
const t0 = h.hooksOf('skillStart').find((x) => x.unit === u).t;
assert.ok(!h.hooksOf('damaged').some((x) => x.source === u && x.target === e && Math.abs(x.t - t0) < 1e-9), `T${tier}: that attack was the heal`);
assert.ok(sownOf(u).has(key(a.tileR, a.tileC)), `T${tier}: the heal sowed`);
if (elite) {
a.hp = a.s.maxHp;
assert.ok(h.runUntil(() => u.skill.activations === 2, 5), 'the second charge on the next attack');
assert.equal(healsBy(h, u)[1].target, b, 'the ally still at ≤ half');
}
done(h);
}
});
test('S2 嘉禾盈仓 (data DEFAULT: casts at her attack on an enemy): 25 s, her attacks heal up to 2 injured allies of x-1 for ATK (+60 % / +90 %), block 3 + 1, interval 1.2 + 1.3 s, range x-1 meanwhile; T1 ×1.3 / ×1.4', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, GUAX]]) {
const sk = skillOf(tier, elite, S2);
const others = [{ uid: 2, chessId: 't_a', row: 10, col: 7 }, { uid: 3, chessId: 't_b', row: 12, col: 5 }, { uid: 4, chessId: 't_c', row: 9, col: 4 }, { uid: 5, chessId: 't_d', row: 12, col: 7 }];
const { h, u } = field({ tier, elite, mod, skill: 1, others });
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.block_cnt, sk.bb.base_attack_time, sk.bb['attack@max_target'], sk.bb.extra_extend_scale, sk.rangeId], elite ? [25, 0.9, 1, 1.3, 2, 1.4, 'x-1'] : [25, 0.6, 1, 1.3, 2, 1.3, 'x-1'], `T${tier}`);
const [a, b, c, d] = [h.unit(2), h.unit(3), h.unit(4), h.unit(5)];
u.skill.gainSp(999, 'init');
a.hp = a.s.maxHp * 0.4; b.hp = b.s.maxHp * 0.7; c.hp = c.s.maxHp * 0.9; d.hp = d.s.maxHp * 0.2;
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: injured allies alone do not cast it`);
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast at her attack`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-1 while it runs`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
assert.equal(u.s.blockCnt, 4, `T${tier}: block 3 + 1`);
approx(u.s.bat, 2.5, `T${tier}: base attack time 1.2 + 1.3 s`); // (T2: four WARRIOR here ⇒ ASPD +12 on top)
const t0 = h.hooksOf('skillStart').find((x) => x.unit === u).t;
assert.ok(h.runUntil(() => healsBy(h, u, (x) => x.t >= t0 - 1e-9).length >= 2, 4), `T${tier}: a heal attack (the cast's own attack)`);
const hs = healsBy(h, u, (x) => x.t >= t0 - 1e-9);
assert.deepEqual(hs.slice(0, 2).map((x) => x.target.id).sort(), [a.id, b.id].sort(), `T${tier}: the two lowest of x-1 (the one at 20 % is outside)`);
for (const x of hs.slice(0, 2)) approx(x.amount, u.s.atk * guax(u, x.target === a ? 0.4 : 0.7), `T${tier}: ATK`, 1e-3);
assert.ok(!h.hooksOf('damaged').some((x) => x.source === u && x.target === e && x.t > t0 + 0.01), `T${tier}: no attack on the enemy`);
// T1 on her sown tiles ×extra_extend_scale while it runs
h.run(0.3);
const fb = a.findBuff(FARM);
assert.ok(fb, `T${tier}: the healed ally stands on a sown tile`);
const t0b = u.def.raw.talents.find((t) => t.index === 0).bb;
approx(fb.mods.hpRegen, t0b.hp_recovery_per_sec * sk.bb.extra_extend_scale, `T${tier}: regeneration ×${sk.bb.extra_extend_scale}`);
approx(a.findBuff('protect').data.value, t0b.damage_resistance * sk.bb.extra_extend_scale, `T${tier}: 庇护 ×${sk.bb.extra_extend_scale}`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1), `T${tier}: ends`);
h.run(0.6);
assert.deepEqual(u.liveRangeGrid, [[0, 0]], `T${tier}: back to 0-1`);
approx(a.findBuff(FARM).mods.hpRegen, t0b.hp_recovery_per_sec, `T${tier}: regeneration back to ×1`);
approx(u.s.bat, 1.2, `T${tier}: attack time back`);
done(h);
}
});
test('S3 离离枯荣 (SKILL_RANGE + an injured ally of x-2 — no enemy needed, an enemy alone does not cast it): range x-2, ATK +22 % / +34 %, every attack on an enemy also heals the most injured other ally for ATK; with no enemy her attack heals (herself included)', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, GUAX]]) {
const sk = skillOf(tier, elite, S3);
const others = [{ uid: 2, chessId: 't_a', row: 12, col: 5 }, { uid: 3, chessId: 't_b', row: 12, col: 9 }];
const { h, u } = field({ tier, elite, mod, skill: 2, others });
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.e_atk, sk.bb.e_attack_speed, sk.bb.max_distance, sk.rangeId], elite ? [30, 0.34, 0.2, 20, 3, 'x-2'] : [30, 0.22, 0.15, 15, 4, 'x-2'], `T${tier}`);
const [a, far] = [h.unit(2), h.unit(3)];
u.skill.gainSp(999, 'init');
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
far.hp = far.s.maxHp * 0.2;
h.run(3);
assert.equal(u.skill.activations, 0, `T${tier}: an enemy and an injured ally outside x-2 do not cast it`);
a.hp = a.s.maxHp * 0.6;
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for the injured ally of x-2`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2 while it runs`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
const t0 = h.hooksOf('skillStart').find((x) => x.unit === u).t;
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((x) => x.source === u && x.target === e && x.t > t0), 3), `T${tier}: she strikes the enemy 2 tiles ahead`);
h.step();
const hit = h.hooksOf('damaged').find((x) => x.source === u && x.target === e && x.t > t0);
assert.deepEqual([hit.type, hit.dmg.isAttack], ['phys', true]);
const hh = healsBy(h, u, (x) => x.t >= hit.t - 1e-9);
assert.equal(hh[0]?.target, a, `T${tier}: the attack heals the injured ally`);
approx(hh[0].amount, u.s.atk, `T${tier}: for ATK`, 1e-3);
// she alone injured: an enemy attack heals nobody; with no enemy her attack heals her
a.hp = a.s.maxHp;
u.hp = u.s.maxHp * 0.7;
const n0 = healsBy(h, u).length;
h.run(3);
assert.equal(healsBy(h, u).length, n0, `T${tier}: the attack-heal is for the others`);
h.b.kill(e, u);
assert.ok(h.runUntil(() => healsBy(h, u).length > n0, 3), `T${tier}: no enemy: a heal attack`);
assert.equal(healsBy(h, u)[n0].target, u, `T${tier}: on herself`);
assert.ok(h.hooksOf('attack').some((x) => x.attacker === u && x.targets[0] === u), `T${tier}: an attack (ASPD / 缴械 count)`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration), `T${tier}: ends`);
assert.deepEqual(u.liveRangeGrid, [[0, 0]], `T${tier}: back to 0-1`);
done(h);
}
});
test('S3 离离枯荣: a ground enemy on a sown tile ⇒ the operators of her range ATK +15 % / +20 % and ASPD +15 / +20 (no summon); a ground enemy entering a sown tile is put back there once it is max_distance (4 / 3) tiles away — until the skill ends', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const others = [{ uid: 2, chessId: 't_a', row: 11, col: 7 }, { uid: 3, chessId: 'chess_char_3_19_a', row: 12, col: 3 },
{ uid: 4, kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 3, row: 11, col: 4 }];
const { h, u } = field({ tier, elite, skill: 2, others });
const a = h.unit(2), wolf = h.unit(4);
// sow row 9 cols 6-8 around (9, 7) with a heal of hers (an ally there for the moment)
h.b.relocate(a, 9, 7);
h.b.heal(u, a, 1);
h.b.relocate(a, 11, 7);
for (const [r, c] of [[9, 6], [9, 7], [9, 8], [10, 7]]) assert.ok(sownOf(u).has(key(r, c)), `T${tier}: ${r},${c} sown`);
a.hp = a.s.maxHp * 0.5;
u.skill.gainSp(999, 'init');
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
h.step();
assert.equal(a.findBuff('skill:shu:harvest'), null, `T${tier}: no enemy on a sown tile yet`);
const w = h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => sownOf(u).has(key(Math.round(w.y), Math.round(w.x))), 15), `T${tier}: it reaches the sown tiles`);
h.step(2);
const hb = a.findBuff('skill:shu:harvest');
assert.ok(hb, `T${tier}: an operator of her range`);
assert.deepEqual(hb.mods, { atkPct: sk.bb.e_atk, aspd: sk.bb.e_attack_speed }, `T${tier}: +${sk.bb.e_atk * 100} % / +${sk.bb.e_attack_speed}`);
assert.ok(u.findBuff('skill:shu:harvest'), `T${tier}: herself`);
assert.equal(wolf.findBuff('skill:shu:harvest'), null, `T${tier}: not a summon`);
const mark = u.mem.shuMarks.get(w.id);
assert.deepEqual([mark.r, mark.c], [9, 8], `T${tier}: marked with the first sown tile it entered`);
let farthest = 8, ported = false;
for (let i = 0; i < 600 && !ported; i++) {
h.step();
const c = Math.round(w.x);
if (c < farthest) farthest = c;
if (c === 8 && farthest < 8) ported = true;
}
assert.ok(ported, `T${tier}: put back on its sown tile`);
// the tile at max_distance is never seen after a tick: it is put back the tick it enters it
assert.equal(farthest, 8 - sk.bb.max_distance + 1, `T${tier}: on entering the tile ${sk.bb.max_distance} tiles away`);
assert.ok(h.eventsOf('fx').some((x) => x[1] === 'teleport'), `T${tier}: teleport fx`);
// after the skill: no more teleports
u.skill.end('test');
h.step();
assert.equal(u.mem.shuMarks, null, `T${tier}: the marks end with the skill`);
let reachedGoal = false;
for (let i = 0; i < 900 && !reachedGoal; i++) { h.step(); if (!w.alive || Math.round(w.x) <= 8 - sk.bb.max_distance - 1) reachedGoal = true; }
assert.ok(reachedGoal, `T${tier}: it walks on`);
done(h);
}
});
test('T1 百谷长青: her heals sow the healed unit\'s tile and the 4 next to it (walkable / deployable tiles only); units on them regenerate 70 / s (80 GUA-X stage 3) and take 10 % (15 %) 庇护 — a 无法被治疗 unit and summons too; all gone when she leaves; GUA-X stage 3 sows her tile at the deployment', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const others = [{ uid: 2, chessId: 't_a', row: 9, col: 5 }, { uid: 3, chessId: 't_b', row: 12, col: 9 }, { uid: 4, chessId: 'chess_char_3_19_a', row: 12, col: 3 },
{ uid: 5, kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 4, row: 10, col: 6 }];
const { h, u } = field({ tier, elite, mod, skill: 2, others });
const t0 = u.def.raw.talents.find((t) => t.index === 0);
const x3 = elite && mod === GUAX && tier === 6;
assert.deepEqual([t0.bb.hp_recovery_per_sec, t0.bb.damage_resistance, t0.bbStr?.born_range_id ?? null], x3 ? [80, 0.15, '0-1'] : [70, 0.1, null], label(f));
assert.equal(sownOf(u).has(key(10, 5)), x3, `${label(f)}: her own tile sown at the deployment (GUA-X stage 3 only)`);
const [a, b, wolf] = [h.unit(2), h.unit(3), h.unit(5)];
a.hp = a.s.maxHp * 0.5;
h.b.heal(u, a, 100);
const s = sownOf(u);
for (const [r, c] of [[9, 5], [10, 5], [9, 4], [9, 6]]) assert.ok(s.has(key(r, c)), `${label(f)}: ${r},${c}`);
assert.ok(!s.has(key(8, 5)), `${label(f)}: 8,5 is no walkable / deployable tile (A)`);
h.run(0.3);
assert.deepEqual(a.findBuff(FARM)?.mods, { hpRegen: t0.bb.hp_recovery_per_sec }, `${label(f)}: 生命回复速度`);
approx(a.findBuff('protect').data.value, t0.bb.damage_resistance, `${label(f)}: 庇护`);
approx(a.s.physTakenMul, 1 - t0.bb.damage_resistance, `${label(f)}: physical ×${1 - t0.bb.damage_resistance}`);
// a 无法被治疗 / 禁疗 unit still regenerates (an hpRegen attribute, no heal); a unit off the tiles gets nothing
h.b.addBuff(a, { key: 'test:noheal', flags: { noHeal: true, healFree: true } });
a.hp = a.s.maxHp * 0.5;
const hp0 = a.hp;
h.run(1);
approx(a.hp - hp0, t0.bb.hp_recovery_per_sec, `${label(f)}: +${t0.bb.hp_recovery_per_sec} HP in 1 s`, 0.05);
assert.equal(b.findBuff(FARM), null, `${label(f)}: off the tiles`);
// a summon (the 禁疗 wolf: no heal of hers reaches it) on a tile her heal of a neighbour sowed
h.b.relocate(b, 10, 7);
b.hp = b.s.maxHp * 0.5;
h.b.heal(u, b, 1);
assert.ok(sownOf(u).has(key(wolf.tileR, wolf.tileC)), `${label(f)}: the wolf's tile sown`);
h.run(0.3);
assert.ok(wolf.findBuff(FARM), `${label(f)}: a summon on a sown tile`);
// she leaves: every tile is cleared
h.b.retreat(u);
h.step();
assert.equal(a.findBuff(FARM), null, `${label(f)}: cleared at once`);
assert.equal(sownOf(u).size, 0, `${label(f)}: no sown tile left`);
h.run(0.5);
assert.equal(a.findBuff('protect'), null, `${label(f)}: 庇护 gone`);
done(h);
}
});
test('T2 天有四时: with her deployed, 3 professions on the field ⇒ every operator max HP +12 %, 3 operators of one profession ⇒ ASPD +12; 4 【岁】 in her squad ⇒ its operators ATK +12 % and 1 SP every 4 s, deployed or not', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
assert.deepEqual(t1, { max_hp: 0.12, attack_speed: 12, atk: 0.12, interval: 4, sp: 1 });
// 黍 (TANK) + 2 WARRIOR + 1 MEDIC: three professions; no three of one
{
const others = [{ uid: 2, chessId: 't_war', row: 12, col: 3 }, { uid: 3, chessId: 't_war2', row: 12, col: 5 }, { uid: 4, chessId: 't_med', row: 12, col: 7 }];
const { h, u } = field({ tier, elite, skill: 1, others });
h.run(0.3);
for (const x of [u, h.unit(2), h.unit(3), h.unit(4)]) {
assert.deepEqual(x.findBuff('talent:shu:seasons:hp')?.mods, { hpPct: t1.max_hp }, `T${tier}: max HP +12 % (${x.defId})`);
assert.equal(x.findBuff('talent:shu:seasons:aspd'), null, `T${tier}: no three of one`);
}
assert.equal(u.findBuff('talent:shu:sui'), null, `T${tier}: one 【岁】 only`);
h.b.retreat(u);
h.run(0.5);
assert.equal(h.unit(2).findBuff('talent:shu:seasons:hp'), null, `T${tier}: only while she is deployed`);
done(h);
}
// three WARRIOR: ASPD +12 (two professions only)
{
const others = [{ uid: 2, chessId: 't_war', row: 12, col: 3 }, { uid: 3, chessId: 't_war2', row: 12, col: 5 }, { uid: 4, chessId: 't_war3', row: 12, col: 7 }];
const { h, u } = field({ tier, elite, skill: 1, others });
h.run(0.3);
for (const x of [u, h.unit(2)]) {
assert.deepEqual(x.findBuff('talent:shu:seasons:aspd')?.mods, { aspd: t1.attack_speed }, `T${tier}: ASPD +12`);
assert.equal(x.findBuff('talent:shu:seasons:hp'), null, `T${tier}: two professions`);
}
done(h);
}
// four 【岁】: 黍, 重岳 (自选), 夕, 余 — and an operator whose SP comes only from gifts
{
const others = [{ uid: 2, diy: { slot: 6, charId: 'char_2024_chyue', skillIndex: 0 }, row: 12, col: 3 },
{ uid: 3, chessId: 'chess_char_5_12_a', row: 9, col: 3 }, { uid: 4, chessId: 'chess_char_6_03_a', row: 11, col: 3 },
{ uid: 5, chessId: 't_sp', row: 12, col: 8 }];
const { h, u } = field({ tier, elite, skill: 1, others });
h.step();
const sp = h.unit(5);
for (const x of [u, h.unit(2), h.unit(3), h.unit(4), sp]) assert.deepEqual(x.findBuff('talent:shu:sui')?.mods, { atkPct: t1.atk }, `T${tier}: ATK +12 % (${x.defId})`);
assert.equal(sp.skill.sp, 0);
h.run(3.9);
assert.equal(sp.skill.sp, 0, `T${tier}: nothing before 4 s`);
h.run(0.2);
assert.equal(sp.skill.sp, t1.sp, `T${tier}: +1 SP at 4 s`);
h.b.retreat(u);
h.run(4);
assert.equal(sp.skill.sp, 2 * t1.sp, `T${tier}: on without her deployed`);
done(h);
}
}
});
test('GUA-X “钦天司时”: her heals ×1.15 on a target at or below 50 % HP (stages 1 and 3), never above, never a regen tick; none without the module', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 't_a', row: 12, col: 9 }] });
const a = h.unit(2);
const on = elite && mod === GUAX;
if (on) assert.deepEqual(u.def.raw.trait.bb, { heal_scale: 1.15, hp_ratio: 0.5 }, label(f));
a.hp = a.s.maxHp * 0.5;
approx(h.b.heal(u, a, 1000), on ? 1150 : 1000, `${label(f)}: at 50 %`);
a.hp = a.s.maxHp * 0.51;
approx(h.b.heal(u, a, 1000), 1000, `${label(f)}: above 50 %`);
done(h);
}
});
+283
View File
@@ -0,0 +1,283 @@
// test/content/op_zumama.test.js — the 自选 operator kit of 森蚺 (char_416_zumama, 6★ 决战者; kit
// server/sim/content/kits/ops/op-zumama.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, HES-X
// “祖玛玛的工具箱”, HES-Y “小小的丑东西” or RA-A 森蚺特限证章 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_zumama.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const ZUMAMA = 'char_416_zumama';
const FORMS = BACKUPS.units[ZUMAMA].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const HESX = 'uniequip_002_zumama', HESY = 'uniequip_003_zumama', RAA = 'uniequip_004_zumama';
const S1 = 'skchr_zumama_1', S2 = 'skchr_zumama_2', S3 = 'skchr_zumama_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_tough: dummy('enemy_tough', { immunities: { stun: true } }),
enemy_frail: dummy('enemy_frail', { hp: 10 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, HESX, HESY, RAA].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 森蚺 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 1, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ZUMAMA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
/** An enemy she blocks: a still dummy put on her own tile (blocked on the next tick). */
function blockOne(h, u, key = 'enemy_dummy') {
const e = h.spawn(key, { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => e.blockedBy === u, 1), 'blocked');
return e;
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('森蚺 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 萨尔贡, no 特质; triggers from the data', () => {
assert.equal(OPERATOR_KITS[ZUMAMA], KITS[ZUMAMA]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [ZUMAMA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'melee', 'phys', false, 1.6], `${label(f)}: 决战者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['sargonShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
// the 重装 exception (the owner's decision of 2026-10-05): S2 / S3 cast in range (DEFAULT), the official TAKE_DAMAGE kept
const sk = form.skills[skill];
if (skill === 0) assert.deepEqual([u.skill.kind, sk.skillType, u.skill.spCost], ['passive', 'PASSIVE', 0], `${label(f)}: S1 passive`);
else assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, sk.skillType], ['DEFAULT', 'DEFAULT', 'TAKE_DAMAGE', 'MANUAL'], `${label(f)}: S${skill + 1} trigger`);
done(h);
}
}
// zh_CN numbers: E2 Lv1 3440 / 835 / 492, E2 Lv60 4121 / 949 / 574; HES-X +80/+70 → +105/+85 ATK/DEF, HES-Y +70/+80 →
// +85/+110, RA-A +280/+80 → +380/+130 HP/ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [3440, 835, 4121, 949]);
assert.deepEqual([modOf(5, HESX).attr, modOf(6, HESX).attr, modOf(5, HESY).attr, modOf(6, HESY).attr, modOf(5, RAA).attr, modOf(6, RAA).attr],
[{ atk: 80, def: 70 }, { atk: 105, def: 85 }, { atk: 70, def: 80 }, { atk: 85, def: 110 }, { maxHp: 280, atk: 80 }, { maxHp: 380, atk: 130 }]);
});
test('a 自选 pick: 森蚺 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(ZUMAMA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ZUMAMA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ZUMAMA, skillIndex: 2, uniEquipId: HESX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: ZUMAMA, skillIndex: 2, uniEquipId: HESX } } });
});
test('S1 轻型挂斧 (PASSIVE): ATK / DEF +14 % / +18 % from every deployment, no SP', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([sk.bb.atk, sk.bb.def], elite ? [0.18, 0.18] : [0.14, 0.14], `T${tier}`);
assert.ok(u.skill.active, `T${tier}: on from the deployment`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
h.b.retreat(u);
h.b.redeploy(u);
h.step();
assert.ok(u.skill.active, `T${tier}: on again after a redeploy`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK again`);
done(h);
}
});
test('S2 震慑劈砍 (data DEFAULT: casts with the enemy she blocks): 16 / 17 s, ATK +100 % / +130 %, interval 1.6 + 0.4 s, every enemy she blocks stunned past 抵抗 while it lasts; a stun immunity holds; all gone after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.spCost, sk.initSp], elite ? [17, 1.3, 0.4, 32, 12] : [16, 1, 0.4, 35, 11], `T${tier}`);
u.skill.gainSp(999, 'init');
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
h.b.applyStatus(e, 'resist', { duration: 999, value: 0.5 });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast once she blocks it`);
assert.equal(e.blockedBy, u);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 2.0, `T${tier}: 1.6 + 0.4 s`);
h.step(2);
assert.ok(e.s.flags.stun, `T${tier}: the blocked enemy is stunned`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.status === 'stun' && c.target === e && c.source === u);
assert.ok(stuns.length > 0 && stuns.every((c) => Math.abs(c.duration - 0.1) < 1e-9), `T${tier}: 抵抗 does not shorten it`);
h.run(5);
assert.ok(e.s.flags.stun && u.skill.active, `T${tier}: still stunned while blocked`);
// a new blockee mid-skill: the first one falls, a stun-immune one and a plain one come in turn
h.b.kill(e, u);
const tough = h.spawn('enemy_tough', { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => tough.blockedBy === u, 1));
h.step(3);
assert.ok(!tough.s.flags.stun, `T${tier}: 晕眩 immunity`);
h.b.kill(tough, u);
const late = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => late.blockedBy === u, 1));
h.step(2);
assert.ok(late.s.flags.stun, `T${tier}: an enemy blocked mid-skill is stunned too`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration), `T${tier}: ends`);
h.run(0.15);
assert.ok(!late.s.flags.stun, `T${tier}: the stun ends with the skill`);
approx(u.s.interval, 1.6, `T${tier}: interval back`);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
// the stunned enemies fought nobody meanwhile: her normal attacks hit them (physical, ground)
assert.ok(atkHits(h, u).some((c) => c.target === late && c.type === 'phys'), `T${tier}: she keeps attacking`);
done(h);
}
});
test('S3 钢铁意志 (data DEFAULT): 31 / 32 s, ATK +140 % / +170 %, DEF +90 % / +120 %, block 1 + 2, 生命回复速度 3 % max HP / s (禁疗-proof); 5 s self-stun after, blocking nobody meanwhile', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, HESX]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, mod, skill: 2 });
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.def, sk.bb.block_cnt, sk.bb.hp_recovery_per_sec_by_max_hp_ratio, sk.bb.stun], elite ? [32, 1.7, 1.2, 2, 0.03, 5] : [31, 1.4, 0.9, 2, 0.03, 5], `T${tier}`);
u.skill.gainSp(999, 'init');
const e = blockOne(h, u);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with the enemy she blocks`);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.05);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
assert.equal(u.s.blockCnt, 3, `T${tier}: block 1 + 2`);
approx(u.s.hpRegen, u.s.maxHp * 0.03, `T${tier}: 3 % max HP / s`);
// 生命回复速度, no heal: 禁疗 and 无法被治疗 do not stop it
h.b.addBuff(u, { key: 'test:healFree', flags: { healFree: true, noHeal: true } });
u.hp = u.s.maxHp * 0.5;
const hp0 = u.hp;
h.run(1);
approx(u.hp - hp0, u.s.maxHp * 0.03, `T${tier}: +3 % in 1 s under 禁疗`, 0.05);
h.b.removeBuff(u, 'test:healFree');
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1), `T${tier}: ends`);
const st = h.hooksOf('statusApplied').find((c) => c.status === 'stun' && c.target === u);
assert.ok(st, `T${tier}: self-stun`);
approx(st.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
h.step(2);
assert.ok(u.s.flags.stun && u.blocking.length === 0 && e.blockedBy !== u, `T${tier}: stunned, blocking nobody`);
assert.equal(u.s.blockCnt, 1, `T${tier}: block back to 1`);
h.run(sk.bb.stun + 0.2);
assert.ok(!u.s.flags.stun, `T${tier}: the stun is over`);
done(h);
}
});
test('T1 勇冠三军: above half HP every hit ×1.15 as an 攻击力倍率 (×1.23 with HES-Y stage 3), at or below half 20 % 庇护 (28 %) on physical / arts damage only — checked every 0.1 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const y3 = elite && mod === HESY && tier === 6;
assert.deepEqual([t0.hp_ratio, t0.atk_scale, t0.damage_resistance], y3 ? [0.5, 1.23, 0.28] : [0.5, 1.15, 0.2], label(f));
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC + 1] }); // in her 1-1, not blocked: no SP, no cast
h.run(0.2);
assert.equal(u.s.atkScaleMul, t0.atk_scale, `${label(f)}: above half`);
const n0 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).length > n0, 3));
const hit = atkHits(h, u)[n0];
approx(hit.amount, u.s.atk * t0.atk_scale, `${label(f)}: ×${t0.atk_scale} on a 0-DEF target`);
// half HP: 庇护, no ×1.15
u.hp = u.s.maxHp * 0.5;
h.run(0.14);
assert.equal(u.s.atkScaleMul, 1, `${label(f)}: at half: no bonus`);
const pb = u.findBuff('protect');
assert.ok(pb, `${label(f)}: 庇护`);
approx(pb.data.value, t0.damage_resistance, `${label(f)}: ${t0.damage_resistance * 100} %`);
const hit1 = (amount, type) => { u.hp = u.s.maxHp * 0.5; return h.b.dealDamage(e, u, { amount, type }); };
approx(hit1(400, 'arts'), 400 * (1 - t0.damage_resistance), `${label(f)}: arts`);
approx(hit1(300, 'true'), 300, `${label(f)}: true untouched`);
approx(hit1(u.s.def + 400, 'phys'), 400 * (1 - t0.damage_resistance), `${label(f)}: physical`);
// back above half: the bonus within one check, the 庇护 gone
u.hp = u.s.maxHp * 0.9;
h.run(0.25);
assert.equal(u.s.atkScaleMul, t0.atk_scale, `${label(f)}: above half again`);
assert.equal(u.findBuff('protect'), null, `${label(f)}: no 庇护`);
done(h);
}
});
test('trait 决战者: no module ⇒ 阻回 (no SP of any kind, gifts too) unless she blocks; HES-X ×0.2 / HES-Y ×0.001 / RA-A ×0 SP recovery unblocked (gifts land); T2 愈战愈勇 +0.2 / s blocking (HES-X stage 3 +0.55; RA-A stays +0.2); HES-Y ATK / DEF +15 % blocking', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const form = formOf(tier, elite);
const m = elite ? modOf(tier, mod) : null;
const ratio = m ? m.traitOverride.bb.sp_recover_ratio : null;
const t2 = elite && mod === HESX && tier === 6 ? 0.55 : 0.2;
assert.equal(ratio, { [HESX]: -0.8, [HESY]: -0.999, [RAA]: -1 }[mod] ?? null, label(f));
const init = skillOf(tier, elite, S2).initSp;
assert.ok(u.skill.sp >= init && u.skill.sp < init + 0.05, `${label(f)}: initSp at the deployment despite 阻回 (${u.skill.sp})`);
// unblocked
h.run(0.2);
const sp0 = u.skill.sp;
h.run(5);
const unblocked = u.skill.sp - sp0;
if (ratio == null) {
assert.ok(u.s.flags.noSp, `${label(f)}: 阻回`);
approx(unblocked, 0, `${label(f)}: no SP`);
assert.equal(u.skill.gainSp(3, 'talent'), 0, `${label(f)}: a gift refused`);
} else {
assert.ok(!u.s.flags.noSp, `${label(f)}: no 阻回 with a module`);
approx(u.s.spRecovery, 1 + ratio, `${label(f)}: ×${1 + ratio}`);
approx(unblocked, 5 * (1 + ratio), `${label(f)}: ${5 * (1 + ratio)} SP in 5 s`, 1e-3);
approx(u.skill.gainSp(3, 'talent'), 3, `${label(f)}: a gift lands`);
}
assert.equal(u.findBuff('trait:zumama:block'), null, `${label(f)}: nothing blocked`);
// blocking: full SP recovery + T2
u.skill.sp = 0;
blockOne(h, u);
h.step();
approx(u.s.spRecovery, 1 + t2, `${label(f)}: 1 + ${t2} / s blocking`);
const sp1 = u.skill.sp;
h.run(5);
approx(u.skill.sp - sp1, 5 * (1 + t2), `${label(f)}: SP while blocking`, 1e-3);
if (elite && mod === HESY) {
assert.deepEqual(u.findBuff('trait:zumama:block')?.mods, { atkPct: 0.15, defPct: 0.15 }, `${label(f)}: HES-Y`);
} else assert.equal(u.findBuff('trait:zumama:block'), null, `${label(f)}: no ATK / DEF trait`);
if (elite && mod === RAA && tier === 6) {
// the composed record carries the 生息演算-only 0.4 / s; the kit reads 愈战愈勇 from talentsBase
assert.equal(u.def.raw.talents.find((t) => t.index === 1).bb.sp_recovery_per_sec, 0.4, 'the sandbox part is in the record');
assert.equal(u.def.raw.talentsBase.find((t) => t.index === 1).bb.sp_recovery_per_sec, 0.2);
}
assert.equal(form.trait.desc, '只有阻挡敌人时才能够回复技力');
done(h);
}
});
test('RA-A 森蚺特限证章: only its attributes and its trait ratio apply here — no 生息演算 parts (S2 block stays 1, no 物理脆弱, no attraction)', () => {
for (const tier of [5, 6]) {
const { h, u } = field({ tier, elite: true, mod: RAA, skill: 1, row: 9, col: 5 });
const m = modOf(tier, RAA);
assert.match(m.traitOverride.moduleDesc, /生息演算/);
u.skill.gainSp(999, 'init');
const far = h.spawn('enemy_dummy', { pos: [12, 9] });
const e = blockOne(h, u);
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.equal(u.s.blockCnt, 1, `T${tier}: no 技能期间阻挡数+2`);
approx(e.s.physTakenMul, 1, `T${tier}: no 物理脆弱`);
approx(far.x, 9, `T${tier}: nobody pulled`);
done(h);
}
});
+345 -98
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_diy1_a=zumama/S1 5_diy2_a=zumama/S2 5_diy1_a=zumama/S3 5_diy2_b=zumama/S1 5_diy1_b=zumama/S2 5_diy2_b=zumama/S3 5_diy1_b=zumama/S1/002_zumama 5_diy2_b=zumama/S2/002_zumama 5_diy1_b=zumama/S3/002_zumama 5_diy2_b=zumama/S1/003_zumama 5_diy1_b=zumama/S2/003_zumama 5_diy2_b=zumama/S3/003_zumama",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m05",
"modeId": "mode_multi_normal",
@@ -1985,74 +1985,74 @@
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 2,
"killed": 5,
"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":5,"total":60,"perfect":false,"leaks":55,"leaksUncounted":0,"coins":0,"dmg":321707,"bossDmg":0,"heal":10404,"deaths":7,"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_diy1_a",0,null,545.31,1,75,0,15233,6335,0,74,0],
["p1",2,"op","chess_char_5_diy2_a",1,null,2006.6,1,76,0,24337,4873,0,75,3],
["p1",3,"op","chess_char_5_diy1_a",2,null,3440,1,77,0,1179,6880,0,26,0],
["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,8,0,42923,4121,0,109,0],
["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,10,1,68502,4121,0,101,4],
["p1",6,"op","chess_char_5_diy2_b",2,"none",4121,1,11,4,128679,10404,10404,127,3],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_zumama",738.94,1,52,0,40854,7503,0,87,0],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_zumama",4121,1,1,0,0,0,0,0,0],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_zumama",4121,1,3,0,0,0,0,0,0],
["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_zumama",4121,1,5,0,0,0,0,0,0],
["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_zumama",4121,1,7,0,0,0,0,0,0],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_zumama",4121,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},
"enemies": {"enemy_1045_hammer_2":[12,0,12,12431,26021],"enemy_1061_zomshd":[48,5,41,31806,382046]},
"hooks": [1,77,6900,817,817,2976,2976,36390,12,65,10,12,0,30526,3090,21,60,53,1,12,0,0,0],
"events": {"atk":817,"deploy":17,"die":12,"dmg":2976,"leak":53,"skill":19,"spawn":72,"status":22},
"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"
"snaps": "e50d699b fff33f51 74e64c52 4d9ccf25 7eb25844 db86a212 b697c387 8355d887 bfb274bc 5bbe1567 8cdf0aa4 279f4fd2 b16b8a31 c4fdf474 26f84dd0 6b440128 6641ed13 8afb6d38 022dda2a 00107ee9 54ee2dbe 97022e88 79460fd6"
},
"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": "5_diy1_b=zumama/S1/004_zumama 5_diy2_b=zumama/S2/004_zumama 5_diy1_b=zumama/S3/004_zumama 6_diy1_a=zumama/S1 6_diy2_a=zumama/S2 6_diy1_a=zumama/S3 6_diy2_b=zumama/S1 6_diy1_b=zumama/S2 6_diy2_b=zumama/S3 6_diy1_b=zumama/S1/002_zumama 6_diy2_b=zumama/S2/002_zumama 6_diy1_b=zumama/S3/002_zumama",
"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": 209.53,
"ticks": 6286,
"reason": "cleared",
"killed": 0,
"total": 60,
"killed": 4,
"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":4,"total":105,"perfect":false,"leaks":101,"leaksUncounted":0,"coins":0,"dmg":75459,"bossDmg":0,"heal":0,"deaths":15,"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_5_diy1_b",0,"uniequip_004_zumama",0,0,84,1,15047,8802,0,15,0],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_004_zumama",4401,1,7,0,0,0,0,0,0],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_004_zumama",4401,1,9,0,0,0,0,0,0],
["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,109,0,4213,10320,0,14,0],
["p1",5,"op","chess_char_6_diy2_a",1,null,3440,1,123,0,234,3440,0,5,0],
["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,73,1,12119,6880,0,17,0],
["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,68,0,5217,8242,0,14,0],
["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,4,1,22260,4121,0,19,1],
["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,6,1,10219,4121,0,13,0],
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_zumama",0,0,1,0,4028,4121,0,5,0],
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_zumama",0,0,3,0,1166,4121,0,2,0],
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_zumama",0,0,5,0,956,4121,0,6,0]
],
"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,4,47,1988,40132],"enemy_1174_duholy_2":[24,0,24,18921,3266],"enemy_1175_dushdo_2":[30,0,30,37379,32062]},
"hooks": [1,123,6286,212,212,212,212,0,19,120,1,9,0,4860,510,19,105,101,1,19,0,0,0],
"events": {"atk":212,"deploy":18,"die":19,"dmg":212,"leak":101,"skill":2,"spawn":117,"status":163},
"fx": {},
"snaps": "4eb4081c f27d705e 12a5e103 ebd0ced8 c0af4450 f8987622 9804172d 98431192 d5001675 04f3813d c7edb47e 7cb6f5bb e1d82438 31f8dc00 b31c38fe 9e6c2ead 988fa49c 6362d373 55cdf373 91661d82 7d853e19"
},
"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=judge/S1 5_diy2_a=judge/S2 5_diy1_a=judge/S3 5_diy2_b=judge/S1 5_diy1_b=judge/S2 5_diy2_b=judge/S3 6_diy1_b=zumama/S1/003_zumama 6_diy2_b=zumama/S2/003_zumama 6_diy1_b=zumama/S3/003_zumama 6_diy2_b=zumama/S1/004_zumama 6_diy1_b=zumama/S2/004_zumama 6_diy2_b=zumama/S3/004_zumama",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m07",
@@ -2060,47 +2060,47 @@
"round": 13,
"seed": 3864737019,
"waveId": "act1autochess_h06",
"time": 204.8,
"ticks": 6144,
"time": 202.97,
"ticks": 6089,
"reason": "cleared",
"killed": 197,
"total": 290,
"killed": 333,
"total": 345,
"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":333,"total":345,"perfect":false,"leaks":12,"leaksUncounted":0,"coins":0,"dmg":588871,"bossDmg":0,"heal":2878,"deaths":0,"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,2960,1,4,10,79,0,0,64,20],
["p1",2,"op","chess_char_5_diy2_a",1,null,2960,1,6,20,138,0,0,40,3],
["p1",3,"op","chess_char_5_diy1_a",2,null,2960,1,8,22,772,0,0,90,0],
["p1",4,"op","chess_char_5_diy2_b",0,"none",3686,1,10,30,16779,0,0,108,37],
["p1",5,"op","chess_char_5_diy1_b",1,"none",3686,1,12,133,85616,0,0,71,4],
["p1",6,"op","chess_char_5_diy2_b",2,"none",3686,1,13,46,256155,0,0,98,4],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_003_zumama",1825.88,1,14,25,139596,2295,0,124,0],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_003_zumama",4121,1,3,2,18,0,0,18,0],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_003_zumama",4121,1,5,7,33,0,0,33,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_004_zumama",4501,1,7,15,48,0,0,48,0],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_004_zumama",4161.84,1,9,10,8735,339,0,55,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_004_zumama",4501,1,11,13,80901,2878,2878,76,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]
"enemy_1195_sfyin": [39,39,0,979,145567],
"enemy_1196_msfyin": [78,77,1,0,155],
"enemy_1197_sfshu": [48,48,0,1141,223949],
"enemy_1198_msfshu": [144,143,1,0,431],
"enemy_1199_sfjin": [18,18,0,3393,218350],
"enemy_1200_msfjin": [18,8,10,0,419]
},
"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":48,"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"
"hooks": [1,359,6089,1244,1244,2075,2075,645,333,345,70,68,0,32323,4,106,345,12,1,333,0,0,0],
"events": {"atk":1244,"deploy":12,"die":333,"dmg":1817,"fx":658,"leak":12,"skill":138,"spawn":359,"status":14},
"fx": {"aoe":136,"crit":5,"shield":154,"summon":105,"thorns":258},
"snaps": "480b65b5 19a0bccf 9c4c613e ada5b78a bdff8e84 b8d469f6 f336d7de 09ddcbef a4e6acba fbbbb637 0b162d9c 9e4a3728 1d7bc70c bd0bb3d2 112713bd 932890d6 70405bff ca609a0e 121cd1f9 bc0b661a 25692e57"
},
"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": "5_diy1_b=judge/S1/002_judge 5_diy2_b=judge/S2/002_judge 5_diy1_b=judge/S3/002_judge 5_diy2_b=judge/S1/003_judge 5_diy1_b=judge/S2/003_judge 5_diy2_b=judge/S3/003_judge 6_diy1_a=judge/S1 6_diy2_a=judge/S2 6_diy1_a=judge/S3 6_diy2_b=judge/S1 6_diy1_b=judge/S2 6_diy2_b=judge/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m01",
@@ -2108,35 +2108,282 @@
"round": 13,
"seed": 1210502478,
"waveId": "act1autochess_h06",
"time": 218.57,
"ticks": 6557,
"time": 228.33,
"ticks": 6850,
"reason": "cleared",
"killed": 0,
"killed": 17,
"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":17,"total":123,"perfect":false,"leaks":106,"leaksUncounted":0,"coins":0,"dmg":814321,"bossDmg":0,"heal":0,"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_5_diy1_b",0,"uniequip_002_judge",0,0,111,0,12835,7672,0,9,2],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_judge",0,0,130,1,126079,7672,0,7,1],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_judge",3836,1,141,0,5437,3836,0,5,0],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_judge",0,0,4,0,8022,3686,0,5,2],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_judge",0,0,9,0,98776,3686,0,1,1],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_judge",0,0,5,1,47609,3686,0,13,1],
["p1",7,"op","chess_char_6_diy1_a",0,null,0,0,82,0,17759,5920,0,16,4],
["p1",8,"op","chess_char_6_diy2_a",1,null,0,0,1,0,6789,2960,0,0,1],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,2,0,656,2960,0,1,0],
["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,6,0,8755,3686,0,5,2],
["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,74,0,8632,7372,0,7,0],
["p1",12,"op","chess_char_6_diy2_b",2,"none",3686,1,72,15,472972,3686,0,78,3]
],
"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":8,"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,15,42,4040,485283],"enemy_1299_ymkilr_2":[27,0,27,48725,117616],"enemy_1422_lrsldr_2":[39,2,37,4057,211421]},
"hooks": [1,141,6850,786,786,1585,1585,0,33,139,17,17,0,5543,4,62,123,106,1,33,0,0,0],
"events": {"atk":786,"deploy":18,"die":33,"dmg":1150,"fx":534,"leak":106,"skill":34,"spawn":135,"status":44},
"fx": {"aoe":39,"crit":4,"shield":39,"thorns":452},
"snaps": "85336f70 15ebb4f8 e27a3972 5b05e977 964e71a7 17c8c498 27fe59a4 3c4e9edb 46175bdc cbb0d18a 3a0ff874 82b99fce 6171a56d f8de78dd aabfedb5 ce9c3684 d28551db 22d6ef23 fe161e7f 8432bd16 ac3231ca 78b66a51 0bf9b3c1"
},
"diy-053": {
"about": "6_diy1_b=judge/S1/002_judge 6_diy2_b=judge/S2/002_judge 6_diy1_b=judge/S3/002_judge 6_diy2_b=judge/S1/003_judge 6_diy1_b=judge/S2/003_judge 6_diy2_b=judge/S3/003_judge 5_diy1_a=shu/S1 5_diy2_a=shu/S2 5_diy1_a=shu/S3 5_diy2_b=shu/S1 5_diy1_b=shu/S2 5_diy2_b=shu/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m02",
"modeId": "mode_multi_funny",
"round": 13,
"seed": 2850577873,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 99,
"total": 114,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":99,"total":114,"perfect":false,"leaks":15,"leaksUncounted":0,"coins":0,"dmg":839661,"bossDmg":0,"heal":16410,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_judge",3986,1,3,0,0,0,0,0,0],
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_judge",3986,1,4,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_judge",3986,1,5,0,0,0,0,0,0],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_judge",3686,1,7,2,27858,0,0,17,7],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_judge",3686,1,11,33,289297,0,0,62,4],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_judge",3686,1,13,55,458054,0,0,121,4],
["p1",7,"op","chess_char_5_diy1_a",0,null,2301.12,1,14,1,9717,4624,4612,207,1],
["p1",8,"op","chess_char_5_diy2_a",1,null,2305.79,1,8,0,1488,4204,3956,91,4],
["p1",9,"op","chess_char_5_diy1_a",2,null,2313,1,12,1,12581,5038,4654,222,3],
["p1",10,"op","chess_char_5_diy2_b",0,"none",2910,1,9,3,16757,1484,1269,93,0],
["p1",11,"op","chess_char_5_diy1_b",1,"none",2903.49,1,10,0,702,1086,1199,39,2],
["p1",12,"op","chess_char_5_diy2_b",2,"none",2910,1,6,4,23207,0,720,99,4],
["-","~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,24,0,4454,133632],"enemy_1381_winman_2":[48,44,0,5321,330476],"enemy_1387_winshd":[39,31,0,6660,375553]},
"hooks": [1,128,6900,2354,2354,2853,2853,6036,99,99,29,28,0,19829,2,113,114,0,1,99,0,0,0],
"events": {"atk":2360,"deploy":12,"die":99,"dmg":2504,"fx":538,"heal":128,"skill":57,"spawn":128,"status":9},
"fx": {"aoe":80,"crit":2,"healField":14,"shield":93,"thorns":349},
"snaps": "a1804ed0 6e1f8881 cb71b3fe 7183a61b de629b62 8eb2f85b 02beb5c8 2f6f897d 8a723304 13b316ca 3dad4830 f0a40c6c b9448612 1edb97a9 97a1869d 364d6c22 d384e689 c20a1b23 9f9f6541 79a07380 41ff316d df034031 a4a58a77"
},
"diy-054": {
"about": "5_diy1_b=shu/S1/002_shu 5_diy2_b=shu/S2/002_shu 5_diy1_b=shu/S3/002_shu 6_diy1_a=shu/S1 6_diy2_a=shu/S2 6_diy1_a=shu/S3 6_diy2_b=shu/S1 6_diy1_b=shu/S2 6_diy2_b=shu/S3 6_diy1_b=shu/S1/002_shu 6_diy2_b=shu/S2/002_shu 6_diy1_b=shu/S3/002_shu",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m03",
"modeId": "mode_single_hard",
"round": 13,
"seed": 196461732,
"waveId": "act1autochess_h06",
"time": 229.47,
"ticks": 6884,
"reason": "cleared",
"killed": 0,
"total": 66,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":66,"perfect":false,"leaks":66,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":66233,"deaths":18,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shu",3070,1,91,0,0,3844,774,0,0],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shu",3070,1,90,0,0,10337,3371,0,0],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_shu",3070,1,10,0,0,0,22156,39,3],
["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,60,0,0,9980,674,0,0],
["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,57,0,0,24040,3795,0,0],
["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,67,0,0,7853,6318,11,1],
["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,64,0,0,9827,1323,0,0],
["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,84,0,0,8730,0,0,0],
["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,5,0,0,3344,7037,15,1],
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_shu",0,0,86,0,0,22809,3390,0,0],
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_shu",3180,1,9,0,0,0,0,0,0],
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_shu",0,0,62,0,0,15665,17395,24,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],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai_2":[12,0,12,2003,0],"enemy_1041_lazerd_2":[24,0,24,114427,0],"enemy_1042_frostd":[30,0,30,0,0]},
"hooks": [1,91,6884,484,484,390,390,7117,18,84,7,7,0,10950,0,0,66,66,1,18,0,0,0],
"events": {"atk":484,"deploy":22,"die":18,"dmg":390,"fx":20,"heal":88,"leak":66,"skill":14,"spawn":81,"status":48},
"fx": {"healField":20},
"snaps": "f04fb9cf 0516108d 04b708fd 76eed263 ac69ca8f 840db29d e2c129db 23c826b8 4dfaf782 b14af258 5fc2160c 2c69b3bd a1718c78 1dd0b441 129f07c0 6526dab9 f46d2b09 b032c87a 6fff1120 371ca359 e7afd5c6 ff07b06c cb476ff1"
},
"diy-055": {
"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_m04",
"modeId": "mode_multi_normal",
"round": 12,
"seed": 1597707536,
"waveId": "act1autochess_h05",
"time": 226.43,
"ticks": 6793,
"reason": "cleared",
"killed": 8,
"total": 117,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":8,"total":117,"perfect":false,"leaks":109,"leaksUncounted":0,"coins":0,"dmg":52598,"bossDmg":0,"heal":8900,"deaths":25,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,117,0,0,6078,0,0,0],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,111,0,0,7823,0,0,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,144,0,0,5128,0,0,0],
["p1",4,"op","chess_char_5_diy2_b",2,"none",208.49,1,145,0,0,10902,0,0,0],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,132,0,18064,2542,0,48,1],
["p1",6,"op","chess_char_5_diy2_b",2,"none",868.62,1,146,5,21138,3661,0,44,1],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,86,1,660,2480,0,2,0],
["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,79,1,4810,2924,0,9,0],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,5,0,0,1218,5678,12,1],
["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,92,0,0,4841,3223,7,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,142,0,3950,2796,0,11,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,135,1,3976,4845,0,9,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_1005_yokai":[51,8,43,0,18454],"enemy_1005_yokai_2":[54,0,54,21039,2515],"enemy_1005_yokai_3":[12,0,12,34200,31629]},
"hooks": [1,146,6793,492,492,425,425,19,33,142,4,4,0,11479,18,0,117,109,1,33,0,0,0],
"events": {"atk":492,"deploy":27,"die":33,"dmg":425,"heal":19,"leak":109,"skill":8,"spawn":131,"status":28},
"fx": {},
"snaps": "e90d8ac5 3e7826d8 2ea79392 0d0a30f6 55810e9b a62c5a87 b1c5cfca 6959234f 84a973da 3386636e fa19929f 31ac9ec4 cc1b23bc 0ce6593e 1aa312f9 8de415cc 7670e151 f5f13c6f 12c4e5a4 75fb7a93 385d158a f1ce79bb 455d3b6d"
},
"diy-056": {
"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": "act1autochess_m01",
"modeId": "mode_multi_hard",
"round": 12,
"seed": 3238296381,
"waveId": "act1autochess_h05",
"time": 208.03,
"ticks": 6241,
"reason": "cleared",
"killed": 3,
"total": 75,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 196,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":3,"total":75,"perfect":false,"leaks":72,"leaksUncounted":0,"coins":0,"dmg":162615,"bossDmg":0,"heal":9891,"deaths":23,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,7,0,249,2808,0,3,0],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,9,1,600,3371,0,5,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,97,1,12118,4587,0,5,1],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,101,0,24851,5712,0,11,1],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,68,0,6186,5276,0,8,0],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,8,0,35092,8965,0,13,1],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,75,0,17393,2748,0,33,1],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,84,1,48820,3924,0,40,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,61,0,8325,3344,0,4,0],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,55,0,8980,3498,0,4,0],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,93,0,0,3198,8062,15,1],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,102,0,0,4657,1829,3,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],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1267_nhpbr_2":[36,2,34,5542,68908],"enemy_1270_nhstlk_2":[27,1,26,12814,85923],"enemy_1272_nhtank_2":[12,0,12,33732,7784]},
"hooks": [1,102,6241,222,222,529,526,32,26,98,6,6,0,8985,0,21,75,72,1,56,0,0,3],
"events": {"atk":261,"deploy":24,"die":26,"dmg":526,"fx":42,"heal":32,"leak":72,"skill":12,"spawn":90,"status":32},
"fx": {"dodge":3,"dp":3,"slash":16,"spGain":2,"zone":18},
"snaps": "4a66aa50 cd957f8d 29040588 85dbfdfb 24a06993 403a5cd0 af8c7db4 ece6f6e6 e208e92d b00a9483 cdbb6067 b86efe8c 4d65b9e6 9e9b8ccc 4dd884e0 de6f0bb5 1ee54cb6 187ba789 edc6ec98 27566532 7101f464"
},
"diy-057": {
"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_m02",
"modeId": "mode_single_normal",
"round": 13,
"seed": 583790378,
"waveId": "act1autochess_h06",
"time": 229.57,
"ticks": 6887,
"reason": "cleared",
"killed": 25,
"total": 147,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 349,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":25,"total":147,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":447188,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,144,0,5014,5208,0,37,1],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,1,0,7239,3431,0,80,2],
["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,143,0,12621,3730,0,59,1],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,139,5,129318,4748,0,83,2],
["p1",5,"op","chess_char_6_diy1_a",2,null,1529,1,176,2,19391,3058,0,28,2],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1904,1,174,8,121508,3808,0,33,4],
["p1",7,"op","chess_char_5_diy1_a",2,null,1154,1,165,0,5198,6924,0,54,0],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,171,0,7386,4344,0,52,0],
["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,177,2,17400,2638,0,21,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,146,6,70277,7062,0,82,2],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,6,0,21302,0,0,139,3],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,8,2,30532,0,0,150,3]
],
"enemies": {"enemy_1166_dusbr_2":[39,10,29,2081,102398],"enemy_1169_duphlx_2":[57,9,48,10670,156040],"enemy_1170_dushld_2":[51,6,45,32200,188750]},
"hooks": [1,177,6887,1087,1087,1627,1734,0,48,170,21,28,0,26130,14208,52,147,122,1,48,0,0,14],
"events": {"atk":1145,"deploy":30,"die":48,"dmg":1705,"fx":155,"leak":122,"skill":40,"spawn":159,"status":1199},
"fx": {"aoe":51,"displace":8,"dodge":32,"dp":16,"slash":48},
"snaps": "3c92533d c853aee7 5b1063fd 5fc57b7e 4a05e8e6 38a71bec 3070ca8c d432fa88 49683316 30a3854a 9104d2b4 923ae15a 22aa5459 cdd051aa 9f802034 82bafa84 ccf4b89d e3e17beb 6eb62b58 fe7abec6 989ccd10 d648552b 98e9bf71"
},
"diy-058": {
"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_m03",
"modeId": "mode_multi_abyss",
"round": 13,
"seed": 2224511831,
"waveId": "act1autochess_h06",
"time": 208.07,
"ticks": 6242,
"reason": "cleared",
"killed": 2,
"total": 105,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 148,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":2,"total":105,"perfect":false,"leaks":103,"leaksUncounted":0,"coins":0,"dmg":240102,"bossDmg":0,"heal":14281,"deaths":30,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,5,0,14676,1865,0,12,1],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,7,1,65680,3799,0,21,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,140,0,19129,12041,0,30,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,139,0,38020,15591,0,35,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,131,0,5981,1272,0,26,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,12,0,8584,1643,0,26,0],
["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,78,0,5748,4303,0,7,0],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,4,1,75199,1819,0,10,1],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,6,0,0,2056,5589,7,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,9,0,0,5567,8692,11,1],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,13,0,3038,1160,0,13,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,73,0,4048,5494,0,11,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_1229_darmy":[27,1,26,5045,88057],"enemy_1229_darmy_2":[57,1,56,19387,116235],"enemy_1275_dwlock_2":[21,0,21,32176,35810]},
"hooks": [1,140,6242,294,294,501,589,31,32,135,6,24,0,7426,388,24,105,103,1,32,0,4,3],
"events": {"atk":328,"deploy":32,"die":32,"dmg":541,"fx":78,"heal":27,"leak":103,"skill":12,"spawn":120,"status":178},
"fx": {"aoe":8,"beam":24,"burst":4,"displace":25,"dodge":5,"spGain":12},
"snaps": "c9dff647 505536a6 046d3d99 29e1ce4f 95f692ed cb471e6e 3e929e64 72083a81 77cee0d9 d920f33c d26b1231 10d91924 2bc3222a 3b3d8eb1 a41709a6 a48f0d23 774d80e5 2256086c dbd65fc9 29f0c70c fa23ead3"
}
}
}