From 10bb22638245ddf4a7e3d242a4b2f2ccb2efb123 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 01:30:05 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20O15:=20=E8=87=AA=E9=80=89=20operato?= =?UTF-8?q?r=20kits=20=E6=A3=AE=E8=9A=BA,=20=E6=96=A5=E7=BD=AA=20and=20?= =?UTF-8?q?=E9=BB=8D=20=E2=80=94=20every=20skill,=20talent,=20trait=20and?= =?UTF-8?q?=20module=20(stage=201=20/=203)=20at=20every=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/research/03-operators.md | 2 +- server/sim/content/kits/index.js | 3 + server/sim/content/kits/ops/op-judge.js | 196 ++++++++++ server/sim/content/kits/ops/op-shu.js | 289 +++++++++++++++ server/sim/content/kits/ops/op-zumama.js | 125 +++++++ test/content/op_judge.test.js | 314 ++++++++++++++++ test/content/op_shu.test.js | 370 +++++++++++++++++++ test/content/op_zumama.test.js | 283 +++++++++++++++ test/golden/diy.json | 443 ++++++++++++++++++----- 9 files changed, 1926 insertions(+), 99 deletions(-) create mode 100644 server/sim/content/kits/ops/op-judge.js create mode 100644 server/sim/content/kits/ops/op-shu.js create mode 100644 server/sim/content/kits/ops/op-zumama.js create mode 100644 test/content/op_judge.test.js create mode 100644 test/content/op_shu.test.js create mode 100644 test/content/op_zumama.test.js diff --git a/docs/research/03-operators.md b/docs/research/03-operators.md index 802a6b81..5038c3a4 100644 --- a/docs/research/03-operators.md +++ b/docs/research/03-operators.md @@ -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): diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index 99f513f5..f27cbac0 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -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', ]); /** diff --git a/server/sim/content/kits/ops/op-judge.js b/server/sim/content/kits/ops/op-judge.js new file mode 100644 index 00000000..f615ce74 --- /dev/null +++ b/server/sim/content/kits/ops/op-judge.js @@ -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 }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-shu.js b/server/sim/content/kits/ops/op-shu.js new file mode 100644 index 00000000..86584c39 --- /dev/null +++ b/server/sim/content/kits/ops/op-shu.js @@ -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 }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-zumama.js b/server/sim/content/kits/ops/op-zumama.js new file mode 100644 index 00000000..8d955841 --- /dev/null +++ b/server/sim/content/kits/ops/op-zumama.js @@ -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 }); + }, + }; + }, +}; diff --git a/test/content/op_judge.test.js b/test/content/op_judge.test.js new file mode 100644 index 00000000..132bed8e --- /dev/null +++ b/test/content/op_judge.test.js @@ -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); + } +}); diff --git a/test/content/op_shu.test.js b/test/content/op_shu.test.js new file mode 100644 index 00000000..6157d299 --- /dev/null +++ b/test/content/op_shu.test.js @@ -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); + } +}); diff --git a/test/content/op_zumama.test.js b/test/content/op_zumama.test.js new file mode 100644 index 00000000..09d16281 --- /dev/null +++ b/test/content/op_zumama.test.js @@ -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); + } +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index 0793c28c..de19a707 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -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" } } }