diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index a3229fcc..37db701c 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -115,6 +115,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([ 'op-cqbw.js', 'op-phenxi.js', 'op-wisdel.js', + 'op-thorns.js', + 'op-chen3.js', + 'op-leizi2.js', ]); /** diff --git a/server/sim/content/kits/ops/op-chen3.js b/server/sim/content/kits/ops/op-chen3.js new file mode 100644 index 00000000..ecef66f0 --- /dev/null +++ b/server/sim/content/kits/ops/op-chen3.js @@ -0,0 +1,302 @@ +// server/sim/content/kits/ops/op-chen3.js — 赤刃明霄陈 (char_1050_chen3) 自选 operator kit: 6★ 术战者 (近卫), an owned-6★ +// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (AFT-X) at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_1050_chen3, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, AFT-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); the client's battle +// logic — buff_template_data (chen3_t2 / [timer] / [evade], chen3_s1[derived_silence], chen3_s2[dead_listener] / +// [record_position] / [try_resapwn] / [respawn_buff], chen3_s3[ensure_dmg] / [finish_projectile]), her charpack (talent 1 +// chen3_t1 + common_weak_damage; S1 attack `_additionalTimes` 1; S3 attack `_additionalTimes` 2, ground selector), the +// skill prefabs (skchr_chen3_2 S2MultiMeleeAttack: `_triggerDelta` 0.4, `s2_extend_time` 0.4 / `s2_times_add_one` 1 per +// lost target; skchr_chen3_3: the sword wave) and projectile_chr_chen3_s3 (speed 1.5, no lifetime of its own — ended by +// the skill —, `_hitDistance` 0.25, turns at high ground / map edge / 侵入点 / 保护目标, clears its hit list when it turns) +// — and PRTS 赤刃明霄陈 (talent 1 修正 "造成的物理和法术伤害变为弱点伤害"; talent 2 备注 "闪避优先级为-1000,不可叠加…二技能 +// 移动时可继承闪避效果"; S2 / S3 备注), Terra wiki Ch'en the Dawnstreak (the wave's enemy hitbox 1.3). +// - Trait (术战者) "攻击造成法术伤害": the artsfghter profile (melee arts, ground only), range 1-1, blocks 1. Module AFT-X +// “记忆残页” adds "未阻挡敌人时攻击速度+8" (trait attack_speed): ASPD + while she blocks nobody. +// - T1 形意洞照 "攻击力+13%,攻击速度+13,造成的物理和法术伤害变为弱点伤害" (PRTS 修正): a permanent ATK / ASPD buff, and +// every physical or arts damage instance she deals becomes 弱点伤害 (ba.weaknessatk "根据目标防御力和法术抗性变更伤害类 +// 型"): the type the target resists less (items/battle.js weaknessRetype, as 双模机械臂 / 陈's band 以己之长). +// - T2 寒暑觉知 "未受到伤害时,每N秒随机治疗自身一定(攻击力的X%~Y%)生命值,并闪避下次物理与法术攻击": a counter +1 every +// second (chen3_t2[timer]: triggerInterval 1, the first a second in), back to 0 with every damage instance she takes +// (ON_APPLIED_MODIFIER IsDamage: not a 流失, not an element fill; a dodged hit lands nothing); at stack_time it restarts and +// she heals herself ATK × a random whole percent in [heal_atk_scale_min, heal_atk_scale_max) (RandomSetter, PRTS +// "最小单位为1%") and holds one dodge (chen3_t2[evade]: STACK, maxStackCnt 1 — "不可叠加") that cancels the next physical +// or arts damage instance that can be dodged (Evade PHYSICAL_AND_MAGICAL), then is spent. Its priority is −1000 (PRTS; +// onEventPriority LOW_PRIORITY): her other dodges (S2's) are rolled first, and the held dodge is kept when one of them +// wins. S2's 【移动】 keeps it (PRTS "二技能移动时可继承闪避效果") — and the counter, as the engine's 【移动】 keeps the rest +// (DESIGN §22.3). AFT-X stage 2+: the module talent's numbers (stage 3: 6 s, 50 %–200 %). +// - S1 赤霄·奔夜 (MANUAL, data DEFAULT): ATK +atk for `duration` s, every attack hits twice (二连击) and 特殊能力失效 — 沉默 — +// its target until the skill ends (chen3_s1[derived_silence]: derived from the skill's holder buff, independentCharacter +// Source): the status for the skill's time left; an early end (she leaves) lifts the ones she set. +// - S2 赤霄·绝影-驰 (MANUAL, 技能范围 x-1, data SKILL_RANGE): she slashes the nearest enemy she can select in the x-1 (air units +// too) `times` (10) times, 0.4 s apart, for atk_scale × ATK arts each (the first at the cast [ASSUMED], so the slashing lasts +// 0.4 s × its slashes); a target that leaves the field (or hides [ASSUMED]) hands the slashes to the selectable enemy +// nearest it within 1.7 of it, else in her x-1, and adds one slash (+0.4 s); with nobody to select the slashing ends. +// Meanwhile (PRTS): 无敌, 不可阻挡 (she blocks nothing), 阻回, 静默, stun / freeze immunity, no normal attack. Then always a +// 【移动】 (Battle.moveRedeploy): onto the tile its last target stood on at its last slash (record_position) when that target +// still stands and the tile takes a melee operator, else back onto her own tile (PRTS "否则返回原位置"); the move's own +// travel and deploy clip (Start_2, 1 s) are not modelled [ASSUMED: instant, as every deployment of the engine]. From +// then the `duration` (6 s, "不包括斩击所耗时间"): ATK +respawn_buff.atk and respawn_buff.prob physical and arts dodge. +// - S3 赤霄·天喟 (MANUAL, data ACTIVE_RANGE on its 3-12): range 3-12, every attack hits up to attack@max_target ground enemies +// three times (`_additionalTimes` 2) for attack@atk_scale × ATK arts, `duration` s. At the cast a sword wave leaves her +// tile in her direction at 1.5 tiles/s and roams until the skill ends (FinishManagedProjectiles) or she leaves: when its +// leading edge (0.25 ahead) would enter a high tile, the map's edge — the battle's field [ASSUMED: a battle's map is its +// field, the stage tiles beyond it are another player's half] —, an 侵入点 or a 保护目标 it turns 90° clockwise (four +// turns at most per tick; boxed in on every side it waits) and forgets whom it hit; each enemy it can select within 1.3 +// (air too) is hit once per straight run for the larger of hp_ratio × its current HP (the unit's own HP, PRTS) and +// projectile_min_atk_scale × her ATK at the hit, arts (RES applies — PRTS "非伤害保底或无视法术抗性"; 普通伤害). Her +// begin clips (S1 0.2 s, S3 0.433 s) are not modelled [ASSUMED: the engine starts skills at once]. + +import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js'; +import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js'; +import { bodyDist } from '../../../body.js'; +import { isHpLoss } from '../../../damage.js'; +import { weaknessRetype } from '../../items/battle.js'; + +const S1 = 'skchr_chen3_1'; +const S2 = 'skchr_chen3_2'; +const S3 = 'skchr_chen3_3'; +/** The skill ranges when a record carries none (data: x-1, 3-12). */ +const X_1 = 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 R3_12 = Object.freeze([[1, 0], [1, 1], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1]]); +/** skchr_chen3_2 S2MultiMeleeAttack `_triggerDelta` / dead_listener `s2_extend_time`: the time per slash. */ +const SLASH_IV = 0.4; +/** PRTS S2 备注: a lost target hands over to "该目标半径1.7范围内的另一个目标". */ +const RETARGET_RADIUS = 1.7; +/** projectile_chr_chen3_s3: speed; its terrain probe (`_hitDistance`); Terra wiki: its enemy hitbox. */ +const WAVE_SPEED = 1.5; +const WAVE_PROBE = 0.25; +const WAVE_HIT_RADIUS = 1.3; +/** S1's early end: a silence of hers that ends within this of the skill's planned end ended with it. */ +const SILENCE_SLACK = 0.1; +const AIR = Object.freeze({ canHitFly: true }); +const DASH_KEY = 'skill:chen3:dash'; +const AFTER_KEY = 'skill:chen3:respawn'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** Absolute tile keys of a skill range at the unit's tile and facing (a 技能范围 ignores 攻击距离). */ +const keysOf = (unit, grid) => absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0); +/** An S2 target that left the field — dead, removed or hidden [ASSUMED: a hidden one counts as gone]. */ +const gone = (e) => !e || !e.alive || e.removed || e.hidden; +/** The enemy of `list` nearest to (x, y) (body distance; ties by spawn order), or null. */ +function nearest(list, x, y) { + let best = null, bd = Infinity; + for (const e of list) { + const d = bodyDist(e, x, y); + if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; } + } + return best; +} +/** A tile S2's 【移动】 may land on: in the field, melee-deployable, free (her own tile aside), no operator lying there. */ +function landable(battle, unit, r, c) { + if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c) || !battle.grid.canStand(r, c)) return false; + if (battle.downOn(r, c)) return false; + return (r === unit.tileR && c === unit.tileC) || !battle.isReservedTile(r, c); +} +/** + * S3's wave turns before a high tile, the map's edge — the battle's field (`rect`): the official map of a battle is that + * field, the stage tiles beyond it (another player's half) are not part of it —, an 侵入点 or a 保护目标. + */ +function waveBlocked(battle, w) { + const r = Math.round(w.y + w.dr * WAVE_PROBE), c = Math.round(w.x + w.dc * WAVE_PROBE); + if (!battle.grid.inRect(r, c)) return true; + const t = battle.grid.tile(r, c); + return t.height === 'HIGH' || t.special === 'start' || t.special === 'end'; +} + +export default { + char_1050_chen3: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const tb = traitBb(chess); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const grid2 = skillRec(chess, S2)?.rangeGrid ?? X_1, grid3 = skillRec(chess, S3)?.rangeGrid ?? R3_12; + const slashes = Math.max(1, Math.floor(num(b2.times, 10))); + + /** S2: the selectable enemies (air too) she may slash next — around the lost target, else in her x-1. */ + const nextTarget = (battle, unit, from) => { + const near = battle.foesInRadius(from.x, from.y, RETARGET_RADIUS).filter((e) => e !== from.e && canTargetEnemy(unit, e, AIR)); + if (near.length) return nearest(near, from.x, from.y); + const inRange = battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR).filter((e) => e !== from.e); + return nearest(inRange, from.x, from.y); + }; + /** S2: the slashing is over — the 【移动】, then the 6 s state. */ + const landS2 = (battle, unit, skill, m) => { + battle.removeBuff(unit, DASH_KEY); + m.phase = 2; + const keep = m.target && !m.lost && !gone(m.target) && m.at; + const [r, c] = keep && landable(battle, unit, m.at[0], m.at[1]) ? m.at : [unit.tileR, unit.tileC]; + battle.moveRedeploy(unit, r, c); + battle.fx('blink', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:respawn' }); + if (!unit.alive || !skill.active) return; + const p = num(b2['chen3_s2[respawn_buff].prob']); + battle.addBuff(unit, { key: AFTER_KEY, mods: { atkPct: num(b2['chen3_s2[respawn_buff].atk']), dodgePhys: p, dodgeArts: p }, tags: ['skill'] }); + skill.timeLeft = Math.max(0.01, skill.duration); + }; + /** S2: one slash on the current target; a target that falls hands over (+1 slash) or ends the slashing. */ + const slash = (battle, unit, m) => { + const e = m.target; + m.n++; + m.at = [Math.round(e.y), Math.round(e.x)]; // record_position (each slash) + battle.fx('slash', { x: e.x, y: e.y, id: unit.id, n: 1, skill: 'chen3:slash' }); + battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'chen3:slash'] }); + }; + /** S2: the current target left — retarget (+1 slash, +0.4 s) or end the slashing (it was lost). */ + const handOver = (m, battle, unit) => { + const from = { e: m.target, x: m.target.x, y: m.target.y }; + const next = nextTarget(battle, unit, from); + if (!next) { m.lost = true; return false; } + m.target = next; + m.lost = false; + m.hits++; + return true; + }; + + return { + // S2's slashes replace her attacks + trait: { canAttack: (b, u) => !(u.mem.chen3S2 && u.mem.chen3S2.phase === 1) }, + skills: { + [S1]: { + kind: 'duration', + mods: { atkPct: num(b1.atk) }, + attack: { + hits: 2, // 二连击 + onHit({ battle, unit, skill, target }) { // 特殊能力失效 until the skill ends + if (!target || !target.alive || target.side !== 'enemy' || !skill.active) return; + if (battle.applyStatus(target, 'silence', { duration: Math.max(0.01, skill.timeLeft), source: unit })) unit.mem.chen3S1Silenced?.add(target); + }, + }, + onStart({ battle, unit, skill }) { unit.mem.chen3S1End = battle.time + skill.timeLeft; unit.mem.chen3S1Silenced = new Set(); }, + onEnd({ battle, unit, reason }) { + const hit = unit.mem.chen3S1Silenced; + unit.mem.chen3S1Silenced = null; + if (reason === 'duration' || !hit) return; + // an early end lifts the silences she set (derived from the skill: _finishDerivedBuffIfParentFinish) — the ones + // that would have ended with it (a tick of slack: the status and the skill count down in different phases) + const left = num(unit.mem.chen3S1End) - battle.time + SILENCE_SLACK; + for (const e of hit) { + for (const s of [...e.buffs]) if (s.key === 'silence' && s.source === unit && s.timeLeft <= left) battle.removeBuff(e, s); + } + }, + }, + [S2]: { + kind: 'duration', + onStart({ battle, unit, skill }) { + const m = unit.mem.chen3S2 = { phase: 1, t: 0, n: 0, hits: slashes, target: null, at: null, lost: false }; + battle.addBuff(unit, { key: DASH_KEY, flags: { invulnerable: true, noBlock: true, noSp: true, silence: true }, tags: ['skill'] }); + battle.releaseBlocked(unit); + m.target = nearest(battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR), unit.x, unit.y); + battle.fx('dash', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:dash' }); + if (!m.target) landS2(battle, unit, skill, m); + }, + onTick({ battle, unit, skill, dt }) { + const m = unit.mem.chen3S2; + if (!m || m.phase !== 1) return; + skill.timeLeft = Math.max(0.01, skill.duration) + dt; // the 6 s start after the move + m.t += dt; + if (gone(m.target) && !handOver(m, battle, unit)) { landS2(battle, unit, skill, m); return; } + while (m.n < m.hits && m.t + 1e-9 >= SLASH_IV * m.n) { + slash(battle, unit, m); + if (!unit.alive || !skill.active) return; + if (gone(m.target) && !handOver(m, battle, unit)) { landS2(battle, unit, skill, m); return; } + } + if (m.n >= m.hits && m.t + 1e-9 >= SLASH_IV * m.hits) landS2(battle, unit, skill, m); + }, + onEnd({ battle, unit }) { + battle.removeBuff(unit, DASH_KEY); + battle.removeBuff(unit, AFTER_KEY); + unit.mem.chen3S2 = null; + }, + }, + [S3]: { + kind: 'duration', + targeting: { rangeGrid: grid3, maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 3))) }, + attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: 3 }, // `_additionalTimes` 2 + onStart({ battle, unit, skill }) { + const [dr, dc] = unit.fwd; + unit.mem.chen3Wave = { x: unit.x, y: unit.y, dr, dc, hit: new Set(), act: skill.activations, seq: unit.deploySeq }; + battle.fx('dash', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:wave' }); + }, + onEnd({ unit }) { unit.mem.chen3Wave = null; }, + }, + }, + talents: [ + { install(battle, unit) { // 形意洞照: ATK / ASPD; every physical / arts damage of hers is 弱点伤害 + statBuff(battle, unit, 'talent:chen3:insight', { atkPct: num(t0.atk), aspd: num(t0.attack_speed) }); + battle.on('hit', (c) => { if (c.source === unit && c.target && c.target.side === 'enemy') weaknessRetype(c.dmg, unit, c.target); }, { owner: unit, priority: 5 }); + } }, + { install(battle, unit) { // 寒暑觉知: every stack_time s without damage: a random self-heal and one held dodge + const st = Math.max(1, Math.floor(num(t1.stack_time, 7))), lo = num(t1.heal_atk_scale_min), hi = num(t1.heal_atk_scale_max, lo + 1); + const fresh = () => ({ count: 0, acc: 0, evade: false }); + unit.mem.chen3T2 = fresh(); + // a 【移动】 (S2) keeps it — the dodge by PRTS, the counter as the engine's 【移动】 keeps the rest + battle.on('deploy', (c) => { if (c.unit === unit && !c.move) unit.mem.chen3T2 = fresh(); }, { owner: unit }); + battle.on('tick', ({ dt }) => { + const m = unit.mem.chen3T2; + if (!up(unit) || !m) return; + m.acc += dt; + while (m.acc >= 1 - 1e-9) { + m.acc -= 1; + if (++m.count < st) continue; + m.count = 0; + m.evade = true; + const pct = lo + battle.rng.int(Math.max(1, Math.floor(hi - lo))); + battle.heal(unit, unit, unit.s.atk * pct / 100, { self: true }); + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:evade' }); + } + }, { owner: unit }); + battle.on('damaged', (c) => { + const m = unit.mem.chen3T2; + if (c.target !== unit || !m || !c.dmg || isHpLoss(c.dmg) || c.type === 'element') return; + m.count = 0; + m.acc = 0; + }, { owner: unit }); + // the held dodge: after her other dodges (priority −1000), on a dodgeable physical / arts damage instance + battle.on('hit', (c) => { + const m = unit.mem.chen3T2, d = c.dmg; + if (c.target !== unit || !m || !m.evade || !d || d.cancel || !d.canDodge || (d.type !== 'phys' && d.type !== 'arts')) return; + const p = d.type === 'phys' ? unit.s.dodgePhys : unit.s.dodgeArts; + if (!(p > 0 && battle.rng() < p)) m.evade = false; // one of her other dodges first: then it is kept + d.cancel = true; + battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); + battle.emit('dodge', { source: c.credit ?? c.source, target: unit, dmg: d }); + }, { owner: unit, priority: -1000 }); + } }, + ], + install(battle, unit) { + // AFT-X “记忆残页”: 未阻挡敌人时攻击速度+8 + const as = num(tb.attack_speed); + if (as) toggleBuff(battle, unit, 'trait:chen3:unblocked', () => unit.blocking.length === 0, { aspd: as }); + // S2: 眩晕 / 冻结 immunity while she slashes + battle.on('beforeStatus', (c) => { + if (c.target === unit && unit.mem.chen3S2 && unit.mem.chen3S2.phase === 1 && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true; + }, { owner: unit }); + // S3: the sword wave + if (unit.skill?.id !== S3) return; + const ratio = num(b3.hp_ratio), minScale = num(b3.projectile_min_atk_scale); + battle.on('tick', ({ dt }) => { + const w = unit.mem.chen3Wave; + if (!w) return; + const sk = unit.skill; + if (!up(unit) || unit.deploySeq !== w.seq || !sk || !sk.active || sk.activations !== w.act) { unit.mem.chen3Wave = null; return; } + for (let i = 0; i < 4 && waveBlocked(battle, w); i++) { // turn 90° clockwise (row up, col right) + const dr = w.dr; + w.dr = 0 - w.dc; // (`0 - x`: never a negative zero) + w.dc = dr; + w.hit.clear(); + } + if (!waveBlocked(battle, w)) { w.x += w.dc * WAVE_SPEED * dt; w.y += w.dr * WAVE_SPEED * dt; } + for (const e of battle.foesInRadius(w.x, w.y, WAVE_HIT_RADIUS)) { + if (w.hit.has(e) || !canTargetEnemy(unit, e, AIR)) continue; + w.hit.add(e); + const amount = Math.max(ratio * e.hp, minScale * unit.s.atk); + battle.fx('slash', { x: e.x, y: e.y, id: unit.id, n: 1, skill: 'chen3:wave' }); + battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', 'chen3:wave'] }); + if (!unit.alive || unit.mem.chen3Wave !== w) return; + } + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-leizi2.js b/server/sim/content/kits/ops/op-leizi2.js new file mode 100644 index 00000000..9f4751d7 --- /dev/null +++ b/server/sim/content/kits/ops/op-leizi2.js @@ -0,0 +1,341 @@ +// server/sim/content/kits/ops/op-leizi2.js — 司霆惊蛰 (char_1043_leizi2) 自选 operator kit: 6★ 解放者 (近卫), an owned-6★ +// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (LIB-X) at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_1043_leizi2, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, LIB-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); the client's battle +// logic — buff_template_data (leizi2_tr, leizi2_e_002_tr, leizi2_s_cast, leizi2_t_1[atk_up] / [on_calcul] / [fly] / +// [trigger] / [buff_start], leizi2_s_2[try_add_atk], leizi2_s_3), her charpack (Trait buff: triggerInterval 1; the +// "thunder" ability: `_maxCount` 15, `_resetCountList` 3…7, `_rangeCastDelay` 0.25; S1 abilities s1_m / s1_l / s1_r: +// `_preDelay` 0.3, ground selectors; S3 attack: projectile_char_leizi2_s3 every `_triggerDelta` 0.28 s), the skill prefabs +// (skchr_leizi2_1: leizi2_s_1 lifeTime 0.5; skchr_leizi2_2: the flood-fill selector `_depth` 3, `checkHeightType`) and +// the projectile prefabs (projectile_char_leizi2_t_1 / [start]: lands `_delayTime` 0.15 s after its cast, ground + air; +// projectile_char_leizi2_s3: speed 1, `_lifeTime` 3.1, `_keepAlreadyHitTime` 0.6, ground only, magical, modifier key +// "leizi2", rebounds off its source), her skeleton's clips (Skill_1_Begin 0.5 s with OnAttack at 0.3; Skill_2_Begin / +// Skill_3_Begin 0.5 s; Skill_1_End / Skill_2_End 0.933 s, Skill_3_End 0.833 s) — and PRTS 司霆惊蛰 (talent / skill 备注). +// The official map of a battle is its field (`rect`): stage tiles beyond it (another player's half) are no tiles here — +// no strike falls there, S2's range and S3's currents stop at its edge [ASSUMED]. +// - Trait (解放者) "通常不攻击且阻挡数为0,技能未开启时40秒内攻击力逐渐提升至最高+200%且技能结束时重置攻击力": the librator +// profile (professions.js: no attack and block 0 while the skill is off, ATK +atk / max_stack_cnt every second up to +// +atk while it is off, reset when it ends). Module LIB-X “一念” "部署后获得+100%加成": its hidden talent init_atk +// (leizi2_e_002_tr: init_atk / (atk / max_stack_cnt) stacks at each deployment) — the profile's rampInit (her DISPLAY +// trait part carries no blackboard, unlike 玛恩纳's, so the kit hands it over). +// - T1 明断 "攻击范围内每个地块每秒有10%的概率落雷对所有敌人造成相当于攻击力100%的法术伤害。未开启技能时起飞;技能期间攻击时攻击 +// 力提升至107%": +// · the strikes (PRTS 备注's algorithm, the thunder ability's numbers): every 1.0333 s from each deployment, each tile of +// her current attack range — first seen: a counter at a random 0…14; else +1, and at 15 the tile is struck and its +// counter restarts at a random 3…7; counters outlive a tile leaving the range and die with the deployment. A strike +// lands 0.15 s after its cast: atk_scale_t × ATK arts on every enemy she can select on that tile, air units too +// (PRTS 可对空). Counting pauses while she cannot act [ASSUMED: the ability does not cast under 晕眩 / 冻结]. +// · 起飞 while no skill runs (leizi2_t_1[fly] in her default mode only; gamedata_const ba.liftoff): flag `liftoff` — +// no ground enemy selects her or is blocked by her (her block count is 0 then anyway). PRTS "开启技能时的降落动作与技能 +// 结束时的起飞动作…期间自身不处于起飞状态,其中降落动作计入技能总时长": she lands at the cast and makes no attack for the +// landing clip (S2 / S3 0.5 s, inside the skill's time; S1's attacks are its own), and after the skill she is up +// again only when the take-off clip is over (S1 / S2 0.933 s, S3 0.833 s) — ground enemies may select her meanwhile; +// her block count is the trait's 0 from the skill's end on. +// · leizi2_t_1[on_calcul] (AtkScaleUp on ON_CALCULATE_DAMAGE while a skill runs; PRTS "对自身技能期间造成的所有伤害生效"): +// an atkScaleMul ×atk_scale[skill_up] buff for the skill's time — her attacks (ai.js resolveHit) and every damage this +// kit computes (strikes, S1, currents) multiply it. LIB-X stage 2+: the module talent's value (stage 3: 1.13). +// - T2 追责 "开启技能时,使攻击范围内所有地面地块落雷,对所有敌人造成相当于攻击力100%的法术伤害和2秒战栗": at every cast (each S1 +// charge too) every low tile of her attack range — the skill's, S2's flood fill included — is struck, ring by ring by +// Manhattan distance from her tile (PRTS "自内向外"), 0.25 s per ring (the thunder `_rangeCastDelay`; 0.07 s under S1 — +// leizi2_t_1[trigger] `_specialRangeCastDelay`), each landing 0.15 s after its cast: atk_scale_t2 × ATK arts and 战栗 +// (`tremble`) not_combat s on every enemy she can select there, air units too. The strikes start at the cast +// [ASSUMED: leizi2_t_1[trigger] rides her landing clip's start event (≤ 0.17 s in), not modelled]. +// - S1 浩气长存 (MANUAL, 可充能 3 次, data SEARCH): a 0.5 s skill (PRTS "技能持续0.5s"; skill prefab lifeTime 0.5) whose +// range is the 3-19; no normal attack in it; 0.3 s after the cast (the abilities' `_preDelay`) three attacks — a 3-2 to +// her left, her front and her right (PRTS: the 3-19 only explains the coverage) — each hit every ground enemy she can +// select on it for attack@atk_scale_s1 × ATK physical (one on her own tile takes all three). "充能耗尽前特性不重置" +// (leizi2_s_cast: the trait resets only when no charge is left): while a charge remains the ramp is kept. +// - S2 正霆摄威 (MANUAL, data SEARCH): at the cast her range becomes the low tiles reachable from hers in up to three 4-way +// steps over the field (PRTS 备注: "任意高度类型为低地的地块以及干员自己所在的地块", "不存在地块数据的地块始终视为不合法"; +// a real attack range — rangeExtend applies), fixed for the skill; attacks hit attack@max_target_s2 targets for +// attack@atk_scale_s2 × ATK physical, air units too (PRTS 攻击可对空); every lightning cast of hers while it runs +// (leizi2_s_2[try_add_atk], the thunder ability's buffsToOwnerOnCast — 追责's strikes too) gives ATK +thunder_atk, up +// to thunder_max_stack_cnt stacks, gone when it ends. +// - S3 天地通明 (MANUAL, data CUSTOM_RANGE on its 2-1): range 2-1, attack interval +base_attack_time s (flat), each attack +// hits its target and every enemy within attack@range_radius of it for attack@atk_scale_s3 × ATK physical (air units +// too: PRTS 普通攻击可对空); after each attack four currents leave the target's spot — up, right, down, left (screen +// directions), 0.28 s apart (the first at the hit [ASSUMED]) — at 1 tile/s for 3.1 s, turning back before they would +// enter a high tile, her own tile or the field's edge (PRTS "只在即将进入地块时进行反弹判定"), gone when she leaves (they +// outlive the skill [ASSUMED]); each hits every ground enemy she can select on its +// tile for attack@atk_scale_current × ATK arts, each enemy at most every 0.6 s per current, and — while S3 runs +// (leizi2_s_3 ON_OUTPUT_MODIFIER, modifier key "leizi2") — with `prob` to 战栗 it not_combat s. + +import { num, talentBb, moduleBb, skillRec, batMod, up } from '../shared/tier1.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { toLocal } from '../../../dir.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_leizi2_1'; +const S2 = 'skchr_leizi2_2'; +const S3 = 'skchr_leizi2_3'; +/** The skill ranges when a record carries none (data: 3-19, 2-1). */ +const R3_19 = Object.freeze([[3, 0], [2, 0], [1, 0], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-2, 0], [-3, 0]]); +const R2_1 = Object.freeze([[2, 0], [1, 0], [1, 1], [0, 0], [0, 1], [0, 2], [-1, 0], [-1, 1], [-2, 0]]); +/** S1's three attacks (PRTS 备注): a 3-2 to her front, her left and her right (facing-RIGHT offsets). */ +const S1_ARMS = Object.freeze([ + Object.freeze([[0, 0], [0, 1], [0, 2], [0, 3]]), + Object.freeze([[0, 0], [1, 0], [2, 0], [3, 0]]), + Object.freeze([[0, 0], [-1, 0], [-2, 0], [-3, 0]]), +]); +/** skchr_leizi2_1 leizi2_s_1 lifeTime (PRTS "技能持续0.5s") and the s1_* abilities' `_preDelay`. */ +const S1_TIME = 0.5; +const S1_STRIKE_AT = 0.3; +/** PRTS 明断 备注: "部署后每1.0333秒对自身当前攻击范围内所有地块执行计数判定" (31 ticks). */ +const COUNT_IV = 31 / 30; +/** The thunder ability: a strike at `_maxCount` 15, then a restart at one of `_resetCountList` 3…7; a new tile 0…14. */ +const COUNT_MAX = 15; +const RESET_MIN = 3; +const RESET_SPAN = 5; +/** projectile_char_leizi2_t_1 / [start] `_delayTime`: a strike lands this long after its cast. */ +const STRIKE_DELAY = 0.15; +/** 追责: the thunder `_rangeCastDelay` per Manhattan ring; leizi2_t_1[trigger] `_specialRangeCastDelay` under S1. */ +const RING_DELAY = 0.25; +const RING_DELAY_S1 = 0.07; +/** projectile_char_leizi2_s3: speed, lifetime, one hit per enemy per this long; the S3 attack's `_triggerDelta`. */ +const CURRENT_SPEED = 1; +const CURRENT_LIFE = 3.1; +const CURRENT_REHIT = 0.6; +const CURRENT_GAP = 0.28; +/** PRTS: "依次生成向上/向右/向下/向左移动的电流" — screen directions as [dRow, dCol] (row 0 is the bottom). */ +const CURRENT_DIRS = Object.freeze([[1, 0], [0, 1], [-1, 0], [0, -1]]); +const FOUR = Object.freeze([[1, 0], [0, 1], [-1, 0], [0, -1]]); +const AIR = Object.freeze({ canHitFly: true }); +const GROUND = Object.freeze({ canHitFly: false }); +/** Her skeleton's landing clips (Skill_2_Begin / Skill_3_Begin; S1's is its own 0.5 s) and take-off clips (Skill_N_End). */ +const LANDING = Object.freeze({ [S2]: 0.5, [S3]: 0.5 }); +const TAKEOFF = Object.freeze({ [S1]: 0.933, [S2]: 0.933, [S3]: 0.833 }); +const LIFTOFF_KEY = 'talent:leizi2:liftoff'; +const SKILL_UP_KEY = 'talent:leizi2:skillUp'; +const S2_ATK_KEY = 'skill:leizi2:thunderAtk'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** Absolute tile keys of a grid at the unit's tile and facing. */ +const keysOf = (unit, grid) => absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0); +/** A tile of the battle's field — the official map of a battle: no lightning falls on a stage tile beyond it. */ +const onField = (battle, k) => battle.grid.inRect(Math.floor(k / COLS), k % COLS); + +/** + * S2's range: the tiles reachable from hers in up to three 4-way steps over low tiles (her own tile always; a tile + * without data never — off the battle's field: the official map of a battle is its field, `rect`), as facing-RIGHT + * offsets (PRTS 备注, the selector's `_depth` 3). + */ +function floodGrid(battle, unit) { + const r0 = unit.tileR, c0 = unit.tileC; + const seen = new Set([r0 * COLS + c0]); + const cells = [[r0, c0]]; + let frontier = [[r0, c0]]; + for (let step = 0; step < 3 && frontier.length; step++) { + const next = []; + for (const [r, c] of frontier) { + for (const [dr, dc] of FOUR) { + const rr = r + dr, cc = c + dc, k = rr * COLS + cc; + if (seen.has(k) || !battle.grid.inRect(rr, cc) || battle.grid.tile(rr, cc).height !== 'LOW') continue; + seen.add(k); + next.push([rr, cc]); + cells.push([rr, cc]); + } + } + frontier = next; + } + return cells.map(([r, c]) => toLocal(r - r0, c - c0, unit.dir)); +} + +export default { + char_1043_leizi2: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const hidden = moduleBb(chess); // LIB-X: init_atk + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3); + const skillUp = num(t0['atk_scale[skill_up]'], 1); + const s2Grid = [[0, 0]]; // this unit's S2 range, rebuilt at every cast + + /** A lightning cast on tile `key` (T1's strikes, `accuse` = T2's): S2's stack at once, the strike 0.15 s later. */ + const cast = (battle, unit, key, accuse) => { + const sk = unit.skill; + if (sk && sk.active && sk.id === S2 && num(b2.thunder_atk) > 0) { + battle.addBuff(unit, { key: S2_ATK_KEY, refresh: 'stack', stacks: 1, maxStacks: Math.max(1, Math.floor(num(b2.thunder_max_stack_cnt, 25))), mods: { atkPct: num(b2.thunder_atk) }, tags: ['skill'] }); + } + const seq = unit.deploySeq; + battle.after(STRIKE_DELAY, () => { + if (!up(unit) || unit.deploySeq !== seq) return; // _stopWhenSourceInvalid + const r = Math.floor(key / COLS), c = key % COLS; + battle.fx('lightning', { x: c, y: r, id: unit.id, skill: accuse ? 'leizi2:accuse' : 'leizi2:thunder' }); + const scale = num(accuse ? t1.atk_scale_t2 : t0.atk_scale_t, 1); + for (const e of battle.enemiesInKeys([key], unit, AIR)) { + if (!e.alive) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * scale * unit.s.atkScaleMul, type: 'arts', tags: ['talent', accuse ? 'leizi2:accuse' : 'leizi2:thunder'] }); + if (accuse && e.alive && num(t1.not_combat) > 0) battle.applyStatus(e, 'tremble', { duration: num(t1.not_combat), source: unit }); + } + }, { owner: unit }); + }; + + return { + trait: { + // LIB-X “一念” 部署后获得+100%加成: the trait's start (the module's hidden talent; her trait part has no blackboard) + ...(num(hidden.init_atk) > 0 ? { rampInit: num(hidden.init_atk) } : {}), + // S2 / S3: no attack while she lands (PRTS "降落动作计入技能总时长") + canAttack: (b, u) => !(num(u.mem.leiziLandUntil) > b.time + 1e-9), + }, + skills: { + [S1]: { + kind: 'duration', + duration: S1_TIME, + targeting: { rangeGrid: s1?.rangeGrid ?? R3_19 }, + attack: { noAttack: true }, // the three attacks below are the skill's + onStart({ battle, unit, skill }) { + const seq = unit.deploySeq, act = skill.activations; + battle.after(S1_STRIKE_AT, () => { + if (!up(unit) || unit.deploySeq !== seq || skill.activations !== act) return; + battle.fx('aoe', { x: unit.x, y: unit.y, radius: 3, id: unit.id, skill: 'leizi2:s1' }); + for (const arm of S1_ARMS) { + for (const e of battle.enemiesInKeys(keysOf(unit, arm), unit, GROUND)) { + if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b1['attack@atk_scale_s1'], 1) * unit.s.atkScaleMul, type: 'phys', isSkill: true, tags: ['skill', 'leizi2:s1'] }); + } + } + }, { owner: unit }); + }, + }, + [S2]: { + kind: 'duration', + targeting: { rangeGrid: s2Grid, canHitFly: true, maxTargets: Math.max(1, Math.floor(num(b2['attack@max_target_s2'], 3))) }, + attack: { atkScale: num(b2['attack@atk_scale_s2'], 1) }, + onStart({ battle, unit }) { + const g = floodGrid(battle, unit); + s2Grid.length = 0; + for (const p of g) s2Grid.push(p); + battle.refreshRange(unit); + }, + onEnd({ battle, unit }) { battle.removeBuff(unit, S2_ATK_KEY); }, + }, + [S3]: { + kind: 'duration', + mods: { batPct: batMod(b3.base_attack_time, chess) }, + targeting: { rangeGrid: s3?.rangeGrid ?? R2_1, canHitFly: true }, + attack: { + atkScale: num(b3['attack@atk_scale_s3'], 1), + splashRadius: num(b3['attack@range_radius'], 1.65), + splashScale: 1, + onHit({ battle, unit, x, y }) { // the currents leave the target's spot + const seq = unit.deploySeq; + CURRENT_DIRS.forEach(([dr, dc], i) => { + const spawn = () => { + if (!up(unit) || unit.deploySeq !== seq) return; + (unit.mem.leiziCurrents ??= []).push({ x, y, dr, dc, life: CURRENT_LIFE, seq, hitAt: new Map() }); + }; + if (i === 0) spawn(); + else battle.after(CURRENT_GAP * i, spawn, { owner: unit }); + }); + battle.fx('pulse', { x, y, id: unit.id, skill: 'leizi2:current' }); + }, + }, + }, + }, + talents: [ + { install(battle, unit) { // 明断: the strikes, 起飞 while no skill runs, ×atk_scale[skill_up] during a skill + const lift = () => { if (up(unit)) battle.addBuff(unit, { key: LIFTOFF_KEY, flags: { liftoff: true }, tags: ['talent'] }); }; + battle.on('deploy', (c) => { + if (c.unit !== unit) return; + if (!unit.skill?.active) lift(); + if (c.move) return; + // the strike counters: a new list with every deployment + const seq = unit.deploySeq; + const counters = new Map(); + battle.every(COUNT_IV, (b, sc) => { + if (!up(unit) || unit.deploySeq !== seq) { sc.cancel(); return; } + if (!unit.canAct) return; + for (const k of unit.rangeKeys) { + if (!onField(b, k)) continue; + if (!counters.has(k)) { counters.set(k, b.rng.int(COUNT_MAX)); continue; } + const n = counters.get(k) + 1; + if (n >= COUNT_MAX) { counters.set(k, RESET_MIN + b.rng.int(RESET_SPAN)); cast(b, unit, k, false); } else counters.set(k, n); + } + }, { owner: unit }); + }, { owner: unit }); + battle.on('skillStart', (c) => { + if (c.unit !== unit) return; + battle.removeBuff(unit, LIFTOFF_KEY); + unit.mem.leiziLandUntil = battle.time + (LANDING[unit.skill.id] ?? 0); + if (skillUp !== 1) battle.addBuff(unit, { key: SKILL_UP_KEY, mods: { atkScaleMul: skillUp }, tags: ['talent'] }); + }, { owner: unit, priority: 50 }); + battle.on('skillEnd', (c) => { + if (c.unit !== unit) return; + battle.removeBuff(unit, SKILL_UP_KEY); + unit.mem.leiziLandUntil = 0; + if (c.reason === 'death') return; + // (the trait's own handler releases what she blocked) — 起飞 again once the take-off clip is over + const seq = unit.deploySeq; + battle.after(TAKEOFF[unit.skill.id] ?? 0, () => { if (unit.deploySeq === seq && !unit.skill?.active) lift(); }, { owner: unit }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 追责: every cast strikes the low tiles of her range, ring by ring from her tile + if (!(num(t1.atk_scale_t2) > 0)) return; + battle.on('skillStart', (c) => { + if (c.unit !== unit) return; + const gap = unit.skill?.id === S1 ? RING_DELAY_S1 : RING_DELAY; + const rings = new Map(); + for (const k of unit.rangeKeys) { + const r = Math.floor(k / COLS), cc = k % COLS; + if (!onField(battle, k) || !battle.grid.isLow(r, cc)) continue; + const d = Math.abs(r - unit.tileR) + Math.abs(cc - unit.tileC); + if (!rings.has(d)) rings.set(d, []); + rings.get(d).push(k); + } + const seq = unit.deploySeq; + for (const d of [...rings.keys()].sort((a, b) => a - b)) { + const fire = () => { if (up(unit) && unit.deploySeq === seq) for (const k of rings.get(d)) cast(battle, unit, k, true); }; + if (d === 0) fire(); + else battle.after(d * gap, fire, { owner: unit }); + } + }, { owner: unit }); + } }, + ], + install(battle, unit) { + const id = unit.skill?.id; + if (id === S1) { + // 充能耗尽前特性不重置: the trait resets the ramp on skillEnd (priority 0) — keep it while a charge remains + battle.on('skillEnd', (c) => { + if (c.unit === unit) unit.mem.leiziRamp = c.reason !== 'death' && unit.skill.charges > 0 ? num(unit.trait.ramp) : null; + }, { owner: unit, priority: 100 }); + battle.on('skillEnd', (c) => { + if (c.unit !== unit || unit.mem.leiziRamp == null) return; + unit.trait.ramp = unit.mem.leiziRamp; + unit.mem.leiziRamp = null; + if (unit.trait.ramp > 0 && unit.alive) battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } }); + }, { owner: unit, priority: -100 }); + } + if (id !== S3) return; + // S3: the currents + const scale = num(b3['attack@atk_scale_current']), prob = num(b3.prob), stop = num(b3.not_combat); + battle.on('tick', ({ dt }) => { + const list = unit.mem.leiziCurrents; + if (!list || !list.length) return; + const keep = []; + for (const cur of list) { + cur.life -= dt; + if (cur.life <= 1e-9 || !up(unit) || unit.deploySeq !== cur.seq) continue; + const nx = cur.x + cur.dc * CURRENT_SPEED * dt, ny = cur.y + cur.dr * CURRENT_SPEED * dt; + const r = Math.round(ny), c = Math.round(nx); + if (r !== Math.round(cur.y) || c !== Math.round(cur.x)) { + // about to enter (r, c): back off a high tile (高台), her own tile or the field's edge [ASSUMED] + if (!battle.grid.inRect(r, c) || battle.grid.tile(r, c).height === 'HIGH' || (r === unit.tileR && c === unit.tileC)) { cur.dr = 0 - cur.dr; cur.dc = 0 - cur.dc; } else { cur.x = nx; cur.y = ny; } + } else { cur.x = nx; cur.y = ny; } + keep.push(cur); + const s3On = !!(unit.skill?.active && unit.skill.id === S3); + for (const e of battle.enemiesInKeys([Math.round(cur.y) * COLS + Math.round(cur.x)], unit, GROUND)) { + const at = cur.hitAt.get(e); + if (!e.alive || (at != null && battle.time - at < CURRENT_REHIT - 1e-9)) continue; + cur.hitAt.set(e, battle.time); + const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale * unit.s.atkScaleMul, type: 'arts', isSkill: true, tags: ['skill', 'leizi2:current'] }); + if (s3On && dealt > 0 && e.alive && stop > 0 && battle.rng.chance(prob)) battle.applyStatus(e, 'tremble', { duration: stop, source: unit }); + } + if (!unit.alive) break; + } + unit.mem.leiziCurrents = keep; + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-thorns.js b/server/sim/content/kits/ops/op-thorns.js new file mode 100644 index 00000000..d233c5cf --- /dev/null +++ b/server/sim/content/kits/ops/op-thorns.js @@ -0,0 +1,229 @@ +// server/sim/content/kits/ops/op-thorns.js — 棘刺 (char_293_thorns) 自选 operator kit: 6★ 领主 (近卫), an owned-6★ pick of +// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (LOR-X, LOR-Δ) at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_293_thorns, 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); the client's battle +// logic — buff_template_data (thorns_t_1, thorns_s_2, thorns_e_002_t / [mark] / _tr, thorns_e_003_t / _tr), his charpack +// (talent 1 `_additiveActiveBuffs` thorns_t_1: triggerInterval 1, waitFirstTriggerInterval, independentCharacterSource; +// talent 2 toggle `_disableWhenInAttackState` / `InCombatState`, `_restoreDelay` 2) and the skill prefabs (skchr_thorns_2 +// Thorn: `_preDelay` 0.5, cooldown key `cooldown`, the trait's 0.8 scale, ground + air selector, `max_target`; +// skchr_thorns_3 [a] / [b] buffs), the module parts ([uc]equips thorns_equip_1_n / 2_n: the trait buffs, and from stage 2 +// the talent overrides — LOR-X `_targetFamilyMask` 31, LOR-Δ 3) — and PRTS 棘刺 (talent 备注, S2 备注, S3 备注, LOR-X 特性 +// "于计算伤害时触发"). +// - Trait (领主) "可以进行远程攻击,但此时攻击力降低至80%": the lord profile (professions.js: ×atk_scale unless the target +// stands on his tile / the tile in front or he blocks it), range 3-12, hits air units (data canHitFly), blocks 2. +// Module LOR-X “盐与沙” adds "攻击附带10%攻击力的法术伤害" (trait atk_scale_m; thorns_e_002_tr: ON_CALCULATE_DAMAGE, attack +// type ADDITION — PRTS "造成预计算法术附加伤害"): atk_scale_m × ATK arts after every hit of his attacks (the trait's +// afterHit, as 拉普兰德 / 断崖 / 仇白 / 银灰 do it; tags module / addition) and of S2's spikes [ASSUMED: the Thorn ability +// calculates its damage like an attack; the fixed-value poison ticks do not]. Module LOR-Δ “沙蚀” adds +// "造成伤害时附带相当于10%伤害的神经损伤" (trait ep_damage_ratio; thorns_e_003_tr: ON_AFTER_OUTPUT_DAMAGE, every damage +// type): ep_damage_ratio × the HP damage of every damage instance he deals — attacks, spikes, LOR-X additions and the +// poison ticks — as 神经损伤 (docs/SIM.md §7.2 element conventions). +// - T1 神经腐蚀 "攻击使目标中毒,在3秒内每秒受到125点法术伤害(对会远程攻击的目标伤害加倍)": every hit of his attacks (and +// of the S2 spikes, PRTS "所有攻击均能附加") poisons the target — one poison per 棘刺 (independentCharacterSource) for +// `duration` s, ticking every second, the first a second in (waitFirstTriggerInterval), damage[normal] or +// damage[ranged] (an enemy whose 攻击方式 includes 远程: data applyWay RANGED or ALL; PRTS "伤害加倍为数值提升") as a fixed +// arts 持续伤害 (FixedValueDamage MAGICAL, attack type BUFF: RES applies, no dodge, tags dot). A new hit refreshes the +// duration and keeps the damage value and the tick rhythm (PRTS "仅刷新持续时间而不改变伤害数值"). The ticks go on after +// he leaves the field [ASSUMED, as 伊芙利特's 灼伤]. A dodged hit poisons nothing (no `damaged`; 击中目标的攻击). +// LOR-X stage 2+ (thorns_e_002_t: no end of its own, cnt 1; every hit a new [mark] of `duration` s whose start adds 1 +// to cnt up to max_cnt; a tick without a [mark] ends it — damage × cnt) "可叠加4次": the tick is the first application's +// value × the hits so far (≤ max_cnt; PRTS "以第一层的伤害数值为基础进行翻倍"), the poison ending `duration` s after the +// last hit; all his hits, spikes included (mask 31). LOR-Δ stage 2+ (thorns_e_003_t, ep_break_multi): on an enemy in its +// 神经损伤 burst (the `neuralBurst` lock, FilterEPBreakRecoveryType SANITY) the tick is ×ep_break_multi 元素伤害; only his +// normal attacks poison (mask 3; PRTS "仅有普通攻击能附加(意味着2技能不再附加中毒Buff)"), and that poison has no +// independentCharacterSource: one per enemy whoever applies it. At stage 1 both modules leave the talent as it is +// (battle_equip_table: no talent part in phase 1). +// - T2 故土潮声 "如果2秒内没有主动攻击过,每秒恢复最大生命3.5%的生命": PRTS "增加“生命回复速度(百分比)”属性,不受治疗加成和禁疗 +// 影响" — an hpRegenRatio buff (checklist 11, never battle.heal), on while his last attack is at least `delay` s old +// (the toggle's restoreDelay after the attack states) and from his deployment on [ASSUMED: no attack yet counts as +// "2秒内没有主动攻击过"]. The S2 spikes are no 主动攻击 (PRTS "不会使第二天赋效果中断"). +// - S1 攻击力强化·γ型 (MANUAL, data DEFAULT): ATK +atk for `duration` s. +// - S2 护身尖刺 (MANUAL, 技能范围 3-1, data SKILL_RANGE): "停止攻击" (no normal attack; PRTS "技能期间,自身丢失全部视野"), +// ATK +atk, DEF +def; every 普通伤害 an enemy deals him (PRTS "指受到普通伤害(伤害类型),而非由普通攻击造成的伤害"; +// thorns_s_2 FilterDamageModifer attack type NORMAL: not 溅射, not 持续 (dot / periodic), not 附加, not element damage, +// not a 流失, not 无来源) sets off the Thorn ability when its `cooldown` (0.8 / 0.75 s) is ready and he can act: 0.5 s +// later (its `_preDelay`) up to max_target enemies of the 3-1 — ground and air, the usual target order — each take ATK × +// the trait's atk_scale (PRTS "始终受降低攻击倍率特性的影响") physical, a skill hit that is no normal attack (no `attack` +// event, no SP), cancelled when the skill has ended, he left or cannot act by then [ASSUMED]. The 3-1 is not his +// attack range: the card keeps his 3-12. +// - S3 至高之术 (MANUAL, attack SP, data ACTIVE_RANGE on its 3-3): range 3-3, ATK +atk, ASPD +attack_speed, every attack at +// full ATK ("远程攻击不再降低攻击力": the skill's attack profile has no 0.8 — dmgMul 1, as 领主·Sharp S1) for `duration` s; +// "第二次及以后使用时能力加成变为最初的两倍,且持续时间无限": from the second activation of a deployment on, the +// thorns_s_3[b] numbers (ATK / ASPD ×2) and no end until he leaves (a toggle; the first use counts its own 30 s down) — +// PRTS "每次部署棘刺时,重新计算该技能的使用次数": the count restarts with every deployment. + +import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js'; +import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; +import { hasHp, isHpLoss } from '../../../damage.js'; + +const S1 = 'skcom_atk_up[3]'; +const S2 = 'skchr_thorns_2'; +const S3 = 'skchr_thorns_3'; +/** The skill ranges when a record carries none (data: 3-1, 3-3). */ +const R3_1 = Object.freeze([[1, 0], [1, 1], [1, 2], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1], [-1, 2]]); +const R3_3 = Object.freeze([[1, 0], [1, 1], [1, 2], [1, 3], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]]); +/** skchr_thorns_2 Thorn `_preDelay`: the spikes land this long after the hit that set them off. */ +const SPIKE_DELAY = 0.5; +/** thorns_t_1 triggerInterval: a poison tick every second. */ +const POISON_IV = 1; +const AIR = Object.freeze({ canHitFly: true }); +const S3_BUFF = 'skill:thorns:destreza'; +const REGEN_KEY = 'talent:thorns:tide'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** An enemy whose 攻击方式 includes 远程 (PRTS "图鉴中【攻击方式】包含远程的敌人"; data applyWay). */ +const attacksRanged = (e) => { const w = String(e?.def?.applyWay ?? '').toUpperCase(); return w === 'RANGED' || w === 'ALL'; }; +/** + * 普通伤害 (PRTS 伤害分类; thorns_s_2 FilterDamageModifer attack type NORMAL) an enemy dealt — not 溅射, 持续 (dot / + * periodic), 附加, element damage, a 流失 or 无来源 damage. + */ +function normalHitFromEnemy(c) { + const d = c.dmg; + if (!d || !c.source || c.source.side !== 'enemy' || d.sourceless || d.isSplash || isHpLoss(d)) return false; + if (c.type === 'element' || c.type === 'elemental') return false; + const tags = d.tags || []; + return !tags.includes('dot') && !tags.includes('periodic') && !tags.includes('addition'); +} + +export default { + char_293_thorns: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const tb = traitBb(chess); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const grid2 = skillRec(chess, S2)?.rangeGrid ?? R3_1, grid3 = skillRec(chess, S3)?.rangeGrid ?? R3_3; + const rangedScale = num(tb.atk_scale, 0.8); + const loreX = num(tb.atk_scale_m); // LOR-X: arts addition + const loreD = num(tb.ep_damage_ratio); // LOR-Δ: 神经损伤 rider + const maxCnt = Math.max(1, Math.floor(num(t0.max_cnt, 1))); // LOR-X stage 2+: 可叠加 + const breakMul = num(t0.ep_break_multi); // LOR-Δ stage 2+: ×multi 元素伤害 in a 神经 burst + const spikesPoison = !(breakMul > 0); // LOR-Δ stage 2+: only normal attacks poison + + /** LOR-X "攻击附带10%攻击力的法术伤害": the arts addition of one hit (PRTS: 预计算法术附加伤害). */ + const addition = (battle, unit, target) => { + if (loreX > 0 && target && target.alive && target.side === 'enemy' && hasHp(target)) { + battle.dealDamage(unit, target, { amount: unit.s.atk * loreX, type: 'arts', tags: ['module', 'addition', 'thorns:lorx'] }); + } + }; + + /** T1 神经腐蚀: poison `e` (one per 棘刺; a new hit refreshes it, LOR-X stage 2+ adds a layer). */ + const poison = (battle, unit, e) => { + if (!e || !e.alive || e.side !== 'enemy' || !hasHp(e)) return; + const dur = num(t0.duration, 3); + const base = num(attacksRanged(e) ? t0['damage[ranged]'] : t0['damage[normal]']); + if (!(dur > 0) || !(base > 0)) return; + // one poison per 棘刺 (independentCharacterSource) — but the LOR-Δ stage 2+ one (thorns_e_003_t has no such flag) + const key = breakMul > 0 ? 'thorns:poison' : `thorns:poison:${unit.id}`; + const old = e.findBuff(key); + const b = battle.addBuff(e, { + key, duration: dur, refresh: 'extend', interval: POISON_IV, source: unit, tags: ['dot', 'talent'], + data: { base, cnt: 1 }, + onTick({ battle: bt, unit: tgt, buff }) { + if (!tgt.alive || !hasHp(tgt)) return; + let amount = buff.data.base * (maxCnt > 1 ? buff.data.cnt : 1); + let type = 'arts'; + if (breakMul > 0 && tgt.findBuff('neuralBurst')) { amount *= breakMul; type = 'elemental'; } + bt.dealDamage(unit, tgt, { amount, type, element: type === 'elemental' ? 'neural' : undefined, canDodge: false, tags: ['talent', 'dot', 'thorns:poison'] }); + }, + }); + if (old && b === old && maxCnt > 1) old.data.cnt = Math.min(maxCnt, num(old.data.cnt, 1) + 1); + }; + + return { + trait: { + // LOR-X: every hit of his attacks carries the arts addition + afterHit(battle, u, target) { addition(battle, u, target); }, + }, + skills: { + [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) } }, + [S2]: { + kind: 'duration', + mods: { atkPct: num(b2.atk), defPct: num(b2.def) }, + attack: { noAttack: true }, // 停止攻击 + }, + [S3]: { + kind: 'toggle', + targeting: { rangeGrid: grid3 }, + attack: { dmgMul: () => 1 }, // 远程攻击不再降低攻击力 + onStart({ battle, unit, skill }) { + const n = (unit.mem.thornsS3Uses = num(unit.mem.thornsS3Uses) + 1); + const again = n >= 2; + const atk = again ? num(b3['thorns_s_3[b].atk'], 2 * num(b3.atk)) : num(b3.atk); + const aspd = again ? num(b3['thorns_s_3[b].attack_speed'], 2 * num(b3.attack_speed)) : num(b3.attack_speed); + battle.addBuff(unit, { key: S3_BUFF, mods: { atkPct: atk, aspd }, tags: ['skill'] }); + // the first use runs `duration` s; from the second on "持续时间无限" ([b].duration −1) + skill.timeLeft = again && num(b3['thorns_s_3[b].duration'], -1) < 0 ? Infinity : Math.max(0.01, skill.duration); + }, + onTick({ skill, dt }) { + if (!Number.isFinite(skill.timeLeft)) return; + skill.timeLeft -= dt; + if (skill.timeLeft <= 1e-9) skill.end('duration'); + }, + onEnd({ battle, unit }) { battle.removeBuff(unit, S3_BUFF); }, + }, + }, + talents: [ + { install(battle, unit) { // 神经腐蚀: his attack hits (and the spikes, but under LOR-Δ stage 2+) poison + battle.on('damaged', (c) => { + if (c.source !== unit || !c.target || c.target.side !== 'enemy' || !c.dmg || c.type === 'element') return; + const tags = c.dmg.tags || []; + if (c.dmg.isAttack || (spikesPoison && tags.includes('thorns:spike'))) poison(battle, unit, c.target); + }, { owner: unit }); + } }, + { install(battle, unit) { // 故土潮声: 生命回复速度 while no attack of his is younger than `delay` s + const r = num(t1.hp_recovery_per_sec_by_max_hp_ratio), delay = num(t1.delay, 2); + if (!(r > 0)) return; + unit.mem.thornsAttackAt = -Infinity; + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.thornsAttackAt = -Infinity; }, { owner: unit }); + battle.on('attack', (c) => { if (c.attacker === unit) unit.mem.thornsAttackAt = battle.time; }, { owner: unit }); + const apply = () => { + const want = up(unit) && battle.time - unit.mem.thornsAttackAt >= delay - 1e-9; + const has = unit.findBuff(REGEN_KEY); + if (want && !has) battle.addBuff(unit, { key: REGEN_KEY, mods: { hpRegenRatio: r }, tags: ['talent'] }); + else if (!want && has) battle.removeBuff(unit, REGEN_KEY); + }; + battle.on('tick', apply, { owner: unit }); + battle.on('battleStart', apply, { owner: unit }); + battle.on('deploy', (c) => { if (c.unit === unit && battle.started) apply(); }, { owner: unit }); + } }, + ], + install(battle, unit) { + // S3: the use count restarts with every deployment (PRTS "每次部署棘刺时,重新计算该技能的使用次数") + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.thornsS3Uses = 0; }, { owner: unit }); + // LOR-Δ: 神经损伤 = ep_damage_ratio × the HP damage of every damage instance he deals + if (loreD > 0) { + battle.on('damaged', (c) => { + if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.type === 'element' || !(c.amount > 0) || !hasHp(c.target)) return; + battle.dealDamage(unit, c.target, { type: 'element', element: 'neural', amount: c.amount * loreD, tags: ['module', 'thorns:lord'] }); + }, { owner: unit }); + } + if (unit.skill?.id !== S2) return; + // S2 护身尖刺: 普通伤害 from an enemy ⇒ the Thorn ability (cooldown), 0.5 s later up to max_target spikes on the 3-1 + unit.mem.thornsSpikeAt = -Infinity; + const cd = num(b2.cooldown, 0.8), n = Math.max(1, Math.floor(num(b2.max_target, 4))); + battle.on('damaged', (c) => { + const sk = unit.skill; + if (c.target !== unit || !sk || !sk.active || !up(unit) || !unit.canAct || !normalHitFromEnemy(c)) return; + if (battle.time < unit.mem.thornsSpikeAt + cd - 1e-9) return; + unit.mem.thornsSpikeAt = battle.time; + const seq = unit.deploySeq, act = sk.activations; + battle.after(SPIKE_DELAY, () => { + if (!up(unit) || unit.deploySeq !== seq || !unit.canAct || !unit.skill?.active || unit.skill.activations !== act) return; + const list = battle.enemiesInKeys(absoluteRangeKeys(grid2, unit.tileR, unit.tileC, unit.dir, 0), unit, AIR); + sortEnemyTargets(battle, unit, list, null); + const targets = list.slice(0, n); + if (!targets.length) return; + battle.fx('thorns', { x: unit.x, y: unit.y, id: unit.id, n: targets.length }); + for (const e of targets) { + if (!e.alive) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * rangedScale * unit.s.atkScaleMul, type: 'phys', isSkill: true, tags: ['skill', 'thorns:spike'] }); + addition(battle, unit, e); + } + }, { owner: unit }); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/test/content/op_chen3.test.js b/test/content/op_chen3.test.js new file mode 100644 index 00000000..e9684428 --- /dev/null +++ b/test/content/op_chen3.test.js @@ -0,0 +1,361 @@ +// test/content/op_chen3.test.js — the 自选 operator kit of 赤刃明霄陈 (char_1050_chen3, 6★ 术战者; kit +// server/sim/content/kits/ops/op-chen3.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 AFT-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_chen3.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 CHEN3 = 'char_1050_chen3'; +const FORMS = BACKUPS.units[CHEN3].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const AFTX = 'uniequip_002_chen3'; +const S1 = 'skchr_chen3_1', S2 = 'skchr_chen3_2', S3 = 'skchr_chen3_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); +/** Talent `i`'s blackboard of a form with its module. */ +const tOf = (tier, elite, mod, i) => { + const base = formOf(tier, elite).talents.find((t) => t.index === i).bb; + const ch = elite && mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === i) : null; + return { ...base, ...(ch?.bb ?? {}) }; +}; +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: 2000 }), + enemy_mid: dummy('enemy_mid', { hp: 10000 }), + enemy_big: dummy('enemy_big', { hp: 1e6 }), + enemy_flymid: dummy('enemy_flymid', { hp: 10000, motion: 'FLY' }), + enemy_mage: dummy('enemy_mage', { res: 60 }), + enemy_tank: dummy('enemy_tank', { def: 5000 }), + enemy_shooter: dummy('enemy_shooter', { atk: 100, range: 2.5, bat: 1 }), + enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, AFTX].map((m) => [t, true, m]))]; + +/** A battle with 赤刃明霄陈 as uid 1 at (row, col) facing RIGHT. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, seed = 5, others = [] } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'heal', 'deploy', 'dodge'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CHEN3, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const from = (h, u, pred = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && pred(c)); +const tagged = (c, t) => (c.dmg?.tags || []).includes(t); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +test('赤刃明霄陈 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 1, melee arts ground-only, 炎, no 特质, ground enemies can target her', () => { + assert.equal(OPERATOR_KITS[CHEN3], KITS[CHEN3]); + 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], [CHEN3, 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], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.sub, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'artsfghter', 'melee', false, 'arts', 1.25], `${label(f)}: 术战者`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`); + assert.deepEqual([form.tokens, form.displayTokens, u.def.raw.tokens], [[], [], []], `${label(f)}: no summons`); + 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`); + done(h); + } + } + // E2 Lv1 2211 / 542 / 352, E2 Lv60 2674 / 627 / 400; AFT-X +240 / +30 → +420 / +60 (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], [2211, 542, 2674, 627]); + assert.deepEqual([modOf(5, AFTX).attr, modOf(6, AFTX).attr], [{ maxHp: 240, atk: 30 }, { maxHp: 420, atk: 60 }]); +}); + +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(CHEN3)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CHEN3), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: CHEN3, skillIndex: 1, uniEquipId: AFTX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: CHEN3, skillIndex: 1, uniEquipId: AFTX } } }); +}); + +test('T1 形意洞照: ATK +13 %, ASPD +13; every physical / arts damage of hers is 弱点伤害 — physical on a high-RES enemy, arts on a high-DEF one', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const t0 = tOf(tier, elite, mod, 0); + assert.deepEqual([t0.atk, t0.attack_speed], [0.13, 13]); + const { h, u } = field({ tier, elite, mod, skill: 0 }); + approx(u.s.atk, u.base.atk * 1.13, `${label(f)}: ATK`); + const as = elite && mod === AFTX ? 8 : 0; // she blocks nobody yet: AFT-X's +8 on top + assert.equal(u.s.aspd, 100 + 13 + as, `${label(f)}: ASPD`); + u.skill.sp = 0; + const mage = h.spawn('enemy_mage', { pos: [10, 6] }); + h.runUntil(() => from(h, u, (c) => c.target === mage).length > 0, 3); + const m = from(h, u, (c) => c.target === mage)[0]; + assert.deepEqual([m.type, m.dmg.isAttack], ['phys', true], `${label(f)}: physical on RES 60 / DEF 0`); + approx(m.amount, u.s.atk, `${label(f)}: full`); + h.b.kill(mage, null); + const tank = h.spawn('enemy_tank', { pos: [10, 6] }); + h.runUntil(() => from(h, u, (c) => c.target === tank).length > 0, 3); + assert.equal(from(h, u, (c) => c.target === tank)[0].type, 'arts', `${label(f)}: arts on DEF 5000 / RES 0`); + done(h); + } +}); + +test('AFT-X “记忆残页” (stages 1 / 3): ASPD +8 while she blocks nobody — off while she blocks', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5 }); + const on = elite && mod === AFTX; + if (on) assert.deepEqual(u.def.raw.trait.bb, { attack_speed: 8 }, label(f)); + h.step(); + assert.equal(!!u.findBuff('trait:chen3:unblocked'), on, `${label(f)}: nobody blocked`); + u.skill.sp = 0; + h.spawn('enemy_walk', { routeIndex: 0 }); + assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches her`); + h.step(); + assert.equal(u.findBuff('trait:chen3:unblocked'), null, `${label(f)}: blocking`); + assert.equal(u.s.aspd, 113, `${label(f)}: ASPD while blocking`); + done(h); + } +}); + +test('T2 寒暑觉知: every 7 s (AFT-X stage 3: 6 s) without damage a self-heal of ATK × a whole random percent of [30, 161) ([50, 201)) and one held dodge; any damage restarts the count', () => { + for (const f of [[5, false, null], [6, true, null], [5, true, AFTX], [6, true, AFTX]]) { + const [tier, elite, mod] = f; + const t1 = tOf(tier, elite, mod, 1); + const st = t1.stack_time, lo = t1.heal_atk_scale_min, hi = t1.heal_atk_scale_max; + assert.deepEqual([st, lo, hi], elite && mod === AFTX && tier === 6 ? [6, 50, 201] : [7, 30, 161], label(f)); + const { h, u } = field({ tier, elite, mod, skill: 0 }); + u.hp = 100; + const heals = () => h.hooksOf('heal').filter((c) => c.target === u && c.source === u && !c.opts?.regen); + h.run(st - 0.15); + assert.equal(heals().length, 0, `${label(f)}: not before ${st} s`); + assert.equal(u.mem.chen3T2.evade, false); + h.run(0.25); + assert.equal(heals().length, 1, `${label(f)}: at ${st} s`); + const pct = heals()[0].amount / u.s.atk * 100; + assert.ok(Math.abs(pct - Math.round(pct)) < 1e-6 && pct >= lo && pct < hi, `${label(f)}: ${pct} % of ATK`); + assert.equal(u.mem.chen3T2.evade, true, `${label(f)}: a dodge held`); + // damage restarts the count: no second heal at 2 × st + h.run(st - 3); + const e = h.spawn('enemy_dummy', { pos: [12, 9] }); + h.b.dealDamage(e, u, { amount: 1, type: 'true' }); + h.run(3); + assert.equal(heals().length, 1, `${label(f)}: the count restarted`); + h.run(st - 3 + 0.2); + assert.equal(heals().length, 2, `${label(f)}: ${st} s after the damage`); + done(h); + } +}); + +test('T2: the held dodge cancels the next dodgeable physical / arts damage instance and is spent; true damage passes; a 流失 restarts nothing; her other dodges (S2) are rolled first and keep it', () => { + const { h, u } = field({ tier: 5, skill: 0 }); + const e = h.spawn('enemy_dummy', { pos: [12, 9] }); + h.run(7.2); + assert.equal(u.mem.chen3T2.evade, true); + const hp0 = u.hp; + assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'true' }), 300, 'true damage is not dodged'); + assert.equal(u.mem.chen3T2.evade, true, 'still held'); + assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'arts' }), 0, 'arts dodged'); + assert.equal(u.mem.chen3T2.evade, false, 'spent'); + assert.equal(h.hooksOf('dodge').filter((c) => c.target === u).length, 1, 'a dodge event'); + assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'phys' }) > 0, true, 'the next one lands'); + approx(u.hp, hp0 - 300 - (300 - u.s.def > 300 * 0.05 ? 300 - u.s.def : 15), 'HP'); + // a 流失 does not restart the count + h.run(5); + h.b.loseHp(u, 1, { source: e }); + h.run(2.2); + assert.equal(u.mem.chen3T2.evade, true, 'a 流失 is no damage taken'); + // another dodge first: a certain one (p 1) wins and the held one is kept + h.b.addBuff(u, { key: 'test:dodge', mods: { dodgePhys: 1 } }); + assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'phys' }), 0); + assert.equal(u.mem.chen3T2.evade, true, 'kept behind the other dodge'); + done(h); +}); + +test('S1 赤霄·奔夜 (MANUAL, data DEFAULT): ATK +65 % / +80 % for 18 s, every attack hits twice, 沉默 on its target until the skill ends', () => { + 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([u.skill.rule, sk.duration, sk.bb.atk], ['DEFAULT', 18, elite ? 0.8 : 0.65], `T${tier}`); + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + approx(u.s.atk, u.base.atk * (1 + 0.13 + sk.bb.atk), `T${tier}: ATK`); + const n0 = from(h, u).length; + h.runUntil(() => from(h, u).length >= n0 + 2, 3); + const two = from(h, u).slice(n0, n0 + 2); + assert.equal(two[0].dmg.attackId, two[1].dmg.attackId, `T${tier}: one attack, two hits`); + approx(two[0].amount, u.s.atk, `T${tier}: ATK each`); + const sil = h.hooksOf('statusApplied').filter((c) => c.status === 'silence' && c.target === e && c.source === u); + assert.ok(sil.length >= 1, `T${tier}: 沉默`); + approx(sil[0].duration + sil[0].t, u.mem.chen3S1End, `T${tier}: until the skill ends`, 0.05); + h.runUntil(() => !u.skill.active, 20); + h.step(); + assert.ok(!e.s.flags.silence, `T${tier}: lifted with the skill's end`); + done(h); + } + // knocked out mid-skill: the silences she set end with it + const { h, u } = field({ tier: 5, skill: 0 }); + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.status === 'silence'), 3); + assert.ok(e.s.flags.silence); + h.b.kill(u, null); + h.step(); + assert.ok(!e.s.flags.silence, 'gone with the skill'); + done(h); +}); + +test('S2 赤霄·绝影-驰 (MANUAL, 技能范围 x-1, data SKILL_RANGE): 10 slashes on the nearest enemy, 0.4 s apart, 390 % / 430 % ATK arts; 无敌, blocks nobody, no attack, stun immune; then a 【移动】 onto its tile and 6 s of ATK +230 % / +260 % with 40 % / 50 % physical and arts dodge', () => { + 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([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk_scale, sk.bb['chen3_s2[respawn_buff].atk'], sk.bb['chen3_s2[respawn_buff].prob']], + ['SKILL_RANGE', 'x-1', 6, elite ? 4.3 : 3.9, elite ? 2.6 : 2.3, elite ? 0.5 : 0.4], `T${tier}`); + u.skill.gainSp(999); + const near = h.spawn('enemy_dummy', { pos: [11, 6] }); // √2 away + const far = h.spawn('enemy_dummy', { pos: [10, 7] }); // 2 away + const shooter = h.spawn('enemy_shooter', { pos: [12, 7] }); + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast`); + const t0 = h.b.time; + h.step(); + assert.ok(u.s.flags.invulnerable && u.s.flags.noBlock, `T${tier}: 无敌, 不可阻挡`); + assert.equal(h.b.applyStatus(u, 'stun', { duration: 2, source: shooter }), false, `T${tier}: stun immune`); + const hp0 = u.hp; + h.run(3.5); + assert.equal(u.hp, hp0, `T${tier}: nothing hurts her`); + h.runUntil(() => u.mem.chen3S2?.phase !== 1, 3); + const cuts = from(h, u, (c) => tagged(c, 'chen3:slash')); + assert.equal(cuts.length, 10, `T${tier}: 10 slashes`); + assert.ok(cuts.every((c) => c.target === near), `T${tier}: on the nearest`); + cuts.forEach((c, i) => approx(c.t - cuts[0].t, 0.4 * i, `T${tier}: slash ${i + 1}`, 0.04)); + approx(cuts[0].t, t0, `T${tier}: the first at the cast`, 0.05); + for (const c of cuts) { approx(c.amount, u.base.atk * 1.13 * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); assert.deepEqual([c.type, c.dmg.isSkill], ['arts', true]); } + // the move onto the target's tile, the 6 s state + const moves = h.hooksOf('deploy').filter((c) => c.unit === u && c.move); + assert.equal(moves.length, 1, `T${tier}: one 【移动】`); + approx(moves[0].t - cuts[0].t, 4, `T${tier}: after 10 × 0.4 s`, 0.05); + assert.equal(from(h, u, (c) => c.dmg.isAttack && c.t < moves[0].t).length, 0, `T${tier}: no attack while slashing`); + assert.deepEqual([u.tileR, u.tileC], [11, 6], `T${tier}: moved onto its tile`); + assert.ok(!u.s.flags.invulnerable && !u.s.flags.noBlock, `T${tier}: the slashing is over`); + approx(u.s.atk, u.base.atk * (1 + 0.13 + sk.bb['chen3_s2[respawn_buff].atk']), `T${tier}: ATK`); + approx(u.s.dodgePhys, sk.bb['chen3_s2[respawn_buff].prob'], `T${tier}: physical dodge`); + approx(u.s.dodgeArts, sk.bb['chen3_s2[respawn_buff].prob'], `T${tier}: arts dodge`); + approx(u.skill.timeLeft, 6, `T${tier}: 6 s from the move`, 0.05); + h.runUntil(() => !u.skill.active, 7); + approx(u.s.atk, u.base.atk * 1.13, `T${tier}: back`); + assert.equal(from(h, u, (c) => tagged(c, 'chen3:slash') && c.target === far).length, 0, `T${tier}: the farther one is never slashed`); + done(h); + } +}); + +test('S2: a slashed enemy that falls hands the slashes to the nearest one within 1.7 (+1 slash); with nobody left the slashing ends and she moves back onto her own tile; damage on a 2000-HP target', () => { + { + const { h, u } = field({ tier: 5, skill: 1 }); + u.skill.gainSp(999); + const frail = h.spawn('enemy_frail', { pos: [10, 6] }), next = h.spawn('enemy_dummy', { pos: [11, 7] }); + assert.ok(h.runUntil(() => u.skill.active, 2)); + h.runUntil(() => u.mem.chen3S2?.phase !== 1, 6); + const cuts = from(h, u, (c) => tagged(c, 'chen3:slash')); + assert.ok(!frail.alive, 'the first target fell'); + const onFrail = cuts.filter((c) => c.target === frail).length; + assert.equal(cuts.length, 11, `11 slashes (${onFrail} on the first)`); + assert.ok(cuts.slice(onFrail).every((c) => c.target === next), 'the rest on the next'); + assert.deepEqual([u.tileR, u.tileC], [11, 7], 'onto the last target\'s tile'); + done(h); + } + { + const { h, u } = field({ tier: 5, skill: 1 }); + u.skill.gainSp(999); + const frail = h.spawn('enemy_frail', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 2)); + h.runUntil(() => u.mem.chen3S2?.phase !== 1, 6); + assert.ok(!frail.alive); + assert.ok(from(h, u, (c) => tagged(c, 'chen3:slash')).length < 10, 'ended early'); + assert.deepEqual([u.tileR, u.tileC], [10, 5], 'back on her own tile'); + assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && c.move).length, 1, 'a 【移动】 in place'); + assert.ok(u.skill.active && u.findBuff('skill:chen3:respawn'), 'the 6 s state still'); + done(h); + } +}); + +test('S3 赤霄·天喟 (MANUAL, data ACTIVE_RANGE on 3-12): range 3-12, each attack hits up to 3 ground enemies 3 times for 165 % / 180 % ATK arts; back to 1-1 after 20 s', () => { + 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([u.skill.rule, sk.rangeId, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@max_target'], sk.bb.hp_ratio, sk.bb.projectile_min_atk_scale], + ['ACTIVE_RANGE', '3-12', 20, elite ? 1.8 : 1.65, 3, 0.06, elite ? 5.5 : 5.3], `T${tier}`); + u.skill.gainSp(999); + const ground = [[10, 7], [9, 6], [11, 6], [10, 8]].map((pos) => h.spawn('enemy_dummy', { pos })); + const fly = h.spawn('enemy_fly', { pos: [11, 5] }); + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: an enemy on the 3-12`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-12`); + const n0 = from(h, u, (c) => c.dmg.isAttack).length; + h.runUntil(() => from(h, u, (c) => c.dmg.isAttack).length >= n0 + 9, 4); + const hits = from(h, u, (c) => c.dmg.isAttack).slice(n0, n0 + 9); + assert.equal(new Set(hits.map((c) => c.dmg.attackId)).size, 1, `T${tier}: one attack`); + assert.equal(new Set(hits.map((c) => c.target)).size, 3, `T${tier}: 3 targets`); + assert.ok(hits.every((c) => c.target !== fly && ground.includes(c.target)), `T${tier}: ground only`); + for (const c of hits) { approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); assert.equal(c.type, 'arts'); } + h.runUntil(() => !u.skill.active, 21); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1`); + done(h); + } +}); + +test('S3: the sword wave leaves her tile forward at 1.5 tiles/s, hits each enemy within 1.3 once per straight run (air too) for max(6 % of its HP, 530 % / 550 % ATK) arts, turns clockwise at the field\'s edge / high ground / gates and forgets whom it hit, and is gone with the skill', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const { h, u } = field({ tier, elite, skill: 2 }); + u.skill.gainSp(999); + const trigger = h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 2)); + const t0 = h.b.time; + const mid = h.spawn('enemy_mid', { pos: [10, 8] }), big = h.spawn('enemy_big', { pos: [9, 8] }); + const fly = h.spawn('enemy_flymid', { pos: [11, 9] }); + const corner = h.spawn('enemy_dummy', { pos: [10, 10] }); + const waveHits = (e) => from(h, u, (c) => tagged(c, 'chen3:wave') && c.target === e); + h.run(3); + const w = u.mem.chen3Wave; + approx(w.x, 5 + 1.5 * (h.b.time - t0), `T${tier}: 1.5 tiles/s along row 10`, 0.02); + assert.deepEqual([w.dr, w.dc, w.y], [0, 1, 10], `T${tier}: still heading right`); + assert.equal(waveHits(trigger).length, 1, `T${tier}: once per run`); + approx(waveHits(trigger)[0].t, t0, `T${tier}: at the cast`, 0.1); + // 10 000 HP: 6 % = 600 < 530 % ATK ⇒ the ATK floor; 1 000 000 HP: 6 % = 60 000 wins + for (const [e, amt] of [[mid, sk.bb.projectile_min_atk_scale * u.s.atk], [fly, sk.bb.projectile_min_atk_scale * u.s.atk], [big, 0.06 * 1e6]]) { + assert.equal(waveHits(e).length, 1, `T${tier}: ${e.defId} once`); + approx(waveHits(e)[0].amount, amt, `T${tier}: ${e.defId}`); + assert.deepEqual([waveHits(e)[0].type, waveHits(e)[0].dmg.isSkill], ['arts', true]); + } + // the field ends after column 10: it turns down there (the corner enemy, within 1.3, is hit again), then left before + // the 侵入点 at (9, 10) — and runs back along row 10 + h.run(1); + assert.ok(waveHits(corner).length >= 2, `T${tier}: the corner one again after a turn (${waveHits(corner).length})`); + h.run(2); + assert.deepEqual([u.mem.chen3Wave.dr, u.mem.chen3Wave.dc], [0, -1], `T${tier}: heading left`); + assert.ok(u.mem.chen3Wave.x < 10 && Math.round(u.mem.chen3Wave.y) === 10, `T${tier}: back along row 10`); + h.runUntil(() => !u.skill.active, 20); + h.step(); + assert.equal(u.mem.chen3Wave, null, `T${tier}: gone with the skill`); + const n = from(h, u, (c) => tagged(c, 'chen3:wave')).length; + h.run(3); + assert.equal(from(h, u, (c) => tagged(c, 'chen3:wave')).length, n, `T${tier}: no more wave hits`); + done(h); + } +}); diff --git a/test/content/op_leizi2.test.js b/test/content/op_leizi2.test.js new file mode 100644 index 00000000..95890351 --- /dev/null +++ b/test/content/op_leizi2.test.js @@ -0,0 +1,371 @@ +// test/content/op_leizi2.test.js — the 自选 operator kit of 司霆惊蛰 (char_1043_leizi2, 6★ 解放者; kit +// server/sim/content/kits/ops/op-leizi2.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 LIB-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_leizi2.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'; +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 LEIZI2 = 'char_1043_leizi2'; +const FORMS = BACKUPS.units[LEIZI2].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const LIBX = 'uniequip_002_leizi2'; +const S1 = 'skchr_leizi2_1', S2 = 'skchr_leizi2_2', S3 = 'skchr_leizi2_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 tOf = (tier, elite, mod, i) => { + const base = formOf(tier, elite).talents.find((t) => t.index === i).bb; + const ch = elite && mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === i) : null; + return { ...base, ...(ch?.bb ?? {}) }; +}; +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { + enemy_dummy: dummy('enemy_dummy'), + enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + enemy_shooter: dummy('enemy_shooter', { atk: 100, range: 2.5, bat: 1 }), + enemy_flyshooter: dummy('enemy_flyshooter', { atk: 100, range: 2.5, bat: 1, motion: 'FLY' }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, LIBX].map((m) => [t, true, m]))]; +const COUNT_IV = 31 / 30; + +/** A battle with 司霆惊蛰 as uid 1 at (row, col) facing RIGHT; `rec` logs her damage with her ATK at that moment. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, seed = 5, silence = false } = {}) { + const log = []; + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LEIZI2, skillIndex: skill, uniEquipId: mod }, elite, row, col }], + setup(b) { + b.on('damaged', (c) => { + const u = b.allyUnits.find((a) => a.uid === 1); + if (c.source === u) log.push({ t: b.time, e: c.target, amount: c.amount, type: c.type, atk: u.s.atk, mul: u.s.atkScaleMul, tags: c.dmg?.tags || [], isAttack: !!c.dmg?.isAttack, isSplash: !!c.dmg?.isSplash, attackId: c.dmg?.attackId }); + }, { priority: 1000 }); + }, + }); + h.step(); + const u = h.unit(1); + if (silence) h.b.addBuff(u, { key: 'test:silence', flags: { silence: true }, persist: true }); + return { h, u, log }; +} +const tagged = (x, t) => x.tags.includes(t); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +test('司霆惊蛰 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-2, a 解放者 (no attack, block 0 while no skill runs), 起飞, melee physical ground-only, 炎, no 特质', () => { + assert.equal(OPERATOR_KITS[LEIZI2], KITS[LEIZI2]); + 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, silence: true }); + 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], [LEIZI2, 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([form.stats.blockCnt, u.s.blockCnt, u.profile.sub, u.profile.noAttackUnlessSkill, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 0, 'librator', true, 'melee', false, 'phys', 1.2], `${label(f)}: 解放者`); + assert.deepEqual([u.profile.rampMax, u.profile.rampTime, u.profile.rampInit], [2, 40, elite && mod === LIBX ? 1 : 0], `${label(f)}: the trait's ramp`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-2`); + assert.deepEqual([form.tokens, form.displayTokens, u.def.raw.tokens], [[], [], []], `${label(f)}: no summons`); + 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)}: 起飞`); + done(h); + } + } + // E2 Lv1 3206 / 306 / 417, E2 Lv60 3642 / 342 / 466; LIB-X +225 / +26 / +27 → +360 / +42 / +36 (HP / ATK / DEF) + 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], [3206, 306, 3642, 342]); + assert.deepEqual([modOf(5, LIBX).attr, modOf(6, LIBX).attr], [{ maxHp: 225, atk: 26, def: 27 }, { maxHp: 360, atk: 42, def: 36 }]); +}); + +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(LEIZI2)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LEIZI2), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: LEIZI2, skillIndex: 2, uniEquipId: LIBX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: LEIZI2, skillIndex: 2, uniEquipId: LIBX } } }); +}); + +test('trait + LIB-X: ATK +5 % a second up to +200 % while no skill runs (LIB-X stages 1 / 3: +100 % from each deployment); she never attacks then', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 1, silence: true }); + const init = elite && mod === LIBX ? 1 : 0; + approx(u.s.atk, u.base.atk * (1 + init), `${label(f)}: at the deployment`); + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(10.05); + approx(u.s.atk, u.base.atk * (1 + Math.min(2, init + 0.5)), `${label(f)}: after 10 s`); + h.run(40); + approx(u.s.atk, u.base.atk * 3, `${label(f)}: +200 % at most`); + assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, `${label(f)}: no attack`); + done(h); + } +}); + +test('T1 明断 起飞: while no skill runs ground enemies never target her (a ground shooter in range stays idle, a flying one hits her); a skill lands her (0.5 s with no attack, counted in its time) and she blocks 3; after it the take-off clip (0.933 s) passes before she is up again', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const { h, u } = field({ tier, elite, skill: 1, silence: true }); + const g = h.spawn('enemy_shooter', { pos: [10, 7] }); + h.run(4); + assert.equal(h.hooksOf('damaged').filter((c) => c.target === u && c.source === g).length, 0, `T${tier}: the ground shooter cannot select her`); + const air = h.spawn('enemy_flyshooter', { pos: [11, 6] }); + h.run(3); + assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source === air), `T${tier}: a flyer can`); + h.spawn('enemy_dummy', { pos: [10, 6] }); // S2's SEARCH: a ground enemy on her 1-2 + h.b.removeBuff(u, 'test:silence'); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: S2 (an enemy on her 1-2)`); + const cast = h.b.time; + assert.deepEqual([!!u.s.flags.liftoff, u.s.blockCnt], [false, 3], `T${tier}: landed`); + h.run(3); + assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source === g), `T${tier}: now the ground shooter hits her`); + const first = h.hooksOf('attack').find((c) => c.attacker === u); + approx(first.t - cast, 0.5, `T${tier}: her first attack once the landing is over`, 0.05); + h.runUntil(() => !u.skill.active, 40); + approx(h.b.time - cast, skillOf(tier, elite, S2).duration, `T${tier}: the landing counts in the 36 s`, 0.05); + h.step(); + assert.deepEqual([!!u.s.flags.liftoff, u.s.blockCnt], [false, 0], `T${tier}: taking off`); + h.run(0.8); + assert.ok(!u.s.flags.liftoff, `T${tier}: still taking off`); + h.run(0.15); + assert.deepEqual([!!u.s.flags.liftoff, u.s.blockCnt], [true, 0], `T${tier}: up again`); + done(h); + } +}); + +test('T1 明断 strikes: every 1.0333 s each tile of her range counts — a struck tile is struck again 8–12 counts later, a new one within 2–16 — for 100 % ATK arts on every enemy there, air units too, 0.15 s after the cast', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, LIBX]]) { + const t0 = tOf(tier, elite, mod, 0); + assert.deepEqual([t0.interval, t0.atk_scale_t, t0.times], [1, 1, 1]); + const { h, log } = field({ tier, elite, mod, skill: 0, silence: true }); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [11, 5] }), off = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.run(300); + for (const tgt of [e, fly]) { + const hits = log.filter((x) => x.e === tgt && tagged(x, 'leizi2:thunder')); + assert.ok(hits.length >= 20, `T${tier}: ${hits.length} strikes on ${tgt.defId}`); + const first = Math.round((hits[0].t - 0.15) / COUNT_IV); + assert.ok(first >= 2 && first <= 16, `T${tier}: the first after ${first} counts`); + approx(hits[0].t - 0.15, first * COUNT_IV, `T${tier}: on a count, landing 0.15 s later`, 0.02); + for (let i = 1; i < hits.length; i++) { + const k = (hits[i].t - hits[i - 1].t) / COUNT_IV; + assert.ok(Math.abs(k - Math.round(k)) < 0.03 && Math.round(k) >= 8 && Math.round(k) <= 12, `T${tier}: ${k} counts apart`); + } + for (const x of hits) { approx(x.amount, x.atk * x.mul, `T${tier}: 100 % ATK`); assert.equal(x.type, 'arts'); approx(x.mul, 1, 'no skill: ×1'); } + } + assert.equal(log.filter((x) => x.e === off).length, 0, `T${tier}: (10,7) is off her 1-2`); + done(h); + } +}); + +test('T1: ×1.07 (LIB-X stage 3: ×1.13) on everything she deals while a skill runs — an atkScaleMul buff for the skill\'s time', () => { + for (const f of [[5, false, null], [6, true, null], [5, true, LIBX], [6, true, LIBX]]) { + const [tier, elite, mod] = f; + const up = tOf(tier, elite, mod, 0)['atk_scale[skill_up]']; + assert.equal(up, elite && mod === LIBX && tier === 6 ? 1.13 : 1.07, label(f)); + const { h, u, log } = field({ tier, elite, mod, skill: 1 }); + approx(u.s.atkScaleMul, 1, `${label(f)}: off`); + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 2)); + approx(u.s.atkScaleMul, up, `${label(f)}: on`); + h.run(4); + const atk = log.filter((x) => x.isAttack); + assert.ok(atk.length > 0, `${label(f)}: attacks`); + for (const x of atk) approx(x.amount, x.atk * skillOf(tier, elite, S2).bb['attack@atk_scale_s2'] * up, `${label(f)}: S2 attack × ${up}`); + h.runUntil(() => !u.skill.active, 40); + approx(u.s.atkScaleMul, 1, `${label(f)}: off again`); + done(h); + } +}); + +test('T2 追责: every cast strikes each low tile of her range ring by ring from her tile (0.25 s apart; S1 0.07 s), landing 0.15 s later: 100 % ATK × skill bonus arts and 2 s 战栗 on every enemy there, air units too', () => { + const t1 = tOf(5, false, null, 1); + assert.deepEqual([t1.atk_scale_t2, t1.not_combat], [1, 2]); + for (const [skill, gap, pos, d] of [[2, 0.25, [10, 7], 2], [0, 0.07, [10, 6], 1], [2, 0.25, [11, 6], 2]]) { + const { h, u, log } = field({ tier: 5, skill }); + const e = h.spawn(pos[0] === 11 ? 'enemy_fly' : 'enemy_dummy', { pos }); + u.skill.gainSp(999); + if (skill === 0) h.spawn('enemy_dummy', { pos: [11, 5] }); // S1 (SEARCH) needs a ground enemy on her 1-2 + assert.ok(h.runUntil(() => u.skill.active, 2), `S${skill + 1}: cast`); + const t0 = h.b.time; + h.run(1.2); + const hits = log.filter((x) => x.e === e && tagged(x, 'leizi2:accuse')); + assert.equal(hits.length, 1, `S${skill + 1}: one strike on ${pos}`); + approx(hits[0].t - t0, d * gap + 0.15, `S${skill + 1}: ring ${d}`, 0.04); + approx(hits[0].amount, hits[0].atk * 1.07, `S${skill + 1}: 100 % ATK × 1.07`); + assert.equal(hits[0].type, 'arts'); + const tr = h.hooksOf('statusApplied').filter((c) => c.status === 'tremble' && c.target === e && c.source === u); + assert.equal(tr.length, 1, `S${skill + 1}: 战栗`); + approx(tr[0].duration, 2, 'for 2 s'); + done(h); + } + // S2: one cast per low tile of the flood fill (19 on the flat stage at (10,5)) — each gives a +10 % stack at once + const { h, u } = field({ tier: 5, skill: 1 }); + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 2)); + const low = u.rangeKeys.filter((k) => h.b.grid.isLow(Math.floor(k / COLS), k % COLS)).length; + assert.equal(low, 19, 'the flood fill'); + h.run(0.8); + const st = u.findBuff('skill:leizi2:thunderAtk'); + assert.ok(st && st.stacks >= 19 && st.stacks <= 25, `S2 stacks after the rings: ${st?.stacks}`); + done(h); +}); + +test('S1 浩气长存 (MANUAL, 3 charges, data SEARCH): a 0.5 s skill on the 3-19; 0.3 s in, a 3-2 to her left, front and right each hits every ground enemy on it for 260 % / 300 % ATK × 1.07 physical (one on her tile three times, none diagonal, no flyer)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u, log } = field({ tier, elite, skill: 0, silence: true }); + assert.deepEqual([u.skill.rule, u.skill.maxCharges, sk.bb['attack@atk_scale_s1'], sk.rangeId, u.skill.spCost, sk.initSp], ['SEARCH', 3, elite ? 3 : 2.6, '3-19', elite ? 13 : 14, 17], `T${tier}`); + const own = h.spawn('enemy_dummy', { pos: [10, 5] }), front = h.spawn('enemy_dummy', { pos: [10, 8] }); + const left = h.spawn('enemy_dummy', { pos: [12, 5] }), right = h.spawn('enemy_dummy', { pos: [9, 5] }); + const diag = h.spawn('enemy_dummy', { pos: [11, 6] }), fly = h.spawn('enemy_fly', { pos: [11, 5] }); + h.b.removeBuff(u, 'test:silence'); + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`); + const t0 = h.b.time; + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: the 3-19`); + h.runUntil(() => !u.skill.active, 1); + approx(h.b.time - t0, 0.5, `T${tier}: 0.5 s`, 0.04); + const s1 = log.filter((x) => tagged(x, 'leizi2:s1')); + const n = (e) => s1.filter((x) => x.e === e).length; + assert.deepEqual([n(own), n(front), n(left), n(right), n(diag), n(fly)], [3, 1, 1, 1, 0, 0], `T${tier}: who is hit`); + for (const x of s1) { + approx(x.t - t0, 0.3, `T${tier}: 0.3 s in`, 0.04); + approx(x.amount, x.atk * sk.bb['attack@atk_scale_s1'] * 1.07, `T${tier}: ${sk.bb['attack@atk_scale_s1'] * 100} % × 1.07`); + assert.equal(x.type, 'phys'); + } + assert.equal(log.filter((x) => x.isAttack).length, 0, `T${tier}: no normal attack`); + done(h); + } +}); + +test('S1: "充能耗尽前特性不重置" — the ramp is kept while a charge remains and reset by the last one', () => { + const { h, u } = field({ tier: 5, skill: 0, silence: true }); + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(8.05); + u.skill.gainSp(999); + assert.equal(u.skill.charges, 3); + const ramp0 = u.trait.ramp; + approx(ramp0, 0.4, 'the ramp after 8 s'); + h.b.removeBuff(u, 'test:silence'); + assert.ok(h.runUntil(() => u.skill.activations === 1 && !u.skill.active, 2)); + assert.ok(u.trait.ramp >= ramp0 - 1e-9, `kept after the first charge (${u.trait.ramp})`); + assert.ok(h.runUntil(() => u.skill.activations === 2 && !u.skill.active, 5)); + assert.ok(u.trait.ramp >= ramp0 - 1e-9, `kept after the second (${u.trait.ramp})`); + approx(u.s.atk, u.base.atk * (1 + u.trait.ramp), 'ATK'); + assert.ok(h.runUntil(() => u.skill.activations === 3 && !u.skill.active, 5)); + assert.equal(u.skill.charges, 0); + assert.ok(u.trait.ramp < 0.06, `reset by the last (${u.trait.ramp})`); + done(h); +}); + +test('S2 正霆摄威 (MANUAL, data SEARCH): range = the low tiles 3 four-way steps from hers (high ground and the field\'s edge stop it), 3 targets with air units for 110 % / 125 % ATK × 1.07, +10 % ATK per lightning cast up to 25, all gone after 36 s', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.bb['attack@atk_scale_s2'], sk.bb['attack@max_target_s2'], sk.bb.thunder_atk, sk.bb.thunder_max_stack_cnt, sk.duration, sk.rangeId], [elite ? 1.25 : 1.1, 3, 0.1, 25, 36, null]); + const { h, u, log } = field({ tier, elite, skill: 1, row: 9, col: 3 }); + assert.equal(u.skill.rule, 'SEARCH'); + u.skill.gainSp(999); + const targets = [[9, 4], [10, 3], [9, 5]].map((pos) => h.spawn('enemy_dummy', { pos })); + const fly = h.spawn('enemy_fly', { pos: [10, 4] }); + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast`); + // from (9,3): row 8 is high, (10,2) / (11,2) / (12,2) are high, (9,2) is low (the goal), columns < 0 are off the field + const want = []; + for (let r = 9; r <= 12; r++) for (let c = 0; c <= 10; c++) { + const t = h.b.grid.tile(r, c); + if (t.height === 'LOW' && Math.abs(r - 9) + Math.abs(c - 3) <= 3) want.push(`${r},${c}`); + } + const got = u.rangeKeys.map((k) => `${Math.floor(k / COLS)},${k % COLS}`).sort(); + assert.deepEqual(got, want.sort(), `T${tier}: the flood fill`); + h.run(3); + const atk = log.filter((x) => x.isAttack); + const ids = new Set(atk.filter((x) => x.attackId === atk[0].attackId).map((x) => x.e)); + assert.equal(ids.size, 3, `T${tier}: 3 targets`); + assert.ok(atk.some((x) => x.e === fly), `T${tier}: a flyer among them`); + for (const x of atk) approx(x.amount, x.atk * sk.bb['attack@atk_scale_s2'] * 1.07, `T${tier}: ${sk.bb['attack@atk_scale_s2'] * 100} % × 1.07`); + h.run(30); + const st = u.findBuff('skill:leizi2:thunderAtk'); + assert.equal(st.stacks, 25, `T${tier}: 25 stacks at most`); + approx(u.s.atk, u.base.atk * (1 + u.trait.ramp + 2.5), `T${tier}: +250 %`); + h.runUntil(() => !u.skill.active, 10); + assert.equal(u.findBuff('skill:leizi2:thunderAtk'), null, `T${tier}: gone`); + assert.deepEqual([u.trait.ramp, u.liveRangeGrid], [0, formOf(tier, elite).rangeGrid], `T${tier}: the trait reset, 1-2 again`); + void targets; + done(h); + } +}); + +test('S3 天地通明 (MANUAL, data CUSTOM_RANGE on 2-1): range 2-1, attack interval 1.2 + 1.7 s, 210 % / 240 % ATK × 1.07 on the target and everyone within 1.65 (air too); then four currents', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.bb['attack@atk_scale_s3'], sk.bb.base_attack_time, sk.bb['attack@range_radius'], sk.bb['attack@atk_scale_current'], sk.bb.prob, sk.bb.not_combat, sk.duration, sk.rangeId], + [elite ? 2.4 : 2.1, 1.7, 1.65, elite ? 0.56 : 0.45, elite ? 0.1 : 0.07, 3, 24, '2-1']); + const { h, u, log } = field({ tier, elite, skill: 2 }); + assert.equal(u.skill.rule, 'CUSTOM_RANGE'); + u.skill.gainSp(999); + const fly0 = h.spawn('enemy_fly', { pos: [12, 5] }); // a flyer on the 2-1 casts it + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: a flyer on the 2-1`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 2-1`); + approx(u.s.interval, 2.9, `T${tier}: 2.9 s`); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }), side = h.spawn('enemy_dummy', { pos: [10, 7] }), flyNear = h.spawn('enemy_fly', { pos: [11, 7] }); + h.b.kill(fly0, null); + h.runUntil(() => log.some((x) => x.isAttack && x.e === e), 4); + const a0 = log.find((x) => x.isAttack && x.e === e); + const one = log.filter((x) => x.isAttack && x.attackId === a0.attackId); + assert.deepEqual(new Set(one.map((x) => x.e)), new Set([e, side, flyNear]), `T${tier}: the target and everyone within 1.65`); + for (const x of one) approx(x.amount, x.atk * sk.bb['attack@atk_scale_s3'] * 1.07, `T${tier}: ${sk.bb['attack@atk_scale_s3'] * 100} % × 1.07`); + done(h); + } +}); + +test('S3 currents: four from the target\'s spot (up, right, down, left; 0.28 s apart) at 1 tile/s for 3.1 s, bouncing off high ground and her own tile; 45 % / 56 % ATK × 1.07 arts on ground enemies of their tile, at most every 0.6 s per current; 战栗 7 % / 10 % for 3 s', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const { h, u, log } = field({ tier, elite, skill: 2 }); + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + const fly = h.spawn('enemy_fly', { pos: [10, 8] }); + assert.ok(h.runUntil(() => u.skill.active, 2)); + h.runUntil(() => log.some((x) => x.isAttack && x.e === e), 4); + const t0 = log.find((x) => x.isAttack && x.e === e).t; + h.run(0.9); + const cur = u.mem.leiziCurrents.slice(0, 4); + assert.deepEqual(cur.map((c) => [c.dr, c.dc]), [[1, 0], [0, 1], [-1, 0], [0, -1]], `T${tier}: up, right, down, left`); + // spawned 0, 0.28, 0.56, 0.84 s after the hit, 1 tile/s: the up one has risen (now − t0), the left one (now − t0 − 0.84) + approx(cur[0].y, 10 + (h.b.time - t0), `T${tier}: the up current`, 0.03); + approx(cur[3].x, 7 - (h.b.time - t0 - 0.84), `T${tier}: the left current`, 0.05); + h.run(2.3); + // the left one bounced before her tile (10,5): never past x 5.5; the up one bounced off row 13 (high) + assert.ok(cur[3].x > 5.5 - 1e-9, `T${tier}: off her tile`); + assert.ok(cur[0].y < 12.5 + 1e-9 && cur[0].dr === -1, `T${tier}: off the high row 13`); + h.run(1); + assert.ok(!u.mem.leiziCurrents.some((c) => cur.includes(c)), `T${tier}: gone after 3.1 s`); + const zap = log.filter((x) => tagged(x, 'leizi2:current')); + assert.ok(zap.length > 0 && zap.every((x) => x.e !== fly), `T${tier}: ground only`); + for (const x of zap) { approx(x.amount, x.atk * sk.bb['attack@atk_scale_current'] * x.mul, `T${tier}: ${sk.bb['attack@atk_scale_current'] * 100} %`); assert.equal(x.type, 'arts'); } + approx(zap[0].mul, 1.07, `T${tier}: × 1.07 while S3 runs`); + done(h); + } + // 战栗: about prob of the current hits while S3 runs, 3 s each + const { h, u, log } = field({ tier: 6, elite: true, skill: 2, seed: 9 }); + u.skill.gainSp(999); + for (const pos of [[10, 6], [10, 7], [9, 7], [11, 7], [10, 8], [12, 7], [9, 6]]) h.spawn('enemy_dummy', { pos }); + assert.ok(h.runUntil(() => u.skill.active, 2)); + h.run(23); + const zaps = log.filter((x) => tagged(x, 'leizi2:current')).length; + const tr = h.hooksOf('statusApplied').filter((c) => c.status === 'tremble' && c.source === u && c.duration === 3); + assert.ok(zaps > 60, `${zaps} current hits`); + assert.ok(tr.length / zaps > 0.04 && tr.length / zaps < 0.18, `${tr.length} / ${zaps} ≈ 10 %`); + done(h); +}); diff --git a/test/content/op_thorns.test.js b/test/content/op_thorns.test.js new file mode 100644 index 00000000..bc99c9b9 --- /dev/null +++ b/test/content/op_thorns.test.js @@ -0,0 +1,395 @@ +// test/content/op_thorns.test.js — the 自选 operator kit of 棘刺 (char_293_thorns, 6★ 领主; kit +// server/sim/content/kits/ops/op-thorns.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, LOR-X +// “盐与沙” or LOR-Δ “沙蚀” 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_thorns.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 THORNS = 'char_293_thorns'; +const FORMS = BACKUPS.units[THORNS].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const LORX = 'uniequip_002_thorns', LORD = 'uniequip_003_thorns'; +const S1 = 'skcom_atk_up[3]', S2 = 'skchr_thorns_2', S3 = 'skchr_thorns_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); +/** Talent 0's blackboard of a form with its module (the module talent change merged over the base). */ +const t0Of = (tier, elite, mod) => { + const base = formOf(tier, elite).talents.find((t) => t.index === 0).bb; + const ch = elite && mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0) : null; + return { ...base, ...(ch?.bb ?? {}) }; +}; +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_ranged: dummy('enemy_ranged', { applyWay: 'RANGED' }), + enemy_both: dummy('enemy_both', { applyWay: 'ALL' }), + // a stationary ranged attacker: 100 ATK every second on whoever stands within 2.5 + enemy_shooter: dummy('enemy_shooter', { atk: 100, range: 2.5, bat: 1 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, LORX, LORD].map((m) => [t, true, m]))]; + +/** A battle with 棘刺 as uid 1 at (row, col) facing RIGHT. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, seed = 5, others = [] } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'attack'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: THORNS, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const from = (h, u, pred = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && pred(c)); +const tagged = (c, t) => (c.dmg?.tags || []).includes(t); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +test('棘刺 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 3-12, blocks 2, a 领主 hitting air, physical, no 特质, ground enemies can target him', () => { + assert.equal(OPERATOR_KITS[THORNS], KITS[THORNS]); + 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], [THORNS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.sub, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat, u.profile.rangedScale], [2, 'lord', 'ranged', true, 'phys', 1.3, 0.8], `${label(f)}: 领主`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-12`); + assert.deepEqual([form.tokens, form.displayTokens, u.def.raw.tokens], [[], [], []], `${label(f)}: no summons`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`); + done(h); + } + } + // the numbers of the forms (zh_CN): E2 Lv1 2063 / 575 / 333, E2 Lv60 2427 / 665 / 379; LOR-X +160 / +39 / +5 → +260 / +55 / +7, + // LOR-Δ +80 / +60 / +5 → +150 / +75 / +7 (HP / ATK / ASPD) + 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], [2063, 575, 2427, 665]); + assert.deepEqual([modOf(5, LORX).attr, modOf(6, LORX).attr, modOf(5, LORD).attr, modOf(6, LORD).attr], + [{ maxHp: 160, atk: 39, aspd: 5 }, { maxHp: 260, atk: 55, aspd: 7 }, { maxHp: 80, atk: 60, aspd: 5 }, { maxHp: 150, atk: 75, aspd: 7 }]); +}); + +test('a 自选 pick: 棘刺 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(THORNS)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(THORNS), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: THORNS, skillIndex: 2, uniEquipId: LORD } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: THORNS, skillIndex: 2, uniEquipId: LORD } } }); +}); + +test('trait 领主: a ranged attack on an enemy two tiles ahead deals 80 % ATK, one on the tile in front 100 %; he hits a flyer', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const { h, u } = field({ tier, elite, skill: 0 }); + u.skill.sp = 0; // keep S1 off + const far = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.runUntil(() => from(h, u, (c) => c.dmg.isAttack && c.target === far).length > 0, 5); + approx(from(h, u, (c) => c.dmg.isAttack && c.target === far)[0].amount, u.s.atk * 0.8, `T${tier}: two tiles ahead`); + h.b.kill(far, null); + const near = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.runUntil(() => from(h, u, (c) => c.dmg.isAttack && c.target === near).length > 0, 5); + approx(from(h, u, (c) => c.dmg.isAttack && c.target === near)[0].amount, u.s.atk, `T${tier}: the tile in front`); + h.b.kill(near, null); + const fly = h.spawn('enemy_fly', { pos: [11, 6] }); + assert.ok(h.runUntil(() => from(h, u, (c) => c.dmg.isAttack && c.target === fly).length > 0, 5), `T${tier}: a flyer`); + done(h); + } +}); + +test('S1 攻击力强化·γ型 (MANUAL, data DEFAULT): ATK +45 % / +60 % for 30 s with an enemy in range', () => { + 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([u.skill.rule, sk.duration, sk.bb.atk, u.skill.spCost, sk.initSp], ['DEFAULT', 30, elite ? 0.6 : 0.45, elite ? 35 : 37, elite ? 10 : 5], `T${tier}`); + u.skill.gainSp(999); + h.run(2); + assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`); + h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01); + h.runUntil(() => !u.skill.active, 31); + approx(u.s.atk, u.base.atk, `T${tier}: back`); + done(h); + } +}); + +test('S2 护身尖刺 (MANUAL, 技能范围 3-1, data SKILL_RANGE): no attack, ATK / DEF up; an enemy\'s 普通伤害 sets off up to 4 spikes 0.5 s later on the 3-1 (air too) for 80 % ATK physical, at most every 0.8 / 0.75 s; his range stays 3-12', () => { + 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([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb.def, sk.bb.cooldown, sk.bb.max_target], + ['SKILL_RANGE', '3-1', elite ? 36 : 33, elite ? 0.4 : 0.3, elite ? 0.7 : 0.55, elite ? 0.75 : 0.8, 4], `T${tier}`); + u.skill.gainSp(999); + h.run(1); + assert.equal(u.skill.activations, 0, `T${tier}: nobody on the 3-1`); + // targets on the 3-1: (9,7) (11,7) (10,8) a flyer at (11,6); one outside it at (10,9) + const shooter = h.spawn('enemy_shooter', { pos: [10, 7] }); + const a = h.spawn('enemy_dummy', { pos: [9, 7] }), b = h.spawn('enemy_dummy', { pos: [11, 7] }); + const fly = h.spawn('enemy_fly', { pos: [11, 6] }), out = h.spawn('enemy_dummy', { pos: [10, 9] }); + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast with an enemy on the 3-1`); + 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.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: the card keeps his 3-12`); + const n0 = from(h, u).length, cast = h.b.time; + h.run(6); + assert.equal(from(h, u, (c) => c.dmg.isAttack && c.t >= cast).length, 0, `T${tier}: 停止攻击`); + const hurt = h.hooksOf('damaged').filter((c) => c.target === u && c.source === shooter); + const spikes = from(h, u, (c) => tagged(c, 'thorns:spike')).filter((c) => from(h, u).indexOf(c) >= n0); + assert.ok(hurt.length >= 4, `T${tier}: ${hurt.length} hits from the shooter`); + // each shooter hit (1 s apart, cooldown < 1 s) ⇒ one volley of 4 spikes on the four nearest 3-1 enemies, 0.5 s later + const volleys = new Map(); + for (const c of spikes) volleys.set(c.t, [...(volleys.get(c.t) ?? []), c]); + assert.ok(volleys.size >= hurt.length - 1, `T${tier}: ${volleys.size} volleys for ${hurt.length} hits`); + for (const [t, v] of volleys) { + assert.equal(v.length, 4, `T${tier}: 4 spikes at ${t}`); + assert.ok(v.every((c) => c.target !== out), `T${tier}: (10,9) is off the 3-1`); + for (const c of v) { approx(c.amount, u.s.atk * 0.8, `T${tier}: 80 % ATK`); assert.deepEqual([c.type, c.dmg.isSkill, !!c.dmg.isAttack], ['phys', true, false]); } + assert.ok(hurt.some((x) => Math.abs(x.t + 0.5 - t) < 0.05), `T${tier}: a hit 0.5 s before ${t}`); + } + assert.ok(spikes.some((c) => c.target === fly) && spikes.some((c) => c.target === a) && spikes.some((c) => c.target === b), `T${tier}: the flyer and the dummies`); + done(h); + } +}); + +test('S2: the cooldown (0.8 s) holds a second hit back; 持续 / 溅射 / element / 流失 / 无来源 damage and an ally\'s set nothing off; a stunned 棘刺 casts none', () => { + const { h, u } = field({ tier: 5, skill: 1 }); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 2)); + const spikes = () => from(h, u, (c) => tagged(c, 'thorns:spike')).length; + const hit = (o) => h.b.dealDamage(e, u, { amount: 1, type: 'phys', ...o }); + hit({ tags: ['dot'] }); hit({ tags: ['periodic'] }); hit({ isSplash: true }); hit({ sourceless: true }); + h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 10 }); + h.b.loseHp(u, 1, { source: e }); + h.b.dealDamage(u, u, { amount: 1, type: 'phys' }); + h.run(1); + assert.equal(spikes(), 0, 'none of those is an enemy\'s 普通伤害'); + hit({}); + h.run(0.3); + hit({}); + h.run(1); + assert.equal(spikes(), 1, 'the second hit fell inside the 0.8 s cooldown'); + hit({}); + h.b.applyStatus(u, 'stun', { duration: 1, source: e }); + h.run(0.8); + assert.equal(spikes(), 1, 'stunned before the spikes landed: none'); + h.run(1); + hit({}); + h.run(0.6); + assert.equal(spikes(), 2, 'after the stun: a volley again'); + done(h); +}); + +test('S3 至高之术 (MANUAL, attack SP, data ACTIVE_RANGE on 3-3): 30 s of range 3-3, ATK +33 % / +40 %, ASPD +14 / +16, no 80 % on ranged attacks; the second use of a deployment doubles both and never ends; a redeploy starts the count again', () => { + 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([u.skill.rule, sk.rangeId, sk.spType, sk.spCost, sk.duration, sk.bb.atk, sk.bb.attack_speed, sk.bb['thorns_s_3[b].atk'], sk.bb['thorns_s_3[b].attack_speed'], sk.bb['thorns_s_3[b].duration']], + ['ACTIVE_RANGE', '3-3', 'INCREASE_WHEN_ATTACK', 15, 30, elite ? 0.4 : 0.33, elite ? 16 : 14, elite ? 0.8 : 0.66, elite ? 32 : 28, -1], `T${tier}`); + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [11, 8] }); // on the 3-3, off the 3-12 + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: an enemy on the 3-3 casts it`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: range 3-3`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + assert.equal(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `T${tier}: ASPD`); + approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01); + h.runUntil(() => from(h, u, (c) => c.dmg.isAttack && c.target === e).length > 0, 3); + approx(from(h, u, (c) => c.dmg.isAttack && c.target === e)[0].amount, u.s.atk, `T${tier}: full ATK at range`); + h.runUntil(() => !u.skill.active, 31); + assert.deepEqual([u.liveRangeGrid, u.s.aspd], [formOf(tier, elite).rangeGrid, u.base.aspd], `T${tier}: back after 30 s`); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: the second use`); + approx(u.s.atk, u.base.atk * (1 + sk.bb['thorns_s_3[b].atk']), `T${tier}: ATK ×2`); + assert.equal(u.s.aspd, u.base.aspd + sk.bb['thorns_s_3[b].attack_speed'], `T${tier}: ASPD ×2`); + h.run(120); + assert.ok(u.skill.active, `T${tier}: 持续时间无限`); + // knocked out and back: the count restarts + h.b.kill(u, null); + h.step(); + assert.ok(h.b.redeploy(u) && u.alive && u.deployed, `T${tier}: redeployed`); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast again`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: the first-use numbers again`); + approx(u.skill.timeLeft, 30, `T${tier}: 30 s again`, 0.05); + done(h); + } +}); + +test('T1 神经腐蚀: an attack poisons its target — 3 ticks of 125 arts (250 on an enemy that attacks at range: RANGED / ALL), the first a second in, 持续伤害; a new hit refreshes it without a second instance', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const t0 = t0Of(tier, elite, null); + assert.deepEqual([t0['damage[normal]'], t0['damage[ranged]'], t0.duration], [125, 250, 3]); + for (const [key, dmg] of [['enemy_dummy', 125], ['enemy_ranged', 250], ['enemy_both', 250]]) { + const { h, u } = field({ tier, elite, skill: 0 }); + u.skill.sp = 0; + const e = h.spawn(key, { pos: [10, 6] }); + h.runUntil(() => from(h, u, (c) => c.dmg.isAttack).length > 0, 3); + const hitT = h.b.time; + u.atkCd = 99; // no further attack + h.run(4); + const ticks = from(h, u, (c) => tagged(c, 'thorns:poison')); + assert.equal(ticks.length, 3, `T${tier} ${key}: 3 ticks`); + approx(ticks[0].t - hitT, 1, `T${tier} ${key}: the first a second in`, 0.05); + for (const c of ticks) { + approx(c.amount, dmg, `T${tier} ${key}: ${dmg} arts`); + assert.deepEqual([c.type, c.dmg.canDodge, tagged(c, 'dot'), c.target], ['arts', false, true, e]); + } + done(h); + } + // refreshed by every hit: one poison per 棘刺, ticking once a second while he keeps attacking + const { h, u } = field({ tier, elite, skill: 0 }); + u.skill.sp = 0; + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(10.05); + assert.equal(e.buffs.filter((b) => b.key === `thorns:poison:${u.id}`).length, 1, `T${tier}: one instance`); + const ticks = from(h, u, (c) => tagged(c, 'thorns:poison')); + assert.ok(ticks.length >= 8 && ticks.length <= 10, `T${tier}: ${ticks.length} ticks in 10 s`); + assert.ok(ticks.every((c) => Math.abs(c.amount - 125) < 1e-6), `T${tier}: the value never changes`); + done(h); + } +}); + +test('T1 + LOR-X stage 3 "可叠加4次": the tick is 135 × the hits so far (≤ 4), 270 on a ranged enemy; stage 1 leaves the talent (125, one layer); a dodged hit poisons nothing', () => { + const t03 = t0Of(6, true, LORX), t01 = t0Of(5, true, LORX); + assert.deepEqual([t03['damage[normal]'], t03['damage[ranged]'], t03.max_cnt, t01['damage[normal]'], t01.max_cnt], [135, 270, 4, 125, undefined]); + for (const [tier, per, cap] of [[6, 135, 4], [5, 125, 1]]) { + const { h, u } = field({ tier, elite: true, mod: LORX, skill: 0 }); + u.skill.sp = 0; + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(12); + const amounts = from(h, u, (c) => tagged(c, 'thorns:poison')).map((c) => c.amount); + assert.ok(amounts.length >= 9, `T${tier}: ${amounts.length} ticks`); + assert.equal(Math.max(...amounts), per * cap, `T${tier}: up to ${cap} layers`); + assert.ok(amounts.every((a) => a % per === 0 && a <= per * cap), `T${tier}: multiples of ${per}`); + // a hit every 1.3 × 100 / 107 s, a tick every second: one layer more per tick until the cap + if (cap > 1) assert.deepEqual(amounts.slice(0, 4), [per, per * 2, per * 3, per * 4], `T${tier}: growing`); + done(h); + } + // a dodged attack: no poison + const { h, u } = field({ tier: 5, skill: 0 }); + u.skill.sp = 0; + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } }); + h.run(5); + assert.ok(h.hooksOf('attack').some((c) => c.attacker === u), 'he attacked'); + assert.equal(e.findBuff(`thorns:poison:${u.id}`), null, 'nothing hit, nothing poisoned'); + done(h); +}); + +test('LOR-X “盐与沙” (stages 1 / 3): every attack hit adds 10 % ATK arts (附加伤害); none without it', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + u.skill.sp = 0; + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(4); + const atks = from(h, u, (c) => c.dmg.isAttack), adds = from(h, u, (c) => tagged(c, 'thorns:lorx')); + const on = elite && mod === LORX; + assert.equal(adds.length, on ? atks.length : 0, `${label(f)}: ${adds.length} additions for ${atks.length} hits`); + if (on) { + assert.deepEqual(u.def.raw.trait.bb, { atk_scale: 0.8, atk_scale_m: 0.1 }, label(f)); + for (const c of adds) { approx(c.amount, u.s.atk * 0.1, `${label(f)}: 10 % ATK`); assert.deepEqual([c.type, tagged(c, 'addition')], ['arts', true]); } + } + done(h); + } +}); + +test('LOR-Δ “沙蚀” (stages 1 / 3): every damage instance he deals carries 10 % of it as 神经损伤; stage 3 poison: ×2.5 元素伤害 in a 神经 burst, and S2\'s spikes poison no more (stage 1 still does)', () => { + for (const [tier] of [[5], [6]]) { + const { h, u } = field({ tier, elite: true, mod: LORD, skill: 0 }); + assert.deepEqual(u.def.raw.trait.bb, { atk_scale: 0.8, ep_damage_ratio: 0.1 }, `T${tier}`); + u.skill.sp = 0; + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(6); + const dealt = from(h, u, (c) => c.type !== 'element' && c.amount > 0); + const fills = from(h, u, (c) => c.type === 'element' && c.dmg.element === 'neural'); + assert.ok(dealt.length > 3 && fills.length === dealt.length, `T${tier}: one fill per damage instance (${fills.length} / ${dealt.length})`); + for (let i = 0; i < 3; i++) approx(fills[i].dmg.amount, dealt[i].amount * 0.1, `T${tier}: 10 %`); + done(h); + } + // stage 3: the poison on an enemy in its 神经 burst + const t03 = t0Of(6, true, LORD); + assert.deepEqual([t03['damage[normal]'], t03['damage[ranged]'], t03.ep_break_multi], [300, 600, 2.5]); + { + const { h, u } = field({ tier: 6, elite: true, mod: LORD, skill: 0 }); + u.skill.sp = 0; + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.b.addBuff(e, { key: 'neuralBurst', duration: 30, flags: { burstLock: true } }); + h.run(4); + const ticks = from(h, u, (c) => tagged(c, 'thorns:poison')); + assert.ok(ticks.length >= 2, 'ticks'); + for (const c of ticks) { approx(c.amount, 300 * 2.5, '×2.5'); assert.deepEqual([c.type, c.dmg.element], ['elemental', 'neural']); } + // thorns_e_003_t has no independentCharacterSource: one poison per enemy, not one per 棘刺 + assert.deepEqual([!!e.findBuff('thorns:poison'), !!e.findBuff(`thorns:poison:${u.id}`)], [true, false], 'the shared poison'); + done(h); + } + // stage 1 has no burst multiplier: plain arts ticks + { + const { h, u } = field({ tier: 5, elite: true, mod: LORD, skill: 0 }); + u.skill.sp = 0; + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.b.addBuff(e, { key: 'neuralBurst', duration: 30, flags: { burstLock: true } }); + h.run(4); + for (const c of from(h, u, (x) => tagged(x, 'thorns:poison'))) { approx(c.amount, 125, 'stage 1: 125'); assert.equal(c.type, 'arts'); } + done(h); + } + // S2 spikes: they poison at stage 1 and without a module, never at LOR-Δ stage 3 + for (const [tier, mod, poisons] of [[6, LORD, false], [5, LORD, true], [6, LORX, true], [6, null, true]]) { + const { h, u } = field({ tier, elite: true, mod, skill: 1 }); + const shooter = h.spawn('enemy_shooter', { pos: [10, 7] }); + u.skill.gainSp(999); + h.run(4); + assert.ok(from(h, u, (c) => tagged(c, 'thorns:spike')).length > 0, `T${tier} ${mod}: spikes`); + assert.equal(!!(shooter.findBuff(`thorns:poison:${u.id}`) || shooter.findBuff('thorns:poison')), poisons, `T${tier} ${mod}: spikes poison`); + done(h); + } +}); + +test('T2 故土潮声: 生命回复速度 +3.5 % max HP/s (an hpRegenRatio buff, no 治疗: 禁疗 does not stop it) once his last attack is 2 s old — off while he attacks; S2\'s spikes are no 主动攻击', () => { + for (const f of [[5, false, null], [6, true, LORX]]) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + const r = formOf(tier, elite).talents.find((t) => t.index === 1).bb; + assert.deepEqual([r.delay, r.hp_recovery_per_sec_by_max_hp_ratio], [2, 0.035]); + assert.deepEqual(u.findBuff('talent:thorns:tide')?.mods, { hpRegenRatio: 0.035 }, `${label(f)}: on from the deployment`); + approx(u.s.hpRegen, u.s.maxHp * 0.035, `${label(f)}: 3.5 % max HP / s`); + u.skill.sp = 0; + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 3); + h.step(); + assert.equal(u.findBuff('talent:thorns:tide'), null, `${label(f)}: off while attacking`); + h.b.kill(e, null); + h.run(1.9); + assert.equal(u.findBuff('talent:thorns:tide'), null, `${label(f)}: not yet`); + h.run(0.2); + assert.ok(u.findBuff('talent:thorns:tide'), `${label(f)}: 2 s after his last attack`); + // 禁疗 (flag healFree) keeps heals off, not the regeneration attribute + h.b.addBuff(u, { key: 'test:healFree', flags: { healFree: true, noHeal: true } }); + u.hp = u.s.maxHp * 0.5; + h.run(1); + approx(u.hp, u.s.maxHp * (0.5 + 0.035), `${label(f)}: +3.5 % in a second under 禁疗`, 0.01); + done(h); + } + // S2: the spikes keep the regeneration on + const { h, u } = field({ tier: 5, skill: 1 }); + h.spawn('enemy_shooter', { pos: [10, 7] }); + u.skill.gainSp(999); + h.run(5); + assert.ok(from(h, u, (c) => tagged(c, 'thorns:spike')).length > 0, 'spikes'); + assert.ok(u.findBuff('talent:thorns:tide'), 'still on'); + done(h); +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index 31b1342b..e28d9381 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -3036,131 +3036,137 @@ "snaps": "2d9f8f73 d2731731 7ad6484b de506ad9 744afbb0 c80fca2f 66d47606 70a8ba35 d4329acf 5cb1fdbb 0f020029 88de71e9 8762bfc7 f80cf797 62fea1f7 63c7f32f 93829e16 17ab53e2 24bc8145 571b2e52" }, "diy-071": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=wisdel/S1 6_diy2_b=wisdel/S2 6_diy1_b=wisdel/S3 6_diy2_b=wisdel/S1/002_wisdel 6_diy1_b=wisdel/S2/002_wisdel 6_diy2_b=wisdel/S3/002_wisdel 5_diy1_a=csnipe 5_diy2_b=csnipe", - "fast": true, + "about": "5_diy1_a=thorns/S1 5_diy2_a=thorns/S2 5_diy1_a=thorns/S3 5_diy2_b=thorns/S1 5_diy1_b=thorns/S2 5_diy2_b=thorns/S3 6_diy1_b=wisdel/S1 6_diy2_b=wisdel/S2 6_diy1_b=wisdel/S3 6_diy2_b=wisdel/S1/002_wisdel 6_diy1_b=wisdel/S2/002_wisdel 6_diy2_b=wisdel/S3/002_wisdel", + "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_multi_funny", "round": 13, "seed": 1743045998, "waveId": "act1autochess_h06", - "time": 200.23, - "ticks": 6007, + "time": 199.6, + "ticks": 5988, "reason": "cleared", "killed": 129, "total": 129, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 2278, + "rngDraws": 2124, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":129,"total":129,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1092882,"bossDmg":0,"heal":0,"deaths":1,"layerGains":{}}}, + "players": {"p1":{"killed":129,"total":129,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1088442,"bossDmg":0,"heal":2331,"deaths":2,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,2128,1,3,0,0,0,0,0,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",2908,1,6,0,0,0,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,9,0,0,0,0,0,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,12,1,5167,3334,0,84,3], - ["p1",5,"op","chess_char_6_diy1_b",0,"none",1735,1,14,5,32370,0,0,51,12], - ["p1",6,"op","chess_char_6_diy2_b",1,"none",1735,1,15,32,250298,0,0,118,4], - ["p1",7,"op","chess_char_6_diy1_b",2,"none",1735,1,1,0,0,0,0,0,1], - ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_002_wisdel",1735,1,4,6,57148,0,0,67,16], - ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_002_wisdel",1735,1,7,40,280613,0,0,121,4], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_wisdel",1735,1,10,41,411715,0,0,83,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,13,0,4500,0,0,192,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,16,0,5660,0,0,192,3], + ["p1",1,"op","chess_char_5_diy1_a",0,null,2063,1,3,0,0,0,0,0,0], + ["p1",2,"op","chess_char_5_diy2_a",1,null,2063,1,6,0,657,0,0,9,2], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2063,1,9,1,16110,0,0,164,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1215,1,12,0,15367,1212,0,151,3], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1970,1,13,4,13271,2788,2331,72,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,94,0,5711,4854,0,57,2], + ["p1",7,"op","chess_char_6_diy1_b",0,"none",1735,1,14,4,16223,0,0,33,8], + ["p1",8,"op","chess_char_6_diy2_b",1,"none",1735,1,15,33,248150,0,0,118,4], + ["p1",9,"op","chess_char_6_diy1_b",2,"none",1735,1,1,0,0,0,0,0,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_wisdel",1735,1,4,3,35260,0,0,31,7], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_wisdel",1735,1,7,40,301670,0,0,120,4], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_wisdel",1735,1,10,40,399205,0,0,83,3], ["p1","~1","token","token_10035_wisdel_wward",null,null,3500,1,2,0,0,0,0,0,0], - ["p1","~2","token","token_10035_wisdel_wward",null,null,3500,1,5,3,11577,0,0,0,36], - ["p1","~3","token","token_10035_wisdel_wward",null,null,3500,1,8,1,16565,0,0,0,48], - ["p1","~4","token","token_10035_wisdel_wward",null,null,3500,1,11,0,17269,0,0,0,51], + ["p1","~2","token","token_10035_wisdel_wward",null,null,3500,1,5,1,6044,0,0,0,15], + ["p1","~3","token","token_10035_wisdel_wward",null,null,3500,1,8,0,15646,0,0,0,47], + ["p1","~4","token","token_10035_wisdel_wward",null,null,3500,1,11,1,15129,0,0,0,48], ["p1","~5","token","token_10035_wisdel_wward",null,null,3500,1,41,0,0,0,0,0,0] ], - "enemies": {"enemy_10124_uashld_2":[18,18,0,2568,432000],"enemy_1043_zomsbr":[66,66,0,0,167717],"enemy_1061_zomshd":[45,45,0,766,525605]}, - "hooks": [1,146,6007,914,914,10408,10408,17595,130,130,184,180,15,39811,2202,6,129,0,1,130,0,0,0], - "events": {"atk":1255,"deploy":17,"die":130,"dmg":10408,"fx":463,"skill":364,"spawn":146,"status":1118}, - "fx": {"aoe":314,"camouflage":1,"overload":8,"strike":135,"summon":5}, - "snaps": "26a95a14 72d3b786 58cfa207 888fb447 256e095a a5cf08bf 079bec13 5ccf0eec 51fdcabe bcc4a5ff d1d1a0dd 45764c99 a1ed7dbd 71926661 3b3af91d fa2cd3b9 c0f99870 8c20c23f fbba0542 b59e13f6" + "enemies": {"enemy_10124_uashld_2":[18,18,0,7491,432000],"enemy_1043_zomsbr":[66,66,0,22,165653],"enemy_1061_zomshd":[45,45,0,1341,519554]}, + "hooks": [1,147,5988,871,871,11238,11238,16721,131,131,149,144,15,33988,1855,30,129,0,1,131,0,0,0], + "events": {"atk":1194,"deploy":18,"die":131,"dmg":11238,"fx":415,"skill":293,"spawn":146,"status":1195}, + "fx": {"aoe":283,"camouflage":1,"overload":8,"strike":110,"summon":5,"thorns":8}, + "snaps": "9847a5a5 564a7bc0 1e8dc4d6 7cf1b445 7ecfe44f efddcc39 dead53bd 2a30c02a 2da1518a 89d8ad3b 9fc7a9db 7ba6918f 7f2105fa 28b15aaa 05b765f0 0e9e3f0d 1e7e0e51 8139d8c9 1439c789 f4c1958b" }, "diy-072": { - "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=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "about": "5_diy1_b=thorns/S1/002_thorns 5_diy2_b=thorns/S2/002_thorns 5_diy1_b=thorns/S3/002_thorns 5_diy2_b=thorns/S1/003_thorns 5_diy1_b=thorns/S2/003_thorns 5_diy2_b=thorns/S3/003_thorns 6_diy1_a=thorns/S1 6_diy2_a=thorns/S2 6_diy1_a=thorns/S3 6_diy2_b=thorns/S1 6_diy1_b=thorns/S2 6_diy2_b=thorns/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m06", "modeId": "mode_single_hard", - "round": 12, + "round": 13, "seed": 3384200484, - "waveId": "act1autochess_h05", - "time": 204.6, - "ticks": 6138, + "waveId": "act1autochess_h06", + "time": 217.97, + "ticks": 6539, "reason": "cleared", - "killed": 19, - "total": 117, + "killed": 0, + "total": 78, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 56, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":19,"total":117,"perfect":false,"leaks":98,"leaksUncounted":0,"coins":0,"dmg":159729,"bossDmg":0,"heal":16045,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":78,"perfect":false,"leaks":78,"leaksUncounted":0,"coins":0,"dmg":218895,"bossDmg":0,"heal":3785,"deaths":19,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,93,0,7137,11030,0,39,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,8,31289,0,0,0,7], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,128,4,20803,5712,0,33,1], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,113,3,20962,7381,0,44,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,120,2,39208,8511,0,56,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,99,0,4203,2266,0,9,2], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,92,1,23030,2720,0,52,2], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,88,0,0,5014,7904,23,2], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,87,0,0,4315,8141,17,2], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,134,0,4227,2796,0,14,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,142,1,8871,5676,0,25,1] + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_thorns",0,0,78,0,8845,5174,0,21,1], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_thorns",2587,1,7,0,21334,0,0,63,3], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_thorns",2587,1,9,0,112925,0,0,193,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_thorns",0,0,92,0,4064,7521,0,19,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_thorns",0,0,87,0,25959,6385,1371,15,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_thorns",2507,1,101,0,11046,5643,629,39,2], + ["p1",7,"op","chess_char_6_diy1_a",0,null,2063,1,99,0,2732,4126,0,13,0], + ["p1",8,"op","chess_char_6_diy2_a",1,null,0,0,61,0,3878,4845,719,10,1], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,59,0,3585,4126,0,17,0], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,1,0,7071,2427,0,20,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,3,0,3922,3493,1066,3,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,63,0,13534,4854,0,48,2] ], - "enemies": {"enemy_1006_shield_3":[18,0,18,24196,19254],"enemy_1043_zomsbr":[72,19,53,8536,91885],"enemy_1402_tgshd_2":[27,0,27,22691,48590]}, - "hooks": [1,142,6138,532,532,643,627,7310,42,140,21,21,0,20309,38,41,117,98,1,45,0,0,16], - "events": {"atk":532,"deploy":25,"die":42,"dmg":627,"fx":240,"heal":40,"leak":98,"skill":42,"spawn":129,"status":119}, - "fx": {"dodge":16,"dp":20,"expose":143,"slash":61}, - "snaps": "edadf2fb 7583fb4c cfa55f1f 6c4b397c 562ed971 5a325195 87a1cfde 57e48e44 6e31121b c85b4fa4 ddd106d8 87048e9a bfc4e6fe 8ce79edc 6c260053 c3c5a3a1 b83e6c72 be4087c8 ae11705c 3a471c74 89ab6038" + "enemies": {"enemy_1045_hammer_2":[18,0,18,16769,32318],"enemy_1061_zomshd":[60,0,60,31825,186577]}, + "hooks": [1,101,6539,598,598,1915,2344,23516,19,97,14,13,0,9782,3,40,78,78,1,19,0,0,0], + "events": {"atk":598,"deploy":23,"die":19,"dmg":2344,"fx":27,"leak":78,"skill":27,"spawn":90,"status":6}, + "fx": {"thorns":27}, + "snaps": "f251b603 b64b2167 b37b9261 d5617ca3 e3a1fd94 8ce6f2e1 84292712 75cc7040 bd4fecc0 e41e936d f1e2a3e5 f770e310 dfa9d1b8 b7d6fac2 3952c796 5accfaf7 4d9eb087 94016144 1f3df616 5a8fd859 e66b35aa 76e4018a" }, "diy-073": { - "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "6_diy1_b=thorns/S1/002_thorns 6_diy2_b=thorns/S2/002_thorns 6_diy1_b=thorns/S3/002_thorns 6_diy2_b=thorns/S1/003_thorns 6_diy1_b=thorns/S2/003_thorns 6_diy2_b=thorns/S3/003_thorns 5_diy1_a=chen3/S1 5_diy2_a=chen3/S2 5_diy1_a=chen3/S3 5_diy2_b=chen3/S1 5_diy1_b=chen3/S2 5_diy2_b=chen3/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m07", "modeId": "mode_multi_normal", - "round": 12, + "round": 13, "seed": 2756955908, - "waveId": "act1autochess_h05", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 7, - "total": 90, + "waveId": "act1autochess_h06", + "time": 205.07, + "ticks": 6152, + "reason": "cleared", + "killed": 68, + "total": 153, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 860, + "rngDraws": 131, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":7,"total":90,"perfect":false,"leaks":83,"leaksUncounted":0,"coins":0,"dmg":565936,"bossDmg":0,"heal":37862,"deaths":15,"layerGains":{}}}, + "players": {"p1":{"killed":68,"total":153,"perfect":false,"leaks":85,"leaksUncounted":0,"coins":0,"dmg":822552,"bossDmg":0,"heal":4609,"deaths":18,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,116,1,23376,5595,0,42,1], - ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,115,3,87314,10246,0,44,2], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,117,2,37753,19555,0,152,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,114,0,16429,26745,0,182,0], - ["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,4,0,16123,0,0,113,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,7,1,39656,0,0,130,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,10,0,77688,0,0,51,2], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,13,0,89279,0,0,48,2], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,14,0,0,0,0,0,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,5,0,0,0,37862,58,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,8,0,73430,0,0,157,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,11,0,104888,0,0,161,3], + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_thorns",0,0,143,5,19764,5374,0,25,1], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_thorns",0,0,182,2,13255,8566,505,13,1], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_thorns",0,0,112,9,16606,5374,0,28,1], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_thorns",0,0,176,4,18098,7731,0,25,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_thorns",0,0,107,0,9833,5671,517,15,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_thorns",0,0,100,1,9802,5154,0,14,0], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,165,0,5512,6633,0,15,0], + ["p1",8,"op","chess_char_5_diy2_a",1,null,2211,1,175,2,3,0,0,0,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,2211,1,5,5,179400,0,0,15,3], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",2674,1,7,0,12,0,0,7,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,117,5,86486,5304,2630,51,4], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",2674,1,11,35,463782,957,957,85,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_1116_liprr_2":[27,6,21,7860,157842],"enemy_1119_vofsd":[39,1,37,25542,243517],"enemy_1121_lifbos_2":[24,0,24,28739,164577]}, - "hooks": [1,117,6900,1235,1235,1585,1650,4412,22,104,20,26,0,32957,7577,27,90,82,1,22,0,0,0], - "events": {"atk":1361,"deploy":25,"die":22,"dmg":1619,"fx":49,"heal":68,"leak":82,"skill":59,"spawn":104,"status":941}, - "fx": {"aoe":6,"displace":2,"dodge":9,"liberate":20,"spGain":12}, - "snaps": "47ef258d f9e9aba1 190ac938 372c5487 ed5a908a 815460f1 55cea32d f76bcba9 6667fc01 24413ad2 2fd382d9 19e2cfc3 759441ca acbe5115 f94bb0ba e8ea30b1 ce1bcacd b79a43bf 180f16ed 40dda8e9 ae9ed60a a9074d2a 9e3ce0d0" + "enemies": { + "enemy_1197_sfshu": [15,10,5,1199,71664], + "enemy_1197_sfshu_2": [39,10,29,7107,178806], + "enemy_1198_msfshu": [30,15,15,0,57], + "enemy_1198_msfshu_2": [30,27,3,0,108], + "enemy_9010_acpupp": [39,6,33,42458,571918] + }, + "hooks": [1,182,6152,381,381,1003,1085,465,86,171,18,18,0,16107,5,46,153,85,1,92,0,0,8], + "events": {"atk":405,"deploy":27,"die":86,"dmg":1085,"fx":437,"heal":4,"leak":85,"skill":36,"spawn":167,"status":8}, + "fx": {"blink":5,"dash":12,"dodge":8,"ember":6,"shield":131,"shieldBreak":2,"slash":243,"summon":20,"telegraph":6,"thorns":4}, + "snaps": "8a3cfafb d17ada74 14edbc58 d8fa1fa9 21fc9472 5547dd82 cb7436e8 fe09dc23 b431d4f8 f267feb6 d47f7c8e e1e078a5 0ddd76df f2940500 cf2f3746 7ebd9419 0b8f7618 0d8de4bd c44f12f2 254b7d70 51f2cc5a" }, "diy-074": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc", + "about": "5_diy1_b=chen3/S1/002_chen3 5_diy2_b=chen3/S2/002_chen3 5_diy1_b=chen3/S3/002_chen3 6_diy1_a=chen3/S1 6_diy2_a=chen3/S2 6_diy1_a=chen3/S3 6_diy2_b=chen3/S1 6_diy1_b=chen3/S2 6_diy2_b=chen3/S3 6_diy1_b=chen3/S1/002_chen3 6_diy2_b=chen3/S2/002_chen3 6_diy1_b=chen3/S3/002_chen3", "fast": false, "kind": "normal", "stageId": "act2autochess_m01", @@ -3168,70 +3174,276 @@ "round": 13, "seed": 102712569, "waveId": "act1autochess_h06", - "time": 221.87, - "ticks": 6656, + "time": 226.5, + "ticks": 6795, "reason": "cleared", - "killed": 8, + "killed": 21, "total": 105, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 205, + "rngDraws": 72, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":8,"total":105,"perfect":false,"leaks":97,"leaksUncounted":0,"coins":0,"dmg":305544,"bossDmg":0,"heal":25311,"deaths":24,"layerGains":{}}}, + "players": {"p1":{"killed":21,"total":105,"perfect":false,"leaks":84,"leaksUncounted":0,"coins":0,"dmg":566712,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,73,0,203,5208,0,4,0], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,72,0,168,6862,0,4,0], - ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,117,0,11702,4587,0,5,1], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,123,1,87482,6798,0,8,2], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,80,1,16290,5276,0,15,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,8,1,27795,17209,0,14,1], - ["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,130,0,19769,2544,0,30,1], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,91,1,66539,3286,0,45,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,4,4,49537,1372,0,11,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,66,0,26060,6199,0,6,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,112,0,0,4229,9625,8,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,129,0,0,9133,15687,16,1] + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_chen3",2914,1,132,1,21366,5828,0,14,1], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_chen3",0,0,116,2,42372,5828,0,8,1], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_chen3",0,0,131,4,216393,8742,0,26,1], + ["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,125,0,1102,4422,0,3,0], + ["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,74,1,31896,4422,0,2,1], + ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,5,0,31583,2211,0,9,1], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,77,1,7760,5348,0,8,1], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,48,4,40661,2674,0,8,1], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,2,6,114218,2674,0,21,2], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_chen3",0,0,81,0,1053,6188,0,2,0], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_chen3",0,0,75,2,53661,6188,0,20,1], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_chen3",0,0,68,0,4647,6188,0,9,0] ], - "enemies": {"enemy_1229_darmy":[27,5,22,10928,81346],"enemy_1229_darmy_2":[57,3,54,20729,130893],"enemy_1275_dwlock_2":[21,0,21,41047,93304]}, - "hooks": [1,130,6656,287,287,718,780,47,32,129,11,11,0,10225,0,35,105,97,1,102,0,6,0], - "events": {"atk":354,"deploy":25,"die":32,"dmg":780,"fx":84,"heal":47,"leak":97,"skill":22,"spawn":117,"status":18}, - "fx": {"aoe":10,"beam":36,"burst":6,"dp":4,"explode":2,"slash":24,"spGain":2}, - "snaps": "0d9dfdaa 32539bfa 43860568 067d2c23 bef0b9e3 3d406805 b49ce9e9 b869cbb4 25e59436 d8053893 f537a887 b66f5aa1 af47af96 de57bc15 7c78eae1 e7a644ec 1a687f01 b8c42e5f 6b4fb3ce 2ec2ec96 20f31e31 d63d7191 fb610efc" + "enemies": {"enemy_1229_darmy":[27,6,21,587,90296],"enemy_1229_darmy_2":[57,10,47,10226,206501],"enemy_1275_dwlock_2":[21,5,16,49900,269915]}, + "hooks": [1,132,6795,211,211,513,539,60,43,127,10,10,0,7956,12,29,105,84,1,43,0,1,21], + "events": {"atk":227,"deploy":27,"die":43,"dmg":539,"fx":244,"leak":84,"skill":20,"spawn":117,"status":8}, + "fx": {"beam":45,"blink":4,"burst":1,"dash":8,"dodge":21,"shield":60,"slash":105}, + "snaps": "45e32aa4 9e2f1376 c0152cae 9f741ff8 cc554332 cd3d8d95 e73ef0f3 3b182583 b13290ac 1ad128dd 247f28ef 02d233e2 e284f0bc dad6ce0b b37b0587 de541236 7ed7a8d3 fd46f9c7 41d01aa4 1c43a222 8635abfb 3725fd22 80d163ed" }, "diy-075": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "5_diy1_a=leizi2/S1 5_diy2_a=leizi2/S2 5_diy1_a=leizi2/S3 5_diy2_b=leizi2/S1 5_diy1_b=leizi2/S2 5_diy2_b=leizi2/S3 5_diy1_b=leizi2/S1/002_leizi2 5_diy2_b=leizi2/S2/002_leizi2 5_diy1_b=leizi2/S3/002_leizi2 6_diy1_a=leizi2/S1 6_diy2_a=leizi2/S2 6_diy1_a=leizi2/S3", "fast": false, "kind": "normal", "stageId": "act2autochess_m02", "modeId": "mode_single_normal", - "round": 13, + "round": 12, "seed": 3770869562, - "waveId": "act1autochess_h06", - "time": 212, - "ticks": 6360, + "waveId": "act1autochess_h05", + "time": 184.87, + "ticks": 5546, "reason": "cleared", - "killed": 7, - "total": 102, + "killed": 149, + "total": 153, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 178, + "rngDraws": 1810, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":7,"total":102,"perfect":false,"leaks":95,"leaksUncounted":0,"coins":0,"dmg":328833,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}}, + "players": {"p1":{"killed":149,"total":153,"perfect":false,"leaks":4,"leaksUncounted":0,"coins":0,"dmg":1261456,"bossDmg":0,"heal":0,"deaths":3,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,126,1,26885,5595,0,29,1], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",2344,1,129,2,65444,5138,0,27,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,128,0,34841,10386,0,49,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",120.14,1,127,3,67065,11752,0,80,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,6,0,52776,0,0,114,2], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,7,1,81822,0,0,136,3], + ["p1",1,"op","chess_char_5_diy1_a",0,null,2288.89,1,3,15,68115,917,0,0,9], + ["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,4,5,30933,3206,0,19,2], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2224.71,1,5,17,111591,981,0,15,3], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1773.07,1,7,11,121111,1869,0,0,14], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,11,33,206266,3642,0,53,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",3579.88,1,157,10,149324,3704,0,14,3], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_leizi2",2242.29,1,14,13,128441,1625,0,0,15], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_leizi2",2797.44,1,8,10,83970,1070,0,27,2], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_leizi2",2645.54,1,12,13,164793,1221,0,18,3], + ["p1",10,"op","chess_char_6_diy1_a",0,null,3062.4,1,9,1,27992,144,0,0,7], + ["p1",11,"op","chess_char_6_diy2_a",1,null,3125.94,1,10,17,133890,80,0,30,2], + ["p1",12,"op","chess_char_6_diy1_a",2,null,3206,1,6,4,35032,0,0,5,3], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] ], - "enemies": {"enemy_1267_nhpbr_2":[27,6,21,7965,101509],"enemy_1270_nhstlk_2":[36,1,35,5072,104987],"enemy_9006_actoxi":[39,0,39,19834,122338]}, - "hooks": [1,129,6360,551,551,755,785,0,28,123,7,22,0,10737,5368,32,102,95,1,28,0,0,8], - "events": {"atk":572,"deploy":25,"die":28,"dmg":720,"fx":50,"leak":95,"skill":47,"spawn":110,"status":709}, - "fx": {"displace":32,"dodge":12,"zone":6}, - "snaps": "219bb3e6 15948f1c 968d0d57 4e777f00 76716f4d d8dae95c a1a991dc 5c7c06a7 3ad889af 10e3e009 8f5f1c4b a6a6d4ea 4d2f29e9 1b0021ec c8db6150 d2e30a7c c0e91e16 4106ca50 3ccfeb13 e5e829bb 56a2c338 72359c7e" + "enemies": {"enemy_1249_lysdb_2":[60,60,0,1488,559872],"enemy_1251_lysyta":[63,63,0,334,269438],"enemy_1329_cbshld":[30,26,4,16637,432145]}, + "hooks": [1,168,5546,313,313,2173,2113,0,152,156,65,61,0,39328,293,121,153,4,1,152,0,0,0], + "events": {"atk":536,"deploy":13,"die":152,"dmg":2113,"fx":1586,"leak":4,"skill":126,"spawn":167,"status":306}, + "fx": {"aoe":45,"lightning":1429,"pulse":52,"shieldBreak":60}, + "snaps": "279180c1 ec59986c 8724f011 2c0b4ef4 0ef558f2 84e4a729 66364554 c0b920f3 6e7185e5 79a82568 49b6ed9f 7493c0a1 f161d189 3b2e9b0a 7a7d89d9 3cf87903 b2fb1d51 9322cda8 bdcbf5e7" + }, + "diy-076": { + "about": "6_diy1_b=leizi2/S1 6_diy2_b=leizi2/S2 6_diy1_b=leizi2/S3 6_diy2_b=leizi2/S1/002_leizi2 6_diy1_b=leizi2/S2/002_leizi2 6_diy2_b=leizi2/S3/002_leizi2 5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m03", + "modeId": "mode_multi_abyss", + "round": 13, + "seed": 1116500759, + "waveId": "act1autochess_h06", + "time": 221.37, + "ticks": 6641, + "reason": "cleared", + "killed": 8, + "total": 144, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 704, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":8,"total":144,"perfect":false,"leaks":136,"leaksUncounted":0,"coins":0,"dmg":682344,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_b",0,"none",2232.16,1,175,1,100633,8694,0,0,7], + ["p1",2,"op","chess_char_6_diy2_b",1,"none",0,0,114,1,49891,7284,0,10,2], + ["p1",3,"op","chess_char_6_diy1_b",2,"none",3642,1,176,1,91439,7284,0,7,2], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_leizi2",0,0,133,0,102305,8004,0,0,7], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_leizi2",0,0,145,0,63113,8004,0,8,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_leizi2",0,0,165,5,251964,8004,0,9,2], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,90,0,1356,4256,0,6,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,81,0,647,5816,0,9,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,2564,1,174,0,5994,5128,0,12,0], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,95,0,6114,6668,0,8,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,158,0,4551,3813,0,14,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,152,0,4336,4530,0,14,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_1251_lysyta_2":[51,8,43,10477,247839],"enemy_1252_lysytb_2":[54,0,54,37950,262303],"enemy_1329_cbshld_2":[39,0,39,29059,172202]}, + "hooks": [1,176,6641,280,280,1112,1112,0,34,170,22,22,0,13611,164,65,144,136,1,34,0,0,0], + "events": {"atk":316,"deploy":29,"die":34,"dmg":1112,"fx":293,"leak":136,"skill":44,"spawn":159,"status":227}, + "fx": {"aoe":11,"lightning":266,"pulse":16}, + "snaps": "b6e40abe f0db8570 c90e6e20 e302c918 dfa05526 3d4b8649 85bc75bd 172418e4 a05afe46 4e4a8c54 12204c69 9175c57b 23f62e93 06bc7066 fee8a47b 9f03d8c8 094d031e 1727f1eb 90766837 fc7af1c2 7af0403b 904c6c85 74a2d458" + }, + "diy-077": { + "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=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_funny", + "round": 12, + "seed": 489962961, + "waveId": "act1autochess_h05", + "time": 216.43, + "ticks": 6493, + "reason": "cleared", + "killed": 28, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 119, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":28,"total":114,"perfect":false,"leaks":86,"leaksUncounted":0,"coins":0,"dmg":155075,"bossDmg":0,"heal":4683,"deaths":26,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,131,3,15003,5208,0,69,1], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,124,1,24440,6742,0,66,2], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,132,4,18564,3058,0,18,2], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,141,1,5622,5712,0,13,2], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,96,0,2043,5276,0,4,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,90,4,29193,6822,0,23,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,3,6,13608,1133,0,37,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,4,4,26194,1360,0,54,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,128,0,0,5310,3125,8,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,125,0,0,7149,1558,6,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,140,4,12459,1864,0,36,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,142,1,7948,3393,0,19,1], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1019_jshoot_2":[51,14,37,7067,50822],"enemy_1381_winman":[27,11,16,343,68509],"enemy_1389_winbab_2":[36,3,33,45616,71345]}, + "hooks": [1,142,6493,1168,1168,2271,2234,14,54,140,14,14,0,15903,50,49,114,86,1,71,0,0,37], + "events": {"atk":1168,"deploy":26,"die":54,"dmg":2234,"fx":125,"heal":14,"leak":86,"skill":28,"spawn":128,"status":104}, + "fx": {"aoe":67,"dodge":37,"dp":9,"slash":12}, + "snaps": "a8ebaa0a c7a3f563 86d849aa c41775ad 4cd063ec 661332c4 3a9b60a3 6c16de62 51354213 50776bdd 1fcc01fb a21d8b42 d9ca94c1 1bd48914 49095136 084282f3 de456c61 1d5569a8 88d801e3 38867e16 f557042a 2ccda668" + }, + "diy-078": { + "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m01", + "modeId": "mode_single_hard", + "round": 12, + "seed": 2130619692, + "waveId": "act1autochess_h05", + "time": 211.13, + "ticks": 6334, + "reason": "cleared", + "killed": 7, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 48, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":7,"total":114,"perfect":false,"leaks":107,"leaksUncounted":0,"coins":0,"dmg":64050,"bossDmg":0,"heal":2112,"deaths":32,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,7,0,5325,2016,0,7,1], + ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,9,4,22936,2374,0,14,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,151,0,7836,11540,0,12,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,150,1,16367,13032,0,22,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,97,0,1239,2544,0,5,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,5,2,3182,1765,0,5,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,11,0,1663,1608,0,1,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,13,0,3986,1749,0,4,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,6,0,0,1224,777,3,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,78,0,0,4163,1335,3,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,71,0,789,2320,0,2,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,8,0,727,1642,0,1,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_1019_jshoot_2":[51,3,48,6735,13561],"enemy_1381_winman":[27,4,23,274,48141],"enemy_1389_winbab_2":[36,0,36,38969,2348]}, + "hooks": [1,151,6334,389,389,313,328,8,39,146,5,24,0,6349,19,23,114,107,1,39,0,0,0], + "events": {"atk":393,"deploy":34,"die":39,"dmg":314,"fx":8,"heal":7,"leak":107,"skill":36,"spawn":129,"status":35}, + "fx": {"displace":6,"spGain":2}, + "snaps": "cbc574c2 514473e2 75591f1c cfb07d82 a78b7245 8f48f6aa be3b725d 921fad95 439e2f43 d25dc3db 519fe02f f01841f2 1a6f7d56 55197d41 f972b250 b95a084c b5ed1052 522fa670 7e2846cd df5e04a5 17e0c749 a7b2c538" + }, + "diy-079": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_multi_normal", + "round": 13, + "seed": 1503726279, + "waveId": "act1autochess_h06", + "time": 220.87, + "ticks": 6626, + "reason": "cleared", + "killed": 18, + "total": 111, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 741, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":18,"total":111,"perfect":false,"leaks":93,"leaksUncounted":0,"coins":0,"dmg":1112925,"bossDmg":0,"heal":16745,"deaths":12,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,124,0,403,7812,0,12,0], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,91,0,9431,7244,0,46,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1529,1,117,0,20345,1529,0,41,4], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1904,1,113,3,123004,5991,0,33,7], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1249.23,1,10,5,61613,8772,0,61,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,132,3,65671,6743,0,39,2], + ["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,5,1,34182,0,0,89,2], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,8,0,236086,0,0,194,3], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,6,4,232286,0,0,79,2], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,9,2,329906,0,0,84,3], + ["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,114,0,0,4128,1680,4,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,77,0,0,2928,15065,20,2] + ], + "enemies": {"enemy_1010_demon":[54,8,46,28411,532284],"enemy_1320_wdrrl_2":[18,0,18,14717,193787],"enemy_1422_lrsldr_2":[39,10,29,2019,386854]}, + "hooks": [1,132,6626,818,818,2133,2113,42,30,123,30,28,0,37558,0,25,111,93,1,34,0,0,20], + "events": {"atk":1045,"deploy":21,"die":30,"dmg":2113,"fx":194,"heal":36,"leak":93,"skill":58,"spawn":123,"status":22}, + "fx": {"aoe":68,"dodge":20,"dp":14,"explode":4,"slash":88}, + "snaps": "a44439af 086db0a0 a68e4f3a 84f700cd 7a9ed117 7e3f7e4f 35f4764e 45919dbf 8d33b48a 809ac470 2a21b044 0d9e3e70 4aff1df9 12c0a2e9 19050d4e ecd037c6 cf2f56ed 4dc0e234 f974bf40 2bcc5e1a 38d67c2c 029d3b9d" + }, + "diy-080": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m03", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 3144258890, + "waveId": "act1autochess_h06", + "time": 204.37, + "ticks": 6131, + "reason": "cleared", + "killed": 2, + "total": 109, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 107, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":2,"total":109,"perfect":false,"leaks":107,"leaksUncounted":0,"coins":0,"dmg":252435,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,136,0,12809,5595,0,27,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,80,2,35608,4988,0,25,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,134,0,27421,10386,0,46,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,137,0,54545,11872,0,58,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,6,0,43256,0,0,80,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,9,0,78795,0,0,108,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_1195_sfyin":[39,2,37,9962,63124],"enemy_1196_msfyin":[4,0,4,0,0],"enemy_1197_sfshu":[48,0,48,16642,92959],"enemy_1199_sfjin":[18,0,18,6237,96351]}, + "hooks": [1,137,6131,461,461,559,589,0,24,131,6,23,0,10394,3192,29,109,107,1,24,0,0,0], + "events": {"atk":470,"deploy":25,"die":24,"dmg":553,"fx":16,"leak":107,"skill":48,"spawn":118,"status":562}, + "fx": {"displace":13,"dodge":1,"summon":2}, + "snaps": "40212f08 7ee7edf5 4e04eafe 1553e3a7 99a50525 30be47aa 60aae4c8 e173c070 f9824ca4 6f4c6d3e a164bbd5 974997f8 450b5a36 a6a2e8fa 582b939d 71eab21c e8b519ac 7bfb7ce5 da5081ed f7ada234 fcc66ab5" } } }