From 541048fed69696f2603b40e0ed1abade769fd34c Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 00:08:17 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20O1:=20=E8=87=AA=E9=80=89=20operator?= =?UTF-8?q?=20kits=20=E4=BC=8A=E8=8A=99=E5=88=A9=E7=89=B9,=20=E8=B0=AC?= =?UTF-8?q?=E5=9B=A0=20(with=20her=20=E4=B8=AD=E7=BB=A7=E5=99=A8)=20and=20?= =?UTF-8?q?=E6=9E=97=20=E2=80=94=20every=20skill,=20talent,=20trait=20and?= =?UTF-8?q?=20module=20(stage=201=20/=203)=20at=20every=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - op-ifrit.js: 轰击术师 line; 精神融解 RES ×0.6 aura, BLA-Δ Lv3 +50 % ATK 元素伤害 on burn-bursting targets (attacks and 灼地 ticks); 莱茵回路 +2 SP / 6 s, BLA-X Lv3 30 % +5; S1 ATK / ASPD; S2 charges, DEF cut + 灼伤 (current ATK, lasts after she leaves); S3 维持技能状态 (no attack, ground-only ticks from the Begin clip's event then every 1 s, RES cut, 2 %/s 流失, ended by stun); BLA-X distance scaling on all her damage while she stands, BLA-Δ 8 % 灼燃损伤 rider. - op-aphris.js: her range is the extension path (5 tiles, +2 and turned by her 中继器; S2 unbounded and turned by allied operators); the 中继器's lifetime / marks / redeploy (leaves and returns with her, cleared by S3), ATK +25 / 35 % while it stands, 取样优化 法术穿透 on allied operators of her path; S1 SP_FULL toggle; S2 beam every 0.5 s (no attack); S3 ammo 20, 3 s 强制缴械, extra hit + longer 停顿 past a 中继器; BLA-Y cost −8, Lv3 35 s / 20 s / +35 %. - op-lin.js: 琉璃璧 shield (absorbs ≤ 200, breaks above: stun + burst on her current range, regrows 8 / 6 s), 韬光 per hit taken; S1 状态切换 toggle (停顿), S2 琉璃璧 copies for allies in range, S3 x-2 + threshold ×2 / ×2.5 + kill refresh; PLX-X keeps DEF +100 % / RES +10 in skills, PLX-Y +3 % per enemy in range (≤ 5). - Sources: data/backups.json, PRTS (备注), the client's battle data (charpacks, battle/prefabs skills / equips, buff templates) and the battle skeletons' clip events; [ASSUMED] items in the kit headers. - kits/index.js OPERATOR_KIT_FILES += op-ifrit.js, op-aphris.js, op-lin.js; tests test/content/op_ifrit / op_aphris / op_lin (10 each). - Golden: diy only — diy-003 … diy-006 now pack the new kits (they held the prototypes), diy-007 … diy-012 are new (the rest of 林, then the prototypes repacked into diy-008 … diy-012); diy-001 / 002 (推进之王) and every other family unchanged. --- server/sim/content/kits/index.js | 7 +- server/sim/content/kits/ops/op-aphris.js | 294 +++++++++++++++ server/sim/content/kits/ops/op-ifrit.js | 215 +++++++++++ server/sim/content/kits/ops/op-lin.js | 228 ++++++++++++ test/content/op_aphris.test.js | 277 ++++++++++++++ test/content/op_ifrit.test.js | 294 +++++++++++++++ test/content/op_lin.test.js | 292 +++++++++++++++ test/golden/diy.json | 438 ++++++++++++++++++----- 8 files changed, 1945 insertions(+), 100 deletions(-) create mode 100644 server/sim/content/kits/ops/op-aphris.js create mode 100644 server/sim/content/kits/ops/op-ifrit.js create mode 100644 server/sim/content/kits/ops/op-lin.js create mode 100644 test/content/op_aphris.test.js create mode 100644 test/content/op_ifrit.test.js create mode 100644 test/content/op_lin.test.js diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index b47d568b..1f4a54dd 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -78,7 +78,12 @@ export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sh * The 自选 operator kit files (`ops/op-.js`, registry key = the operator's charId, an owned-6★ pick of * data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS). */ -export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js']); +export const OPERATOR_KIT_FILES = Object.freeze([ + 'op-siege.js', + 'op-ifrit.js', + 'op-aphris.js', + 'op-lin.js', +]); /** * The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 — diff --git a/server/sim/content/kits/ops/op-aphris.js b/server/sim/content/kits/ops/op-aphris.js new file mode 100644 index 00000000..fff4899e --- /dev/null +++ b/server/sim/content/kits/ops/op-aphris.js @@ -0,0 +1,294 @@ +// server/sim/content/kits/ops/op-aphris.js — 谬因 (char_4229_aphris) 自选 operator kit: 6★ 轰击术师 (术师), an owned-6★ pick +// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module and her summon “中继器” at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4229_aphris, 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]. +// Summon: tokens.token_10070_aphris_pc “中继器” (talent 链路协议; a hand piece the player places with a facing — data +// `placeable`, a talent summon: it deploys with the board, docs/SIM.md §1.1 token pieces). +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 谬因 (链路协议 备注: +// her range "以自己所在格为起点向部署方向延伸,直至达到延伸距离上限" — 5 tiles from E1 —, "若延伸到了自身的中继器所在的地块,会令延伸 +// 距离上限增加(并非增加谬因的攻击距离属性)并改变接下来的延伸方向", "尝试延伸到已延伸到的地块或地图边界外时延伸会提前终止", "索敌与攻击 +// 始终采用格子判定", "攻击力增加效果不可叠加"; 取样优化 备注 "无视法抗效果为增加受益者的法术穿透属性(直接加算)"; 连续映射 备注 +// "中继器持续时间结束时不再进行自动撤退"; 临界瞬爆 备注 "技能持续期间,自身的攻击范围可无限延伸直至无法继续延伸", "持有孤立的我方干员 +// 也可令谬因攻击范围延伸效果转向"; 混沌的本质 备注 "技能开启后3秒内持有强制缴械", "“停顿时间延长”指的是增加本次攻击对该敌人施加的 +// 停顿的时间", "技能开始/结束时强制撤回所有中继器", 修正 "清空再部署"); PRTS 中继器 (备注: 无敌, 禁疗, 孤立, 阻挡数 0, 再部署策略 +// 队列); PRTS 卫戍协议/帮助 §作战阶段 ("若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"); the client's +// battle data (charpack char_4229_aphris — the range selectors, the talent abilities; battle/prefabs [uc]skills skchr_aphris_1/2/3; +// buff_template_data aphris_token_add_atk_tohost, aphris_pc_t[with-draw], aphris_sk1(_token), aphris_sk2_switch_mode4, +// aphris_sk3 / [token-withdraw] / [on_finish], die_to_kill_token, charge_token[born]). +// - Range (链路协议; her selectors `_extendable`, `_extendValidator` = the 中继器, `_extendLengthEveryValidateChar` 2): a path, +// not her data grid — from her tile along her facing, one tile at a time, up to her 5-1 length; reaching her own 中继器 +// adds 2 to the length still allowed and turns the path to that piece's facing; it stops at a tile it already holds or at +// the map's edge. The path is her range (`unit.rangeGrid`, rebuilt when a piece or an operator comes or goes, at her +// skill's start / end, and checked every tick): her attacks, the DEFAULT trigger, 取样优化 and the detail card all read it. +// [ASSUMED] a huge enemy counts on every tile it covers (the engine's selection; PRTS: 格子判定 by its position), her hits +// land at once (the client spaces them `_hitInterval` 0.05 s along the beam) and a 攻击距离 bonus (none exists for her in +// this mode) would extend it as the engine extends any range. +// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her path at once, +// air units too. Module BLA-Y “第三相态”: 部署费用 −8 (the attribute; stats 34 → 26), HP / ATK in the stats, no trait change; +// stage 3 changes T1 (35 s piece, 再部署时间变短 — the piece's byModule respawnTime 30 → 20 —, ATK +35 %). +// - T1 链路协议: one 中继器 lasting the token talent's duration (25 s; BLA-Y stage 3: its hidden module talent, 35 s) from each +// deployment (aphris_pc_t[with-draw]: it withdraws when that time is up unless it holds aphris_sk1_token — given to every +// piece of hers when 连续映射 starts, and to a piece taking the field while 连续映射 or 混沌的本质 runs). Back on its tile its +// respawnTime (30 / 20 s) after it left, paying its 3 DP — the 卫戍 auto redeploy of a placed summon, as 斯卡蒂的海嗣 —, and +// only while 谬因 stands: her leaving the field withdraws it (die_to_kill_token), her (re)deployment makes it ready at once +// (charge_token[born]). ATK +atk while one of hers stands (aphris_token_add_atk_tohost: one buff aphris_t1[atk], "不可叠加"). +// The piece "不会受到攻击" (data: untargetable) and holds 无敌, 禁疗 and 孤立 (PRTS 中继器; [ASSUMED] given by this kit — the +// data's `abnormal` lacks them). +// - T2 取样优化 "攻击范围内的友方干员攻击时无视敌方10点法术抗性" (aphris[t2]: attribute 27, 法术穿透, on allied operators — profession +// mask 639 — of her attack range): resIgnoreFlat +magic_resist_penetrate_fixed on every allied operator standing on her path, +// her own tile included [ASSUMED: the selector does not drop its owner], refreshed every AURA_IV s; several sources keep the +// strongest. +// - S1 连续映射 (AUTO, 持续时间无限): ATK +atk (aphris_sk1) and the 中继器 marks above. An AUTO skill acting on herself fires at +// full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule, kits/README.md checklist 5); `toggle`. +// - S2 临界瞬爆 (MANUAL, data DEFAULT, 8 s): mode 4 (aphris_sk2_switch_mode4) — her normal attack is replaced by the beam +// ability (`_preDelay` 0.167 = the Skill_2_Begin clip, then `_triggerDelta` 0.5; MAGICAL atk_scale): from BEAM_FIRST on, +// every `interval` (0.5) s, every selectable enemy on her path takes atk_scale × ATK arts (16 hits in the 8 s). The path +// meanwhile has no length limit (`_infinite`) and turns at her 中继器 and at every allied +// operator it reaches (validators: the piece, allies of profession mask 639 — every operator profession; a 孤立 one too), +// to that unit's facing. The data resolves DEFAULT: the unbounded range is no data range (no rangeId), so build-data's +// ACTIVE_RANGE rule cannot see it (reported, not hand-coded). +// - S3 混沌的本质 (MANUAL, data DEFAULT, 20 发弹药 — `ammo`, attack@trigger_time): for charge_time (3) s from the cast 强制缴械 +// (aphris_sk3[sheltering], abnormal flag disarmed) and aphris_sk3[token-withdraw]: every 中继器 of hers withdrawn and its +// redeploy time cleared (ResetTokenRespawnTime — the placed piece is back on its tile at once; the two extra pieces the +// skill allows have no piece to place in the auto battle [ASSUMED: N/A]); attack interval +base_attack_time (−1.1 s on +// 2.9 s), ATK +atk, each attack attack@atk_scale × ATK on her path, and on every enemy it hits the buff aphris_sk3: 停顿 +// origin_sluggish + extra_sluggish × n s and n hits of extra_atk_scale × ATK arts, n = the 中继器 the path passed before the +// enemy, at most max_times (1). It ends with its bullets; aphris_sk3[on_finish] withdraws every 中继器 and clears their +// redeploy time again (so the piece is back at once, now with its normal lifetime). + +import { num, talentBb, skillRec, up, batMod, toggleBuff } from '../shared/tier1.js'; +import { COLS } from '../../../constants.js'; +import { dirVec, toLocal } from '../../../dir.js'; + +const S1 = 'skchr_aphris_1'; +const S2 = 'skchr_aphris_2'; +const S3 = 'skchr_aphris_3'; +/** Her summon “中继器” (talent 链路协议). */ +const RELAY = 'token_10070_aphris_pc'; +/** Extra path length per 中继器 (the selectors' `_extendLengthEveryValidateChar`; "使该次攻击距离+2"). */ +const RELAY_REACH = 2; +/** Talent-2 aura refresh period and buff length (the non-stacking aura convention of the kits). */ +const AURA_IV = 0.25; +const AURA_DUR = 0.4; +/** S2 临界瞬爆: the first beam hit (skchr_aphris_2 `_preDelay`, the Skill_2_Begin clip's length), then every `interval` s. */ +const BEAM_FIRST = 0.167; +/** How often a withdrawn 中继器 tries to come back once ready (her on the field, its tile free, DP paid). */ +const RELAY_RETRY = 0.1; +const LINK_KEY = 'aphris:link'; +const SAMPLING_KEY = 'aphris:sampling'; +const CHARGE_KEY = 'aphris:charge'; +const RELAY_ABNORMAL_KEY = 'aphris:relayAbnormal'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** A 中继器 of `unit`. */ +const isRelayOf = (t, unit) => !!t && t.kind === 'token' && t.defId === RELAY && t.ownerUnit === unit; +const relaysOf = (battle, unit) => battle.allyUnits.filter((t) => isRelayOf(t, unit)); +/** 连续映射 / 混沌的本质 running: a 中继器 taking the field now gets the no-withdraw mark (aphris_pc_t[with-draw]). */ +const marksRelays = (unit) => !!(unit.skill && unit.skill.active && (unit.skill.id === S1 || unit.skill.id === S3)); +/** Her own path length: the farthest forward tile of her data grid (5 on her E2 5-1). */ +function baseReach(unit) { + let n = 0; + for (const p of unit.def?.rangeGrid ?? []) if (Array.isArray(p) && p[0] === 0 && p[1] > n) n = p[1]; + return n; +} +/** A 中继器's lifetime: the last `duration` of its talents (the BLA-Y stage-3 hidden talent after the base one). */ +function relayLife(t) { + let life = 0; + for (const x of t.def?.talents ?? []) if (x && x.bb && x.bb.duration != null) life = num(x.bb.duration, life); + return life; +} + +/** + * Her 延伸 path (header): absolute tiles in order from her own, with `index` (tile key → position) and `relays` (the + * positions of the 中继器 tiles it passed). `beam` = S2: no length limit, every allied operator turns it too. + */ +function aphrisPath(battle, unit, beam = false) { + const tiles = [[unit.tileR, unit.tileC]]; + const index = new Map([[unit.tileR * COLS + unit.tileC, 0]]); + const relays = []; + let [dr, dc] = dirVec(unit.dir); + let reach = beam ? Infinity : baseReach(unit); + let r = unit.tileR, c = unit.tileC, steps = 0; + while (steps < reach) { + const nr = r + dr, nc = c + dc; + const k = nr * COLS + nc; + if (!battle.grid.inBounds(nr, nc) || index.has(k)) break; + index.set(k, tiles.length); + tiles.push([nr, nc]); + r = nr; c = nc; steps++; + const o = battle.unitAt(nr, nc); + if (o && isRelayOf(o, unit)) { + reach += RELAY_REACH; + [dr, dc] = dirVec(o.dir); + relays.push(tiles.length - 1); + } else if (beam && o && o !== unit && o.kind === 'op' && o.side === 'ally') [dr, dc] = dirVec(o.dir); + } + return { tiles, index, relays }; +} + +/** Rebuild her range from the path when it changed (the relative grid of the path in her facing-RIGHT frame). */ +function refreshPath(battle, unit) { + if (!up(unit)) return; + const beam = !!(unit.skill && unit.skill.active && unit.skill.id === S2); + const p = aphrisPath(battle, unit, beam); + const sig = p.tiles.map(([r, c]) => r * COLS + c).join(','); + unit.mem.aphrisPath = p; + if (unit.mem.aphrisSig === sig) return; + unit.mem.aphrisSig = sig; + unit.rangeGrid = p.tiles.map(([r, c]) => toLocal(r - unit.tileR, c - unit.tileC, unit.dir)); + battle.refreshRange(unit); +} + +/** How many 中继器 her path passed before `e`'s tile (S3 aphris_sk3 "extendTimes"). */ +function relaysBefore(unit, e) { + const p = unit.mem.aphrisPath; + if (!p || !p.relays.length) return 0; + const i = p.index.get(Math.round(e.y) * COLS + Math.round(e.x)); + return i == null ? 0 : p.relays.filter((j) => j < i).length; +} + +/** Withdraw every 中继器 of hers that stands; `clear` = their redeploy time cleared (ResetTokenRespawnTime). */ +function withdrawRelays(battle, unit, clear) { + for (const t of relaysOf(battle, unit)) { + if (t.alive) { + t.mem.relayCleared = clear; + battle.retreat(t, { reason: 'expired', permanent: true }); + } else if (clear && !t.removed) t.mem.relayReadyAt = battle.time; + } +} + +/** A 中继器 of hers took the field: its abnormal flags and redeploy rule once, its lifetime and mark every time. */ +function relayDeployed(battle, unit, t) { + if (!t.mem.aphrisRelay) { + t.mem.aphrisRelay = true; + battle.addBuff(t, { key: RELAY_ABNORMAL_KEY, flags: { invulnerable: true, isolated: true, noHeal: true }, persist: true, allowDead: true }); + // back on its tile its respawnTime after it left (at once when its redeploy time was cleared), while she stands, + // paying its DP — a token is otherwise removed for good: the piece is kept (`removed = false`) so its hooks survive + // and `battle.redeploy` accepts it + battle.on('death', (c) => { + if (c.unit !== t || battle.finished) return; + t.removed = false; + t.mem.relayReadyAt = battle.time + (t.mem.relayCleared ? 0 : Math.max(0, num(t.base.respawnTime))); + t.mem.relayCleared = false; + if (t.mem.relayRetry) return; + t.mem.relayRetry = battle.every(RELAY_RETRY, (b, sched) => { + const stop = () => { sched.cancel(); t.mem.relayRetry = null; }; + if (t.alive || t.removed || b.finished) { stop(); return; } + if (!up(unit) || b.time + 1e-9 < t.mem.relayReadyAt) return; + if (b.redeploy(t, { free: false })) stop(); + }, { owner: t }); + }, { owner: t, priority: -10 }); + } + t.mem.relayKept = marksRelays(unit); // aphris_pc_t[with-draw] ON_BUFF_START: aphris_sk1_token + const life = relayLife(t); + if (!(life > 0)) return; + const seq = t.deploySeq; + battle.after(life, () => { + if (!t.alive || t.deploySeq !== seq || t.mem.relayKept) return; // 连续映射's mark: "不再进行自动撤退" + battle.retreat(t, { reason: 'expired', permanent: true }); + }, { owner: t }); +} + +export default { + char_4229_aphris: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + return { + skills: { + [S1]: { + kind: 'toggle', trigger: 'SP_FULL', mods: { atkPct: num(b1.atk) }, + onStart({ battle, unit }) { for (const t of relaysOf(battle, unit)) if (t.alive) t.mem.relayKept = true; }, // aphris_sk1 → aphris_sk1_token + }, + [S2]: { + kind: 'duration', + attack: { noAttack: true }, + onStart({ battle, unit }) { + unit.mem.beamAt = 0; + unit.mem.beamNext = BEAM_FIRST; + battle.fx('beam', { x: unit.x, y: unit.y, id: unit.id, skill: 'aphris:beam' }); + }, + onTick({ battle, unit, skill, dt }) { + const iv = num(b2.interval, 0.5); + if (!(iv > 0)) return; + const t = (unit.mem.beamAt = (unit.mem.beamAt ?? 0) + dt); + while (t >= unit.mem.beamNext - 1e-6 && skill.active && unit.alive) { + unit.mem.beamNext += iv; + for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'aphris:beam'] }); + } + } + }, + }, + [S3]: { + kind: 'ammo', ammo: num(b3['attack@trigger_time'], 20), + mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) }, + attack: { + atkScale: num(b3['attack@atk_scale'], 1), + onEachHit({ battle, unit, target }) { // aphris_sk3 on every enemy the attack hits + if (!target || target.side !== 'enemy' || !target.alive) return; + const max = num(b3['attack@max_times'], 1); + const n = max >= 0 ? Math.min(max, relaysBefore(unit, target)) : relaysBefore(unit, target); + const slow = num(b3['attack@origin_sluggish']) + num(b3['attack@extra_sluggish']) * n; + if (slow > 0) battle.applyStatus(target, 'sluggish', { duration: slow, source: unit }); + const ex = num(b3['attack@extra_atk_scale']); + for (let i = 0; i < n && target.alive && ex > 0; i++) battle.dealDamage(unit, target, { amount: unit.s.atk * ex, type: 'arts', isSkill: true, tags: ['skill', 'aphris:relay'] }); + }, + }, + onStart({ battle, unit }) { + const ct = num(b3.charge_time); + if (ct > 0) battle.addBuff(unit, { key: CHARGE_KEY, duration: ct, flags: { disarm: true }, tags: ['skill'] }); // 强制缴械 + withdrawRelays(battle, unit, true); // aphris_sk3[token-withdraw] + }, + onEnd({ battle, unit }) { + battle.removeBuff(unit, CHARGE_KEY); + withdrawRelays(battle, unit, true); // aphris_sk3[on_finish] + }, + }, + }, + talents: [ + { install(battle, unit) { // 链路协议: ATK +atk while one of her 中继器 stands (one buff however many) + const a = num(t0.atk); + if (a) toggleBuff(battle, unit, LINK_KEY, () => relaysOf(battle, unit).some((t) => t.alive && t.deployed), { atkPct: a }); + // die_to_kill_token: her pieces leave with her; charge_token[born]: her (re)deployment makes a waiting one ready + battle.on('death', (c) => { if (c.unit === unit) withdrawRelays(battle, unit, false); }, { owner: unit }); + battle.on('deploy', (c) => { + if (c.unit !== unit) return; + for (const t of relaysOf(battle, unit)) if (!t.alive && !t.removed) t.mem.relayReadyAt = battle.time; + }, { owner: unit }); + } }, + { install(battle, unit) { // 取样优化: 法术穿透 +v on the allied operators of her path + const v = num(t1.magic_resist_penetrate_fixed); + if (!(v > 0)) return; + battle.every(AURA_IV, () => { + if (!up(unit) || !unit.rangeKeySet) return; + for (const a of battle.alliesFor(unit)) { + if (a.kind !== 'op' || !unit.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue; + const cur = a.findBuff(SAMPLING_KEY); + if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) continue; + battle.addBuff(a, { key: SAMPLING_KEY, duration: AURA_DUR, mods: { resIgnoreFlat: v }, source: unit, data: { v }, tags: ['aura'] }); + } + }, { owner: unit, immediate: true }); + } }, + ], + install(battle, unit) { + // the 延伸 path: rebuilt on any ally's arrival / departure (her 中继器, an operator S2 bends on), her skill's + // start / end, and checked every tick + battle.on('deploy', (c) => { + const t = c.unit; + if (t === unit) unit.mem.aphrisSig = null; + else if (isRelayOf(t, unit)) relayDeployed(battle, unit, t); + if (t && t.side === 'ally') refreshPath(battle, unit); + }, { owner: unit }); + battle.on('death', (c) => { if (c.unit && c.unit.side === 'ally' && c.unit !== unit) refreshPath(battle, unit); }, { owner: unit }); + battle.on('skillStart', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit }); + battle.on('skillEnd', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit }); + battle.on('tick', () => refreshPath(battle, unit), { owner: unit }); + // S3's 强制缴械 from the cast on: the attack loop goes on to the attack the cast was made for (ai.js updateAlly + // checks `disarm` before the cast only) — no target, no attack, no bullet spent + battle.on('beforeAttack', (c) => { if (c.attacker === unit && unit.findBuff(CHARGE_KEY)) c.targets = []; }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-ifrit.js b/server/sim/content/kits/ops/op-ifrit.js new file mode 100644 index 00000000..0b5e34c0 --- /dev/null +++ b/server/sim/content/kits/ops/op-ifrit.js @@ -0,0 +1,215 @@ +// server/sim/content/kits/ops/op-ifrit.js — 伊芙利特 (char_134_ifrit) 自选 operator kit: 6★ 轰击术师 (术师), an owned-6★ pick +// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_134_ifrit, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 伊芙利特 (炎爆 备注 +// "灼伤:目标每秒受到伊芙利特当前攻击力33%的法术持续伤害,伊芙利特离场后也会生效" and "<模组相关>于伊芙利特在场时受模组基础效果影响"; +// 灼地 备注 "属于维持技能状态,时间模式为跟随动画", "降低法术抗性的效果持续1秒"; 精神融解 备注 "<Δ模组>造成的元素伤害为持续伤害"; +// “同熔” 特性 "目标距离自身0格或以上时应用加成,按距离线性提升,目标距离自身4格或以上时达到最大值"); PRTS 游戏数据基础 §维持技能状态; +// PRTS 分支特性信息 轰击术师 ("可对空", "普通攻击同时攻击范围内的所有敌人"); the client's battle data (battle/prefabs [uc]skills +// skchr_ifrit_1/2/3, [uc]equips ifrit_equip_*, charpack char_134_ifrit, buff_template_data: burn, atk_scale_by_distance, +// periodic_damage_by_hp_ratio[skip_modifier_fix], ifrit_e_002[sp] / [dice_sp], ifrit_e_003_tr, ifrit_e_003_ele) and her +// battle skeleton (Skill_2_Begin's OnAttack event). +// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her 5-1 line at +// once, air units too, the same damage near and far (professions.js blastcaster). +// - Module BLA-X “同熔” (trait min_dist 0 / max_dist 4 / damage_scale 0.1): "距离越远伤害越高,最高达到110%" — ×(1 + 0.1 × +// clamp((d − 0) / (4 − 0), 0, 1)) by the Euclidean distance d between her and the target, on EVERY damage she outputs +// while on the field (the module's buff atk_scale_by_distance: ON_OUTPUT_DAMAGE, no damage mask) — her attacks, the 灼地 +// ticks and the 炎爆 灼伤 (PRTS: "于伊芙利特在场时受模组基础效果影响"). Stage 3 also changes T2 (below). +// - Module BLA-D “热成形记忆” (trait ep_damage_ratio 0.08): "造成法术伤害时附带相当于8%伤害的灼燃损伤" (ifrit_e_003_tr: ON_AFTER_ +// OUTPUT_DAMAGE, every MAGICAL damage of hers) — 8 % of the HP damage dealt as 灼燃损伤 (`type: 'element'`, docs/SIM.md §7.2 +// element conventions), the 灼伤 too while she stands. Stage 3 also changes T1 (below). +// - T1 精神融解 "攻击范围内的敌军法术抗性-40%" (magic_resistance −0.4: a ratio; charpack ifrit_t_1, removed when the enemy leaves): +// RES ×0.6 on every enemy on her range, refreshed every AURA_IV s; several 伊芙利特 keep one (applyStrongest — the mode's +// "同名效果取最高" convention; [ASSUMED] the client's independentCharacterSource flag would let two stack, which a lone +// base-game 伊芙利特 never shows). BLA-D stage 2+ "若攻击目标处于灼燃损伤爆发期间,攻击对其额外造成相当于攻击力50%的元素伤害" (the +// module's hidden talent element_atk_scale; ifrit_e_003_ele: IsTargetInEPBreakRecovery FIRE ⇒ ELEMENT damage, attack type +// BUFF, 持续伤害): every hit of her attack abilities — normal attacks and the 灼地 ticks (the equip adds it to the abilities of +// `_targetFamilyMask` 31, [ASSUMED] every family: 1 / 3 mark the narrower equips) — on an enemy in its 灼燃 burst (its +// `burnBurst` lock) adds 50 % ATK 元素伤害 +// (`type: 'elemental'`), tagged 'dot' (PRTS: "<Δ模组>造成的元素伤害为持续伤害"); never the 灼伤 (a buff's damage). +// - T2 莱茵回路 "每6秒额外回复2点技力" (periodic_sp; BLA-X stage 2+ ifrit_e_002[sp]): +sp SP every `interval` s of a deployment +// (the first one interval in: waitFirstTriggerInterval; a gift during a running 狂热 / 灼地 is lost — no SP during a +// skill). BLA-X stage 2+ "有30%概率额外回复5点技力" (ifrit_e_002[dice_sp] prob / interval / sp): one roll per its own interval. +// - S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +atk, ASPD +attack_speed. +// - S2 炎爆 (AUTO, 可充能 2 / 3 次, data DEFAULT — the "next attack" waits for her attack): the next attack hits her whole line +// (ground and air: its selector's targetMotion ALL) for atk_scale × ATK; every enemy it hits gets the buff ifrit_s_2 +// (template burn): DEF +def (−100 / −200, "同名效果取最高") for `duration` (3.01) s and the 灼伤 — burn.atk_scale (33 %) × her +// CURRENT ATK arts every second of it (triggerInterval 1, the first a second in: 3 ticks), a 持续伤害 (no dodge) that keeps +// ticking after she leaves the field. A new 炎爆 on the same enemy restarts both. +// - S3 灼地 (MANUAL, data DEFAULT, 20 s): a 维持技能状态 — no normal attack. The ability (skchr_ifrit_3: `_triggerDelta` 1, +// ground-only selector) strikes first at the attack event of its Begin clip (SCORCH_FIRST: 0.4 s; facing UP / DOWN the +// back / down clips, 0.333 s) and then every S3_CADENCE (1) s — a fixed cadence no ASPD change touches: every ground enemy +// on her range takes atk_scale × ATK arts and the active buff ifrit_s_3, RES +magic_resistance (−7 / −10, flat) for 1 s +// (PRTS 备注) — the cut before the damage [ASSUMED order], so the tick it comes with counts it. Her own periodic_damage_by_ +// hp_ratio buff takes hp_ratio (2 %) of her max HP every second of the skill (the first a second in; a 流失 — it ignores +// for SP and skips the damage events —, it can knock her out; PRTS "实际的生命流失量略低于标注值" gives no number [ASSUMED: +// the stated 2 %]). 晕眩 / 冻结 (any state that stops her acting) ends it at once (PRTS 维持技能状态). + +import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js'; +import { bodyInKeys } from '../../../body.js'; +import { hasHp } from '../../../damage.js'; + +const S1 = 'skchr_ifrit_1'; +const S2 = 'skchr_ifrit_2'; +const S3 = 'skchr_ifrit_3'; +/** T1 aura refresh period and buff length (the non-stacking aura convention of the kits). */ +const AURA_IV = 0.25; +const AURA_DUR = 0.4; +/** S3 灼地: the ability's `_triggerDelta` (s) — one damage tick per second, "每秒". */ +const S3_CADENCE = 1; +/** S3 灼地: the first tick at the OnAttack event of Skill_2_Begin (front 0.4 s; the back clip and Skill_Down_2_Begin 0.333 s). */ +const SCORCH_FIRST = Object.freeze({ side: 0.4, vertical: 0.333 }); +/** S3 灼地 RES cut length (ifrit_s_3 lifeTime 1; PRTS 备注 "降低法术抗性的效果持续1秒"). */ +const S3_RES_TIME = 1; +/** S3 灼地 self 流失 period (periodic_damage_by_hp_ratio triggerInterval 1). */ +const S3_LOSS_IV = 1; +const MELT_KEY = 'ifrit:meltdown'; +const S2_DEF_KEY = 'ifrit:pyroDef'; +const S2_BURN_KEY = 'ifrit:pyroBurn'; +const S3_RES_KEY = 'ifrit:scorchRes'; +const SCORCH_TAG = 'ifrit:scorch'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** RES cut mods for a blackboard magic_resistance value (applyStrongest): |v| < 1 = ×(1 + v), else flat v. */ +const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v }); +const defCut = (v) => ({ defFlat: v }); + +/** S2 炎爆 on one enemy its attack hit: DEF cut and the 灼伤 for `duration` s. */ +function pyroclasm(battle, unit, e, b2) { + if (!e || !e.alive || e.side !== 'enemy') return; + const dur = num(b2.duration, 3); + const d = num(b2.def); + if (d) battle.applyStrongest(e, S2_DEF_KEY, { duration: dur, value: d, mods: defCut, source: unit }); + const sc = num(b2['burn.atk_scale']); + if (!(sc > 0)) return; + battle.addBuff(e, { + key: S2_BURN_KEY, duration: dur, interval: 1, source: unit, tags: ['dot'], + // "伊芙利特当前攻击力": her ATK at each tick; it ticks on after she leaves the field (PRTS 备注) + onTick: ({ battle: b, unit: tgt }) => b.dealDamage(unit, tgt, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'skill', 'ifrit:burn'] }), + }); +} + +/** S3 灼地 tick: every ground enemy on her range — the RES cut, then the damage. */ +function scorch(battle, unit, b3) { + const scale = num(b3.atk_scale), mr = num(b3.magic_resistance); + const victims = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false }); + for (const e of victims) { + if (mr) battle.applyStrongest(e, S3_RES_KEY, { duration: S3_RES_TIME, value: mr, mods: resCut, source: unit }); + if (e.alive && scale > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', SCORCH_TAG] }); + } + battle.fx('flame', { x: unit.x, y: unit.y, id: unit.id, n: victims.length, skill: SCORCH_TAG }); +} + +export default { + char_134_ifrit: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const hidden = moduleBb(chess); // BLA-D stage 2+: element_atk_scale + const tb = traitBb(chess); // BLA-X: min_dist / max_dist / damage_scale; BLA-D: ep_damage_ratio + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s2 = skillRec(chess, S2); + return { + skills: { + [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } }, + [S2]: { + kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant', + attack: { + atkScale: num(b2.atk_scale, 1), + onEachHit({ battle, unit, target }) { pyroclasm(battle, unit, target, b2); }, + }, + onStart({ battle, unit }) { battle.fx('aoe', { x: unit.x, y: unit.y, radius: 0.5, id: unit.id, skill: 'ifrit:pyroclasm' }); }, + }, + [S3]: { + kind: 'duration', + attack: { noAttack: true }, + onStart({ unit }) { + unit.mem.scorchAt = 0; + unit.mem.scorchNext = unit.dir === 'UP' || unit.dir === 'DOWN' ? SCORCH_FIRST.vertical : SCORCH_FIRST.side; + unit.mem.scorchLoss = 0; + }, + onTick({ battle, unit, skill, dt }) { + // 维持技能状态: a hard control (晕眩 / 冻结 / 浮空 / 睡眠 — the unit cannot act) ends it at once + if (!unit.canAct) { skill.end('interrupted'); return; } + const t = (unit.mem.scorchAt = (unit.mem.scorchAt ?? 0) + dt); + while (t >= unit.mem.scorchNext - 1e-6 && unit.alive && skill.active) { + unit.mem.scorchNext += S3_CADENCE; + scorch(battle, unit, b3); + } + const ratio = num(b3.hp_ratio); + while (ratio > 0 && t >= (unit.mem.scorchLoss + 1) * S3_LOSS_IV - 1e-6 && unit.alive) { + unit.mem.scorchLoss++; + battle.loseHp(unit, unit.s.maxHp * ratio, { source: unit, tags: ['skill', SCORCH_TAG] }); + } + }, + }, + }, + talents: [ + { install(battle, unit) { // 精神融解: RES −40 % on her range; BLA-D stage 2+: +50 % ATK 元素伤害 on a bursting target + const v = num(t0.magic_resistance); + if (v) { + battle.every(AURA_IV, () => { + if (!up(unit) || !unit.rangeKeySet) return; + for (const e of battle.enemies) { + if (!e.alive || e.hidden || !bodyInKeys(e, unit.rangeKeySet)) continue; + battle.applyStrongest(e, MELT_KEY, { duration: AURA_DUR, value: v, mods: resCut, source: unit }); + } + }, { owner: unit, immediate: true }); + } + const ex = num(hidden.element_atk_scale); + if (ex > 0) { + battle.on('damaged', (c) => { + const e = c.target, d = c.dmg; + if (c.source !== unit || !d || !(d.isAttack || d.tags?.includes(SCORCH_TAG)) || !e || e.side !== 'enemy' || !hasHp(e) || !e.findBuff('burnBurst')) return; + battle.dealDamage(unit, e, { amount: unit.s.atk * ex, type: 'elemental', element: 'burn', canDodge: false, tags: ['dot', 'module', 'ifrit:meltdown'] }); + }, { owner: unit }); + } + } }, + { install(battle, unit) { // 莱茵回路: +sp SP every interval s; BLA-X stage 2+: prob to +5 SP more + const sp = num(t1.sp), iv = num(t1.interval, 6); + const dProb = num(t1['ifrit_e_002[dice_sp].prob']), dSp = num(t1['ifrit_e_002[dice_sp].sp']); + const dIv = num(t1['ifrit_e_002[dice_sp].interval'], iv); + if (!(sp > 0 && iv > 0) && !(dProb > 0 && dSp > 0 && dIv > 0)) return; + battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.rhineAcc = 0; unit.mem.rhineDice = 0; } }, { owner: unit }); + battle.on('tick', ({ dt }) => { + if (!up(unit)) return; + if (sp > 0 && iv > 0) { + unit.mem.rhineAcc = (unit.mem.rhineAcc ?? 0) + dt; + while (unit.mem.rhineAcc >= iv - 1e-6) { unit.mem.rhineAcc -= iv; giveSp(unit, sp); } + } + if (dProb > 0 && dSp > 0 && dIv > 0) { + unit.mem.rhineDice = (unit.mem.rhineDice ?? 0) + dt; + while (unit.mem.rhineDice >= dIv - 1e-6) { + unit.mem.rhineDice -= dIv; + if (battle.rng.chance(dProb)) giveSp(unit, dSp); + } + } + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // BLA-X “同熔”: farther targets take more of every damage she outputs (up to ×1 + damage_scale at max_dist tiles) + const ds = num(tb.damage_scale); + if (ds > 0 && tb.max_dist != null) { + const lo = num(tb.min_dist), hi = num(tb.max_dist, 4); + battle.on('hit', (c) => { + const e = c.target; + if (c.source !== unit || !e || e.side !== 'enemy' || c.dmg.type === 'element' || !up(unit)) return; + const d = Math.hypot(e.x - unit.x, e.y - unit.y); + c.dmg.mul *= 1 + ds * (hi > lo ? Math.max(0, Math.min(1, (d - lo) / (hi - lo))) : 1); + }, { owner: unit }); + } + // BLA-D “热成形记忆”: 8 % of each arts damage she deals as 灼燃损伤 + const ep = num(tb.ep_damage_ratio); + if (ep > 0) { + battle.on('damaged', (c) => { + const e = c.target; + if (c.source !== unit || c.type !== 'arts' || !(c.amount > 0) || !e || e.side !== 'enemy' || !hasHp(e) || !up(unit)) return; + battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: c.amount * ep, tags: ['module', 'ifrit:thermal'] }); + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-lin.js b/server/sim/content/kits/ops/op-lin.js new file mode 100644 index 00000000..d072884c --- /dev/null +++ b/server/sim/content/kits/ops/op-lin.js @@ -0,0 +1,228 @@ +// server/sim/content/kits/ops/op-lin.js — 林 (char_4080_lin) 自选 operator kit: 6★ 阵法术师 (术师), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4080_lin, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 林 (计出万全 备注: +// "通常情况下琉璃璧生效优先级为+1000", "琉璃璧破碎效果于满足条件的伤害应用前瞬间触发" — so a lethal hit still sets it off —, +// "其破碎时的影响范围以持有该效果的单位为中心,与林的当前攻击范围一致,先造成晕眩后造成伤害", "可造成琉璃璧破碎的伤害结算时,伤害量减少 +// 相当于琉璃璧吸收伤害上限的数值"; 玲珑 备注 "非初始状态下…技能开启时的效果均会生效,但此状态下并不实际属于“技能期间”"; 荫庇 备注 +// "每个干员单独获得琉璃壁,单独计算琉璃壁破碎与恢复时间", "伤害计算使用林的攻击力,伤害来源为林自身"; 流光乍裂 备注 "第一天赋生效的 +// 优先级从+1000变为±0,琉璃璧破碎效果于满足条件的伤害应用后瞬间触发", "仅自身普通攻击造成击杀时可触发琉璃璧破碎/生成效果,单次普攻 +// 最多触发1次效果"); PRTS 卫戍协议/帮助 §技能操作 ("携带状态切换类技能的干员…每次部署后仅开启一次技能"; 阵法术师 row "在初始攻击范围内 +// 存在敌人时释放技能"; 林【流光乍裂】 x-2 "在自定义范围内存在敌人时释放技能"); PRTS 分支特性信息 阵法术师 ("攻击时可对空"); the client's +// battle data (charpack char_4080_lin, battle/prefabs [uc]skills skchr_lin_1/2/3, buff_template_data lin_t_1 / lin_t_1[damage] +// / lin_t_1_s_3 / lin_t_1_s_3[refresh] / lin_t_2 / lin_e_002[half_attribute] / lin_e_002_trait). +// - Trait (阵法术师) "通常时不攻击且防御力和法术抗性大幅度提升,技能开启时攻击造成群体法术伤害": the profession (professions.js +// phalanx — DEF +def ×, RES +magic_resistance while no skill runs; while one runs, `rangeAoe`: every selectable enemy of +// her range at once, air units too). Module PLX-X “七窍玲珑” overrides it with "更大幅度提升" (def 2.15 / magic_resistance +// 25, the profession's trait tunables) and "技能开启时保留部分效果" (lin_e_002[buff] def 1 / magic_resistance 10): DEF +100 % +// and RES +10 while any skill runs — 玲珑's switched state too (PRTS: "技能开启时的效果均会生效"). Module PLX-Y “情与义”: +// "范围内敌人越多造成的伤害越高(最高提升15%)" — the hidden module talent: +damage_scale (3 %) per enemy on her range, at most +// max_valid_stack_cnt (5), on every damage she deals (the 琉璃璧 bursts too), counted like 卡涅利安's PLX-Y (every living +// enemy standing on her current range). +// - T1 计出万全 (lin_t_1, HIGH_PRIORITY: ON_TAKE_DAMAGE → BlockDamage of a fixed `value`, a break when the damage left it +// negative): a 琉璃璧 on her from every deployment — an HP shield of `value` (200) that every damage instance refills +// (`absorbShields` absorbs it after DEF / RES and the damage multipliers): a hit of at most 200 is absorbed whole; a hit +// over 200 ("单次超过200点伤害") goes through less 200 (PRTS: "伤害量减少相当于琉璃璧吸收伤害上限的数值") and breaks it — the +// burst (lin_t_1[damage] on the targets of Lin's "Attack" selector, i.e. her CURRENT attack range, centred on the holder): +// stun `stun` s on every enemy there, then atk_scale × Lin's ATK arts (source Lin) — and lin_t_1[disable] brings it back +// `interval` s later (8 s; PLX-Y stage 3: 110 %, 6 s). A 流失 or an element 损伤 never touches it. [ASSUMED] the shield sits +// in the buff order (HIGH_PRIORITY puts it first; no other shield usually stands on her), and the burst comes right after +// the hit has landed — before a knock-out, as PRTS's lethal-hit note says — in the 流光乍裂 order too (PRTS: "伤害应用后"). +// - T2 韬光 "受到攻击时,有50%几率回复1点技力" (PLX-X stage 3: 75 %, 2 SP): the client's lin_t_2 (ON_TAKE_DAMAGE → IsIgnoreForSp → +// Dice prob → ModifySp sp, no damage-value filter): one roll per damage instance she takes that could give 受击回复 SP — a +// hit the 琉璃璧 absorbs whole included (it is still taken, at 0); not a 流失 or an element 损伤. No SP while a skill runs +// (玲珑's state included: it stays on). +// - S1 玲珑 (MANUAL, data SEARCH, a 状态切换类 skill: switched on once per deployment and kept — the official rule, `toggle`): +// attack interval +base_attack_time (+1.0 s on her 2.0 s), ATK +atk, every enemy each attack hits 停顿 attack@sluggish s. +// PRTS's "并不实际属于技能期间" changes nothing here: the state is never switched back in the auto battle. +// - S2 荫庇 (MANUAL, data SEARCH, 25 s): ASPD +attack_speed, taunt +taunt_level (−1: "自身不容易受到敌人攻击"); every allied +// unit on her range (the client's validator: ground allies — every ally of this mode but the 孤立 “炎佑”, which no ally +// ability selects) gets its own lin_t_1 — the plain T1 (threshold `value`, Lin's ATK and source, its own break and +// `interval` regrowth; independentCharacterSource, not on Lin herself) — while the skill runs (removed when the ability +// detaches), checked every SHELTER_IV s; an ally that leaves the field loses it (its buffs go). +// - S3 流光乍裂 (MANUAL, data CUSTOM_RANGE on its x-2, 26 / 28 s): ATK +atk; range x-2 (the burst range follows: it is her +// current range: lin_t_1_s_3_selector); her own 琉璃璧 threshold ×talent_scale (2 / 2.5) while it runs (lin_t_1_s_3); +// each kill by her "Attack" ability (ON_TARGET_KILLED, one per attack) runs lin_t_1_s_3[refresh]: a standing 琉璃璧 bursts +// and stays, a broken one comes back at once. The auto battle never switches it off early. + +import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp, batMod } from '../shared/tier1.js'; +import { COLS } from '../../../constants.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { bodyInKeys } from '../../../body.js'; +import { isHpLoss } from '../../../damage.js'; + +const S1 = 'skchr_lin_1'; +const S2 = 'skchr_lin_2'; +const S3 = 'skchr_lin_3'; +/** The 琉璃璧 shield buff (one per holder: Lin's own, and the S2 copies on her allies). */ +const GLASS_KEY = 'lin:glass'; +/** PLX-X: the part of the trait kept while a skill runs. */ +const KEEP_KEY = 'lin:plxKeep'; +/** S2 荫庇: how often the allies on her range are checked for a 琉璃璧. */ +const SHELTER_IV = 0.1; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const s3On = (lin) => !!(lin.skill && lin.skill.active && lin.skill.id === S3); + +export default { + char_4080_lin: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const hidden = moduleBb(chess); // PLX-Y: damage_scale per enemy, max_valid_stack_cnt + const tb = traitBb(chess); // PLX-X: lin_e_002[buff] def / magic_resistance + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const g3 = skillRec(chess, S3)?.rangeGrid ?? null; + const glass = { value: num(t0.value, 200), atkScale: num(t0.atk_scale, 1), stun: num(t0.stun, 1), interval: num(t0.interval, 8) }; + const s3Scale = num(b3.talent_scale, 1); + + /** Threshold (= shield) of a holder's 琉璃璧: Lin's own ×talent_scale while 流光乍裂 runs, the plain value otherwise. */ + const thresholdOf = (lin, holder) => (holder === lin && s3On(lin) ? glass.value * s3Scale : glass.value); + /** (Re)raise the 琉璃璧 of `holder`, full. `mods: {}` keeps the buff when an instance drains it exactly (absorbShields). */ + const raise = (battle, lin, holder) => { + if (!holder.alive) return; + const r = holder.mem.linGlassRegen; + if (r) { r.cancel(); holder.mem.linGlassRegen = null; } + battle.addBuff(holder, { key: GLASS_KEY, shield: thresholdOf(lin, holder), mods: {}, source: lin, tags: ['talent'] }); + battle.fx('shield', { x: holder.x, y: holder.y, id: holder.id, skill: 'lin:glass' }); + }; + /** The break: Lin's current attack range centred on the holder — stun first, then the damage (PRTS 备注). */ + const burst = (battle, lin, holder) => { + const grid = lin.liveRangeGrid || lin.rangeGrid || [[0, 0]]; + const keys = absoluteRangeKeys(grid, holder.tileR, holder.tileC, lin.dir, 0); + const victims = battle.enemiesInKeys(keys, lin, { canHitFly: true }); + if (glass.stun > 0) for (const e of victims) battle.applyStatus(e, 'stun', { duration: glass.stun, source: lin }); + for (const e of victims) { + if (e.alive && glass.atkScale > 0) battle.dealDamage(lin, e, { amount: lin.s.atk * glass.atkScale, type: 'arts', tags: ['talent', 'lin:glass'] }); + } + battle.fx('aoe', { x: holder.x, y: holder.y, radius: 1.5, id: lin.id, skill: 'lin:glass' }); + }; + /** Break `holder`'s 琉璃璧; it grows back `interval` s later (an S2 copy only while 荫庇 still covers it), or at once. */ + const shatter = (battle, lin, holder, now = false) => { + battle.removeBuff(holder, GLASS_KEY); + burst(battle, lin, holder); + if (now) { raise(battle, lin, holder); return; } + const seq = holder.deploySeq; + holder.mem.linGlassRegen = battle.after(glass.interval, () => { + holder.mem.linGlassRegen = null; + if (!holder.alive || holder.deploySeq !== seq || holder.findBuff(GLASS_KEY)) return; + if (holder !== lin && !lin.mem.shelter?.has(holder.id)) return; + raise(battle, lin, holder); + }, { owner: lin }); + }; + /** Drop an S2 copy (the ally left her range, or 荫庇 ended). */ + const dropCopy = (battle, holder) => { + const g = holder.findBuff(GLASS_KEY); + if (g) battle.removeBuff(holder, g); + const r = holder.mem.linGlassRegen; + if (r) { r.cancel(); holder.mem.linGlassRegen = null; } + }; + /** S2: the allied units (not Lin, not devices) on her range get / keep / lose their 琉璃璧. */ + const shelterTick = (battle, lin) => { + const st = lin.mem.shelter; + if (!st || !lin.rangeKeySet) return; + const now = new Set(); + for (const a of battle.alliesFor(lin)) { + if (a === lin || a.kind === 'device' || !lin.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue; + now.add(a.id); + if (!st.has(a.id)) { st.add(a.id); raise(battle, lin, a); } + } + for (const id of [...st]) { + if (now.has(id)) continue; + st.delete(id); + const a = battle.unitById(id); + if (a) dropCopy(battle, a); + } + }; + const endShelter = (battle, lin) => { + const st = lin.mem.shelter; + lin.mem.shelter = null; + for (const id of st || []) { const a = battle.unitById(id); if (a) dropCopy(battle, a); } + }; + + return { + skills: { + [S1]: { + kind: 'toggle', + mods: { atkPct: num(b1.atk), batPct: batMod(b1.base_attack_time, chess) }, + attack: { onHitStatus: { key: 'sluggish', duration: num(b1['attack@sluggish']) } }, + }, + [S2]: { + kind: 'duration', + mods: { aspd: num(b2.attack_speed), taunt: num(b2.taunt_level) }, + onStart({ battle, unit }) { unit.mem.shelter = new Set(); shelterTick(battle, unit); }, + onTick({ battle, unit, dt }) { + unit.mem.shelterAcc = (unit.mem.shelterAcc ?? 0) + dt; + if (unit.mem.shelterAcc + 1e-9 < SHELTER_IV) return; + unit.mem.shelterAcc = 0; + shelterTick(battle, unit); + }, + onEnd({ battle, unit }) { unit.mem.shelterAcc = 0; endShelter(battle, unit); }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk) }, + targeting: g3 ? { rangeGrid: g3 } : undefined, + onStart({ battle, unit }) { // the threshold ×talent_scale: a standing 琉璃璧 grows to it + const g = unit.findBuff(GLASS_KEY); + if (g) { g.shield = thresholdOf(unit, unit); unit.markDirty(); } + battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'lin:s3' }); + }, + onAttack({ battle, unit, targets }) { // a knock-out by this attack: break and raise again, once per attack + if (!(targets || []).some((t) => t && t.side === 'enemy' && !t.alive)) return; + if (unit.findBuff(GLASS_KEY)) shatter(battle, unit, unit, true); + else raise(battle, unit, unit); + }, + }, + }, + talents: [ + { install(battle, unit) { // 计出万全: her 琉璃璧 (and the S2 copies — the same hook: any holder of one she gave) + battle.on('deploy', (c) => { if (c.unit === unit) raise(battle, unit, unit); }, { owner: unit }); + battle.on('damaged', (c) => { + const h = c.target; + const g = h && h.findBuff(GLASS_KEY); + if (!g || g.source !== unit || c.type === 'element' || isHpLoss(c.dmg)) return; + if (g.shield > 1e-9 || !(c.amount > 0)) { // absorbed whole: full again for the next instance + g.shield = thresholdOf(unit, h); + h.markDirty(); + return; + } + if (h === unit || unit.alive) shatter(battle, unit, h); + }, { owner: unit, priority: 50 }); + battle.on('skillEnd', (c) => { // 流光乍裂 over: a standing 琉璃璧 shrinks to the plain threshold + if (c.unit !== unit || c.skill.id !== S3) return; + const g = unit.findBuff(GLASS_KEY); + if (g) { g.shield = thresholdOf(unit, unit); unit.markDirty(); } + }, { owner: unit }); + } }, + { install(battle, unit) { // 韬光: prob to +sp SP per damage instance taken (absorbed or not) that could give 受击回复 SP + const p = num(t1.prob), sp = num(t1.sp); + if (!(p > 0 && sp > 0)) return; + battle.on('damaged', (c) => { + if (c.target !== unit || !c.dmg || c.dmg.noSp || c.type === 'element' || isHpLoss(c.dmg) || !up(unit)) return; + if (battle.rng.chance(p)) giveSp(unit, sp); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // PLX-X “七窍玲珑”: 技能开启时保留部分效果 (any skill; 玲珑's switched state included) + const keepDef = num(tb['lin_e_002[buff].def']), keepRes = num(tb['lin_e_002[buff].magic_resistance']); + if (keepDef || keepRes) { + battle.on('skillStart', (c) => { if (c.unit === unit) battle.addBuff(unit, { key: KEEP_KEY, mods: { defPct: keepDef, resFlat: keepRes }, tags: ['module'] }); }, { owner: unit }); + battle.on('skillEnd', (c) => { if (c.unit === unit) battle.removeBuff(unit, KEEP_KEY); }, { owner: unit }); + } + // PLX-Y “情与义”: +damage_scale per enemy on her range (≤ max_valid_stack_cnt), every damage she deals + const per = num(hidden.damage_scale), cap = Math.max(0, Math.floor(num(hidden.max_valid_stack_cnt, 5))); + if (per > 0 && cap > 0) { + battle.on('hit', (c) => { + if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element' || !unit.rangeKeySet) return; + let n = 0; + for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, unit.rangeKeySet) && ++n >= cap) break; + if (n > 0) c.dmg.mul *= 1 + per * n; + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/test/content/op_aphris.test.js b/test/content/op_aphris.test.js new file mode 100644 index 00000000..0abf09af --- /dev/null +++ b/test/content/op_aphris.test.js @@ -0,0 +1,277 @@ +// test/content/op_aphris.test.js — the 自选 operator kit of 谬因 (char_4229_aphris, 6★ 轰击术师; kit +// server/sim/content/kits/ops/op-aphris.js) and her summon “中继器” (token_10070_aphris_pc), fielded the production way (a DIY +// slot + its `diy` pick, simdata getDiy; the 中继器 as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, +// skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or BLA-Y “第三相态” at stage 1 (tier 5) / 3 (tier 6). +// Every number is read back from data/backups.json (the form of that slot status, the token's variant); the fidelity +// checklist of kits/README.md item by item. +// Run: node --test test/content/op_aphris.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 APHRIS = 'char_4229_aphris'; +const RELAY = 'token_10070_aphris_pc'; +const FORMS = BACKUPS.units[APHRIS].forms; +const TOKEN = BACKUPS.tokens[RELAY]; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const BLAY = 'uniequip_002_aphris'; +const S1 = 'skchr_aphris_1', S2 = 'skchr_aphris_2', S3 = 'skchr_aphris_3'; +const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusKey(tier, elite)]; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_hitter: dummy('enemy_hitter', { atk: 500, range: 3, bat: 1 }) }; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BLAY].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; +const K = (r, c) => r * 21 + c; +const sortN = (a) => [...a].sort((x, y) => x - y); +/** 谬因 at (9, 2) facing RIGHT; her own line (9, 2) … (9, 7). */ +const OWN = [[9, 2], [9, 3], [9, 4], [9, 5], [9, 6], [9, 7]]; +/** With her 中继器 on (9, 4) facing UP: two steps to it, then 5 more (5 + 2) turned up. */ +const BENT = [[9, 2], [9, 3], [9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4]]; + +/** A battle with 谬因 as uid 1 at (9, 2) facing RIGHT (`relay`: her piece as uid 2 at relay.row / col / dir), plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 1, relay = null, others = [], seed = 5, dp = 99 } = {}) { + const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: APHRIS, skillIndex: skill, uniEquipId: mod }, elite, row: 9, col: 2 }]; + if (relay) units.push({ uid: 2, kind: 'token', tokenId: RELAY, ownerUid: 1, row: relay.row, col: relay.col, dir: relay.dir }); + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy'], captureNoisy: true, + units: [...units, ...others], + }); + h.step(); + return { h, u: h.unit(1), r: relay ? h.unit(2) : null }; +} +const UP4 = { row: 9, col: 4, dir: 'UP' }; +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes(tag)); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('谬因 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes (BLA-Y: 部署费用 −8), 5-1 line without a 中继器, 轰击术师, no 特质', () => { + assert.equal(OPERATOR_KITS[APHRIS], KITS[APHRIS]); + 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], [APHRIS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.cost], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.cost + (m?.attr.cost ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.profile.dmgType, u.profile.canHitFly, u.profile.allInRange, u.profile.projectile], ['arts', true, true, 'beam'], `${label(f)}: 轰击术师`); + assert.deepEqual(sortN(u.rangeKeys), sortN(OWN.map(([a, b]) => K(a, b))), `${label(f)}: her own 5-1`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: the card shows 5-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [BACKUPS.diy.operators[APHRIS].bonds, [], [RELAY]], `${label(f)}: bonds / 特质 / summon`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`); + done(h); + } + } + // E2 Lv1 1261 / 727, E2 Lv60 1526 / 826, cost 34; BLA-Y −8 cost, +120 / +45 → +200 / +70 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, FORMS['2/60/7/1'].stats.cost], [1261, 727, 1526, 826, 34]); + assert.deepEqual([modOf(5, BLAY).attr, modOf(6, BLAY).attr], [{ cost: -8, maxHp: 120, atk: 45 }, { cost: -8, maxHp: 200, atk: 70 }]); +}); + +test('a 自选 pick: 谬因 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(APHRIS)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(APHRIS), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: APHRIS, skillIndex: 1, uniEquipId: BLAY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: APHRIS, skillIndex: 1, uniEquipId: BLAY } } }); +}); + +test('链路协议 range: her 中继器 turns the path to its facing and allows 2 more tiles; one attack strikes every enemy of the bent path (air too), none of the straight line past it; the card shows the bent path', () => { + for (const f of FORMS_ALL) { + const { h, u, r } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 1, relay: UP4 }); + assert.deepEqual([r.alive, r.dir, r.ownerUnit === u, r.def.id], [true, 'UP', true, RELAY], `${label(f)}: the piece`); + assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `${label(f)}: bent path`); + assert.deepEqual(sortN(u.baseRangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `${label(f)}: the DEFAULT trigger reads it too`); + assert.equal(u.liveRangeGrid.length, BENT.length, `${label(f)}: the card`); + const before = h.spawn('enemy_dummy', { pos: [9, 3] }), past = h.spawn('enemy_fly', { pos: [12, 4] }), straight = h.spawn('enemy_dummy', { pos: [9, 6] }); + h.runUntil(() => atkHits(h, u).length >= 2, 4); + const hits = atkHits(h, u); + const first = hits.filter((c) => c.dmg.attackId === hits[0].dmg.attackId); + assert.deepEqual(first.map((c) => c.target.id).sort(), [before.id, past.id].sort(), `${label(f)}: the bent path`); + assert.ok(!hits.some((c) => c.target === straight), `${label(f)}: not the straight line past the piece`); + done(h); + } +}); + +test('the 中继器 (T1 链路协议): ATK +25 % (T6 BLA-Y +35 %) while it stands; it withdraws after 25 s (T6 BLA-Y 35 s) and comes back on its tile 30 s (T6 BLA-Y 20 s) later for 3 DP; untargetable, 无敌, 禁疗, 孤立, blocks nothing', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u, r } = field({ tier, elite, mod, skill: 1, relay: UP4 }); + const y3 = elite && tier === 6 && mod === BLAY; + const v = TOKEN.variants[`${APHRIS}@${statusKey(tier, elite)}`]; + const stats = (mod && v.byModule?.[mod]?.stats) || v.stats; + const life = y3 ? 35 : 25, back = y3 ? 20 : 30, atk = y3 ? 0.35 : 0.25; + assert.deepEqual([stats.respawnTime, stats.cost, r.base.respawnTime, r.base.blockCnt], [back, 3, back, 0], `${label(f)}: token data`); + assert.equal(u.def.raw.talents.find((t) => t.index === 0).bb.atk, atk, `${label(f)}: talent`); + assert.deepEqual([r.s.flags.untargetable, r.s.flags.invulnerable, r.s.flags.isolated, r.s.flags.noHeal], [true, true, true, true], `${label(f)}: flags`); + h.run(0.1); + approx(u.s.atk, u.base.atk * (1 + atk), `${label(f)}: ATK +${atk * 100} %`); + h.run(life - 0.3); + assert.ok(r.alive, `${label(f)}: still there before ${life} s`); + const dp0 = h.b.players[0].dp; + h.run(0.4); + assert.ok(!r.alive && !r.removed, `${label(f)}: withdrawn after ${life} s, waiting`); + h.step(); + approx(u.s.atk, u.base.atk, `${label(f)}: no bonus without it`); + assert.deepEqual(sortN(u.rangeKeys), sortN(OWN.map(([a, b]) => K(a, b))), `${label(f)}: the straight line again`); + h.run(back - 0.4); + assert.ok(!r.alive, `${label(f)}: not before ${back} s`); + h.run(0.6); + assert.ok(r.alive && r.tileR === 9 && r.tileC === 4 && r.dir === 'UP', `${label(f)}: back on its tile`); + approx(dp0 - h.b.players[0].dp, 3, `${label(f)}: 3 DP`); + h.step(); + approx(u.s.atk, u.base.atk * (1 + atk), `${label(f)}: the bonus again`); + done(h); + } +}); + +test('T2 取样优化: every allied operator standing on her path — her own tile too — ignores 10 RES (法术穿透); none off it', () => { + for (const f of FORMS_ALL) { + const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }, { uid: 4, chessId: 'chess_char_1_02_a', row: 9, col: 6 }]; + const { h, u } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 1, relay: UP4, others }); + assert.equal(u.def.raw.talents.find((t) => t.index === 1).bb.magic_resist_penetrate_fixed, 10, label(f)); + h.run(0.3); + assert.deepEqual([u.s.resIgnoreFlat, h.unit(3).s.resIgnoreFlat, h.unit(4).s.resIgnoreFlat], [10, 10, 0], `${label(f)}: herself, the one on the bent path, not the one on the straight line`); + done(h); + } +}); + +test('S1 连续映射 (AUTO, 持续时间无限 — the kit\'s SP_FULL: no enemy needed): at full SP (45 / 40); ATK +90 % / +120 %; a 中继器 whose time runs out stays', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, BLAY]]) { + const sk = skillOf(tier, elite, S1); + const { h, u, r } = field({ tier, elite, mod, skill: 0, relay: UP4 }); + assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, sk.bb.atk], ['SP_FULL', 'toggle', elite ? 40 : 45, elite ? 1.2 : 0.9], `T${tier}`); + h.run(sk.spCost - 1); + assert.equal(u.skill.activations, 0, `T${tier}: not before full SP`); + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast at full SP, no enemy on the field`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK (no 中继器 bonus: it withdrew at its time)`); + // the piece back (cooldown), then its time runs out while 连续映射 runs: it stays + h.runUntil(() => r.alive, 40); + h.run((elite && tier === 6 ? 35 : 25) + 5); + assert.ok(r.alive, `T${tier}: the 中继器 stays`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk + (elite && tier === 6 ? 0.35 : 0.25)), `T${tier}: both bonuses`); + h.run(60); + assert.deepEqual([u.skill.active, u.skill.activations], [true, 1], `T${tier}: infinite`); + done(h); + } +}); + +test('S2 临界瞬爆 (MANUAL, data DEFAULT, 8 s): no normal attack; from 0.167 s every 0.5 s the beam hits every enemy along an unbounded path — turned by her 中继器 and by any allied operator it reaches — for 90 % / 120 % ATK arts; 16 hits; her range is back after', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 4, dir: 'RIGHT' }]; + const { h, u } = field({ tier, elite, skill: 1, relay: UP4, others }); + assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk_scale, sk.bb.interval, u.skill.spCost], ['DEFAULT', 8, elite ? 1.2 : 0.9, 0.5, elite ? 20 : 22], `T${tier}`); + const inPath = h.spawn('enemy_dummy', { pos: [10, 4] }), afterOp = h.spawn('enemy_fly', { pos: [11, 9] }); + const pastOp = h.spawn('enemy_dummy', { pos: [12, 4] }), straight = h.spawn('enemy_dummy', { pos: [9, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + // the beam: (9,2) → (9,4) 中继器 UP → (11,4) 角峰 RIGHT → the field's edge + for (const [a, b] of [[10, 4], [11, 4], [11, 5], [11, 10], [11, 20]]) assert.ok(u.rangeKeySet.has(K(a, b)), `T${tier}: (${a}, ${b}) on the beam`); + for (const [a, b] of [[12, 4], [9, 5], [9, 6]]) assert.ok(!u.rangeKeySet.has(K(a, b)), `T${tier}: (${a}, ${b}) off it`); + const n0 = atkHits(h, u).length, cast = h.hooksOf('skillStart').at(-1).t; + h.runUntil(() => !u.skill.active, 10); + const endT = h.hooksOf('skillEnd').at(-1).t; + const beam = tagged(h, u, 'aphris:beam'); + approx(beam[0].t - cast, 0.167, `T${tier}: the first hit after the Begin clip (0.167 s)`, 0.2); + approx(beam.filter((c) => c.target === inPath)[1].t - beam[0].t, 0.5, `T${tier}: then every 0.5 s`, 0.07); + for (const e of [inPath, afterOp]) { + const mine = beam.filter((c) => c.target === e); + assert.equal(mine.length, 16, `T${tier}: 16 beam hits on ${e.defId}`); + approx(mine[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); + assert.deepEqual([mine[0].type, mine[0].dmg.isAttack], ['arts', false], `T${tier}: arts, no normal attack`); + } + assert.ok(!beam.some((c) => c.target === pastOp || c.target === straight), `T${tier}: nothing off the beam`); + assert.equal(atkHits(h, u).filter((c) => c.t < endT).length, n0, `T${tier}: no normal attack meanwhile`); + assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `T${tier}: the bent 5-1 path again`); + done(h); + } +}); + +test('S3 混沌的本质 (MANUAL, data DEFAULT, 20 bullets): 3 s 强制缴械 from the cast, every 中继器 withdrawn and back at once; interval 2.9 → 1.8 s, ATK +95 % / +115 %, 140 % / 155 % + 停顿 0.2 / 0.4 s; past a 中继器 +25 % / +30 % ATK and 停顿 +0.2 / +0.4 s; no 中继器 withdraws until it ends, all do then and come back at once', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, BLAY]]) { + const sk = skillOf(tier, elite, S3); + const { h, u, r } = field({ tier, elite, mod, skill: 2, relay: UP4 }); + const atk = elite && tier === 6 ? 0.35 : 0.25; + assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.ammo, sk.bb.charge_time, sk.bb.base_attack_time, sk.bb.atk, sk.bb['attack@atk_scale'], sk.bb['attack@origin_sluggish'], sk.bb['attack@extra_atk_scale'], sk.bb['attack@extra_sluggish']], + ['DEFAULT', 'ammo', 20, 3, -1.1, elite ? 1.15 : 0.95, elite ? 1.55 : 1.4, elite ? 0.4 : 0.2, elite ? 0.3 : 0.25, elite ? 0.4 : 0.2], `T${tier}`); + const before = h.spawn('enemy_dummy', { pos: [9, 3] }), past = h.spawn('enemy_dummy', { pos: [11, 4] }), off = h.spawn('enemy_dummy', { pos: [9, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + const t0 = h.hooksOf('skillStart').at(-1).t, n0 = atkHits(h, u).length; + assert.ok(h.hooksOf('death').some((c) => c.unit === r && Math.abs(c.t - t0) < 1e-9), `T${tier}: the 中继器 withdrawn at the cast`); + assert.ok(h.runUntil(() => r.alive, 0.5), `T${tier}: and back at once (redeploy time cleared)`); + h.run(2.8 - (h.b.time - t0)); + assert.equal(atkHits(h, u).length, n0, `T${tier}: 强制缴械 3 s`); + approx(u.s.interval, 1.8, `T${tier}: 1.8 s`); + approx(u.s.atk, u.base.atk * (1 + atk + sk.bb.atk), `T${tier}: ATK`); + h.runUntil(() => atkHits(h, u).length > n0, 1); + const hits = atkHits(h, u).slice(n0); + assert.ok(h.b.time - t0 >= 3 - 1e-6, `T${tier}: the first attack after 3 s`); + assert.deepEqual(hits.map((c) => c.target.id).sort(), [before.id, past.id].sort(), `T${tier}: the bent path`); + 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'); } + assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `T${tier}: the bent 5-1 path while it runs`); + const extra = tagged(h, u, 'aphris:relay'); + assert.deepEqual(extra.map((c) => c.target.id), [past.id], `T${tier}: the extra hit past the 中继器 only`); + approx(extra[0].amount, u.s.atk * sk.bb['attack@extra_atk_scale'], `T${tier}: +${sk.bb['attack@extra_atk_scale'] * 100} % ATK`); + const slow = (e) => h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && c.target === e).at(-1)?.duration; + approx(slow(before), sk.bb['attack@origin_sluggish'], `T${tier}: 停顿 before it`); + approx(slow(past), sk.bb['attack@origin_sluggish'] + sk.bb['attack@extra_sluggish'], `T${tier}: 停顿 past it`); + assert.ok(!hits.some((c) => c.target === off)); + // its time runs out during the skill: it stays; all go when the skill ends — and come back at once (redeploy time + // cleared again), with their normal time + h.runUntil(() => !u.skill.active, 60); + assert.equal(atkHits(h, u).filter((c) => c.dmg.isSkill).length, 2 * 20, `T${tier}: 20 bullets (2 targets each)`); + const end = h.hooksOf('skillEnd').at(-1).t; + assert.ok(end - t0 > (elite && tier === 6 ? 35 : 25), `T${tier}: it outlasted the piece's time`); + assert.ok(h.hooksOf('death').some((c) => c.unit === r && Math.abs(c.t - end) < 1e-9), `T${tier}: withdrawn at the end`); + assert.ok(h.runUntil(() => r.alive, 0.5), `T${tier}: back at once`); + const back = h.b.time; + h.run((elite && tier === 6 ? 35 : 25) + 0.5); + assert.ok(h.hooksOf('death').some((c) => c.unit === r && c.t > back), `T${tier}: and withdraws at its time again`); + done(h); + } +}); + +test('BLA-Y “第三相态” (stages 1 and 3): the 部署费用 attribute (34 → 26); stage 3 only: 35 s 中继器, its redeploy time 30 → 20 s (再部署时间变短), ATK +35 %', () => { + for (const tier of [5, 6]) { + for (const mod of [null, BLAY]) { + const { h, u, r } = field({ tier, elite: true, mod, relay: UP4 }); + assert.equal(u.base.cost, mod ? 26 : 34, `T${tier} ${mod}: cost`); + const y3 = tier === 6 && mod === BLAY; + assert.deepEqual(r.def.talents.map((t) => t.bb.duration), y3 ? [25, 35] : [25], `T${tier} ${mod}: the piece's talents`); + assert.equal(r.base.respawnTime, y3 ? 20 : 30, `T${tier} ${mod}: redeploy time`); + if (y3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /35秒.*再部署时间变短.*攻击力\+35%/); + done(h); + } + } +}); + +test('链路协议: her 中继器 leaves the field with her (die_to_kill_token) and is back on its tile as soon as she is (charge_token[born]), not before', () => { + const { h, u, r } = field({ tier: 6, elite: true, mod: BLAY, skill: 1, relay: UP4 }); + h.run(2); + assert.ok(r.alive); + const e = h.spawn('enemy_dummy', { pos: [9, 3] }); + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.equal(u.alive, false, 'knocked out'); + assert.ok(!r.alive && !r.removed, 'the piece withdrawn with her'); + h.run(40); + assert.ok(!r.alive, 'not while she is down (its redeploy time is long over)'); + h.runUntil(() => u.alive, 60); + assert.ok(h.runUntil(() => r.alive, 0.5), 'back with her'); + assert.ok(r.tileR === 9 && r.tileC === 4 && r.dir === 'UP', 'on its tile'); + done(h); +}); diff --git a/test/content/op_ifrit.test.js b/test/content/op_ifrit.test.js new file mode 100644 index 00000000..63fb1fe4 --- /dev/null +++ b/test/content/op_ifrit.test.js @@ -0,0 +1,294 @@ +// test/content/op_ifrit.test.js — the 自选 operator kit of 伊芙利特 (char_134_ifrit, 6★ 轰击术师; kit +// server/sim/content/kits/ops/op-ifrit.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, BLA-X +// “同熔” or BLA-D “热成形记忆” 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_ifrit.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 IFRIT = 'char_134_ifrit'; +const FORMS = BACKUPS.units[IFRIT].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const BLAX = 'uniequip_002_ifrit', BLAD = 'uniequip_003_ifrit'; +const S1 = 'skchr_ifrit_1', S2 = 'skchr_ifrit_2', S3 = 'skchr_ifrit_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { + enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + enemy_res: dummy('enemy_res', { res: 50 }), enemy_def: dummy('enemy_def', { def: 300 }), + enemy_hitter: dummy('enemy_hitter', { atk: 1000, range: 0, bat: 1 }), +}; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BLAX, BLAD].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** A battle with 伊芙利特 as uid 1 at (row, col) facing RIGHT (her line: that row, col … col + 5), plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5, flags = {} } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999, ...flags }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: IFRIT, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => (c.source === u || c.credit === u) && c.dmg?.tags?.includes(tag)); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('伊芙利特 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 5-1 line, 轰击术师 (every enemy of the line, air too, arts, instant), no 特质', () => { + assert.equal(OPERATOR_KITS[IFRIT], KITS[IFRIT]); + 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], [IFRIT, 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 + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.allInRange, u.profile.projectile, u.base.bat], ['ranged', 'arts', true, true, 'beam', 2.9], `${label(f)}: 轰击术师`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 5-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[IFRIT].bonds, []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`); + done(h); + } + } + // E2 Lv1 1276 / 722, E2 Lv60 1544 / 820; BLA-X +50 ATK +5 ASPD → +72 / +7, BLA-D +120 HP +45 ATK → +200 / +70 + assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1276, 722, 1544, 820]); + assert.deepEqual([modOf(5, BLAX).attr, modOf(6, BLAX).attr, modOf(5, BLAD).attr, modOf(6, BLAD).attr], [{ atk: 50, aspd: 5 }, { atk: 72, aspd: 7 }, { maxHp: 120, atk: 45 }, { maxHp: 200, atk: 70 }]); +}); + +test('a 自选 pick: 伊芙利特 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(IFRIT)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(IFRIT), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: IFRIT, skillIndex: 2, uniEquipId: BLAD } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: IFRIT, skillIndex: 2, uniEquipId: BLAD } } }); +}); + +test('trait 轰击术师: one attack strikes every enemy of her 5-1 line at once — ground and air, the same damage near and far (no module) — and nothing off the line', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const { h, u } = field({ tier, elite, skill: 0 }); + const near = h.spawn('enemy_dummy', { pos: [10, 5] }), far = h.spawn('enemy_dummy', { pos: [10, 9] }); + const fly = h.spawn('enemy_fly', { pos: [10, 7] }), off = h.spawn('enemy_dummy', { pos: [11, 5] }), behind = h.spawn('enemy_dummy', { pos: [10, 3] }); + assert.ok(h.runUntil(() => atkHits(h, u).length > 0, 4), `T${tier}: she attacks`); + const first = atkHits(h, u); + const hit = first.filter((c) => c.dmg.attackId === first[0].dmg.attackId); + assert.deepEqual(hit.map((c) => c.target.id).sort(), [near, far, fly].map((e) => e.id).sort(), `T${tier}: her line, air included`); + for (const c of hit) { approx(c.amount, u.s.atk, `T${tier}: 100 % ATK at any distance`); assert.equal(c.type, 'arts'); } + for (const e of [off, behind]) assert.ok(!hit.some((c) => c.target === e), `T${tier}: off the line`); + done(h); + } +}); + +test('S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +10 % / +20 %, ASPD +58 / +67; all back after', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0 }); + assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.attack_speed, u.skill.spCost], ['DEFAULT', 20, elite ? 0.2 : 0.1, elite ? 67 : 58, elite ? 44 : 47], `T${tier}`); + u.skill.gainSp(999); + h.run(1); + assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast on her attack`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`); + approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.01); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 5-1 while it runs`); + h.runUntil(() => !u.skill.active, 25); + approx(u.s.atk, u.base.atk, `T${tier}: ATK back`); + assert.equal(u.s.aspd, 100, `T${tier}: ASPD back`); + done(h); + } +}); + +test('S2 炎爆 (AUTO, 2 / 3 charges, data DEFAULT): the next attack hits her whole line for 160 % / 190 %, DEF −100 / −200 for 3 s and the 灼伤 (33 % of her current ATK per s, 3 ticks, no dodge) on every enemy hit — the burn ticks on after she leaves', () => { + 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, u.skill.kind, u.skill.maxCharges, u.skill.spCost, sk.bb.atk_scale, sk.bb.def, sk.bb['burn.atk_scale'], sk.bb.duration], + ['DEFAULT', 'charges', elite ? 3 : 2, 8, elite ? 1.9 : 1.6, elite ? -200 : -100, 0.33, 3.01], `T${tier}`); + const a = h.spawn('enemy_def', { pos: [10, 6] }), b = h.spawn('enemy_fly', { pos: [10, 8] }), off = h.spawn('enemy_dummy', { pos: [11, 6] }); + u.skill.gainSp(999); + const n0 = atkHits(h, u).length; + assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).length > n0, 4), `T${tier}: cast on her next attack`); + const hits = atkHits(h, u).slice(n0); + const t0 = h.b.time; + for (const c of hits) assert.equal(c.dmg.isSkill, true); + approx(hits.find((c) => c.target === b).amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % on the flyer`); + approx(a.s.def, 300 + sk.bb.def < 0 ? 0 : 300 + sk.bb.def, `T${tier}: DEF cut`); + assert.equal(off.findBuff('ifrit:pyroDef'), null, `T${tier}: off the line`); + assert.ok(a.findBuff('ifrit:pyroBurn') && b.findBuff('ifrit:pyroBurn'), `T${tier}: both burn`); + // the burn: 3 ticks of 33 % of her current ATK; she leaves the field after the first one + h.runUntil(() => tagged(h, u, 'ifrit:burn').filter((c) => c.target === b).length >= 1, 2); + const atkNow = u.s.atk; + h.b.retreat(u); + h.run(3); + const burns = tagged(h, u, 'ifrit:burn').filter((c) => c.target === b); + assert.equal(burns.length, 3, `T${tier}: 3 ticks`); + approx(burns[0].amount, atkNow * 0.33, `T${tier}: 33 % ATK arts`); + for (const c of burns) { assert.deepEqual([c.type, c.dmg.canDodge], ['arts', false]); assert.ok(c.t - t0 <= 3.05, `T${tier}: within 3 s`); } + assert.equal(a.findBuff('ifrit:pyroDef'), null, `T${tier}: DEF cut over after 3 s`); + done(h); + } +}); + +test('S3 灼地 (MANUAL, data DEFAULT, 20 s, 维持技能状态): no normal attack; from the Begin clip\'s event (0.4 s) every second each ground enemy of her line takes 90 % / 110 % ATK arts and RES −7 / −10 for 1 s; she loses 2 % max HP per s; 20 ticks; a stun ends it at once', () => { + 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.duration, sk.bb.atk_scale, sk.bb.magic_resistance, sk.bb.hp_ratio], ['DEFAULT', 20, elite ? 1.1 : 0.9, elite ? -10 : -7, 0.02], `T${tier}`); + const g = h.spawn('enemy_res', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [10, 7] }), off = h.spawn('enemy_res', { pos: [11, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + const n0 = atkHits(h, u).length, hp0 = u.hp, cast = h.hooksOf('skillStart').at(-1).t; + h.run(1.02); + // RES 50 × 0.6 (精神融解) − 7 / −10: (50 − cut) × 0.6 + approx(g.s.res, (50 + sk.bb.magic_resistance) * 0.6, `T${tier}: RES cut + 精神融解`, 1e-9); + approx(off.s.res, 50, `T${tier}: off the line`); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 5-1 while it runs`); + const ticks = () => tagged(h, u, 'ifrit:scorch').filter((c) => c.type === 'arts'); + assert.equal(ticks().length, 1, `T${tier}: one tick after 1 s`); + approx(ticks()[0].t - cast, 0.4, `T${tier}: the first tick at the Begin clip's attack event`, 0.05); + approx(ticks()[0].amount, u.s.atk * sk.bb.atk_scale * (1 - (50 + sk.bb.magic_resistance) * 0.6 / 100), `T${tier}: ${sk.bb.atk_scale * 100} % ATK arts`); + assert.ok(!ticks().some((c) => c.target === fly || c.target === off), `T${tier}: ground enemies of her line only`); + approx(hp0 - u.hp, u.s.maxHp * 0.02, `T${tier}: 2 % max HP`); + h.runUntil(() => !u.skill.active, 25); + assert.equal(ticks().length, 20, `T${tier}: 20 ticks`); + const endT = h.hooksOf('skillEnd').at(-1).t; + assert.equal(atkHits(h, u).filter((c) => c.t < endT).length, n0, `T${tier}: no normal attack meanwhile`); + // a second cast, cut short by a stun + u.hp = u.s.maxHp; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 6), `T${tier}: cast again`); + h.run(2.5); + h.b.applyStatus(u, 'stun', { duration: 1, force: true }); + h.step(); + assert.equal(u.skill.active, false, `T${tier}: the stun ends 灼地`); + assert.equal(h.hooksOf('skillEnd').at(-1).reason, 'interrupted'); + done(h); + } +}); + +test('T1 精神融解: RES ×0.6 on every enemy of her line (gone once it leaves), none off it; BLA-D stage 3: her attacks and 灼地 ticks on an enemy in its 灼燃 burst add 50 % ATK 元素伤害 (a 持续伤害)', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + const t0 = u.def.raw.talents.find((t) => t.index === 0); + assert.equal(t0.bb.magic_resistance, -0.4, label(f)); + const on = h.spawn('enemy_res', { pos: [10, 7] }), off = h.spawn('enemy_res', { pos: [11, 7] }); + h.run(0.3); + approx(on.s.res, 30, `${label(f)}: 50 → 30`); + approx(off.s.res, 50, `${label(f)}: off the line`); + h.b.retreat(u); + h.run(0.6); + approx(on.s.res, 50, `${label(f)}: gone with her`); + done(h); + } + for (const [tier, mod, extra] of [[6, BLAD, 0.5], [5, BLAD, 0], [6, BLAX, 0], [6, null, 0]]) { + const { h, u } = field({ tier, elite: true, mod, skill: 2 }); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + const calm = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.b.addBuff(e, { key: 'burnBurst', duration: 60, flags: { burstLock: true } }); + const n0 = atkHits(h, u).length; + h.runUntil(() => atkHits(h, u).length > n0, 4); + h.step(); + const el = () => tagged(h, u, 'ifrit:meltdown'); + assert.equal(el().length, extra ? 1 : 0, `T${tier} ${mod}: the extra hit on her attack`); + if (extra) { + approx(el()[0].amount, u.s.atk * extra, 'T6 BLA-D: 50 % ATK'); + assert.deepEqual([el()[0].type, el()[0].dmg.element, el()[0].dmg.tags.includes('dot'), el()[0].target], ['elemental', 'burn', true, e]); + assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /灼燃损伤.*爆发期间.*50%的元素伤害/); + } + // the 灼地 ticks are attacks of hers too (the equip adds it to every ability family) + u.skill.gainSp(999); + h.runUntil(() => u.skill.active, 4); + h.run(2.5); + const onTicks = el().filter((c) => c.t > h.hooksOf('skillStart').at(-1).t); + assert.equal(onTicks.length, extra ? 3 : 0, `T${tier} ${mod}: one per 灼地 tick (0.4 / 1.4 / 2.4 s)`); + assert.ok(!el().some((c) => c.target === calm), `T${tier} ${mod}: none on an enemy outside a burst`); + done(h); + } +}); + +test('T2 莱茵回路: +2 SP every 6 s of a deployment; BLA-X stage 3: a 30 % roll for +5 SP more each 6 s (none in any other form)', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 }); + const dice = elite && tier === 6 && mod === BLAX; + const t1 = u.def.raw.talents.find((t) => t.index === 1).bb; + assert.deepEqual([t1.sp, t1.interval, t1['ifrit_e_002[dice_sp].prob'] ?? 0, t1['ifrit_e_002[dice_sp].sp'] ?? 0], [2, 6, dice ? 0.3 : 0, dice ? 5 : 0], label(f)); + const gains = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent'); + for (let i = 0; i < 60; i++) { u.skill.sp = 0; h.run(6); } + const g = gains(); + const twos = g.filter((c) => c.amount === 2).length, fives = g.filter((c) => c.amount === 5).length; + assert.ok(twos >= 59 && twos <= 60, `${label(f)}: ${twos} gifts of 2 SP in 360 s`); + if (dice) assert.ok(fives >= 9 && fives <= 30, `${label(f)}: ${fives} / 60 rolls of +5 ≈ 30 %`); + else assert.equal(fives, 0, `${label(f)}: no roll`); + done(h); + } +}); + +test('BLA-X “同熔” (stages 1 and 3): ×(1 + 0.1 × d / 4) by the distance to the target, capped at 4 tiles — on her attacks, the 灼伤 while she stands and the 灼地 ticks; none without it', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + const blax = elite && mod === BLAX; + if (blax) assert.deepEqual(u.def.raw.trait.bb, { min_dist: 0, max_dist: 4, damage_scale: 0.1 }, label(f)); + const at = [5, 6, 8, 9].map((c) => h.spawn('enemy_dummy', { pos: [10, c] })); + const n0 = atkHits(h, u).length; + h.runUntil(() => atkHits(h, u).length >= n0 + 4, 4); + const hits = atkHits(h, u).slice(n0, n0 + 4); + for (const [i, d] of [[0, 1], [1, 2], [2, 4], [3, 5]]) { + const c = hits.find((x) => x.target === at[i]); + approx(c.amount, u.s.atk * (blax ? 1 + 0.1 * Math.min(1, d / 4) : 1), `${label(f)}: d = ${d}`); + } + done(h); + } + // the 灼伤 while she stands and after she left; the 灼地 tick + for (const [skill, tag] of [[1, 'ifrit:burn'], [2, 'ifrit:scorch']]) { + const { h, u } = field({ tier: 6, elite: true, mod: BLAX, skill }); + const e = h.spawn('enemy_dummy', { pos: [10, 8] }); // 4 tiles: ×1.1 + u.skill.gainSp(999); + h.runUntil(() => tagged(h, u, tag).filter((c) => c.type === 'arts').length >= 1, 6); + const c = tagged(h, u, tag).find((x) => x.type === 'arts' && x.target === e); + const scale = skill === 1 ? 0.33 : skillOf(6, true, S3).bb.atk_scale; + approx(c.amount, u.s.atk * scale * 1.1, `${tag}: ×1.1 at 4 tiles`); + if (skill === 1) { + h.b.retreat(u); + h.run(2.1); + const after = tagged(h, u, tag).filter((x) => x.target === e).at(-1); + approx(after.amount, u.s.atk * scale, '灼伤 after she left: no module effect'); + } + done(h); + } +}); + +test('BLA-D “热成形记忆” (stages 1 and 3): every arts damage she deals while on the field attaches 8 % of it as 灼燃损伤 — her attacks and the 灼伤 — and no 元素损伤 without it', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 1 }); + const blad = elite && mod === BLAD; + if (blad) assert.equal(u.def.raw.trait.bb.ep_damage_ratio, 0.08, label(f)); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + u.skill.gainSp(999); + h.runUntil(() => tagged(h, u, 'ifrit:burn').length >= 2, 6); + const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'arts').reduce((s, c) => s + c.amount, 0); + approx(e.elem.burn, blad ? arts * 0.08 : 0, `${label(f)}: 8 % of ${Math.round(arts)} arts damage as 灼燃损伤`); + done(h); + } +}); diff --git a/test/content/op_lin.test.js b/test/content/op_lin.test.js new file mode 100644 index 00000000..a7bacf00 --- /dev/null +++ b/test/content/op_lin.test.js @@ -0,0 +1,292 @@ +// test/content/op_lin.test.js — the 自选 operator kit of 林 (char_4080_lin, 6★ 阵法术师; kit +// server/sim/content/kits/ops/op-lin.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, PLX-X +// “七窍玲珑” or PLX-Y “情与义” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form +// of that slot status); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_lin.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 LIN = 'char_4080_lin'; +const FORMS = BACKUPS.units[LIN].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const PLXX = 'uniequip_002_lin', PLXY = 'uniequip_003_lin'; +const S1 = 'skchr_lin_1', S2 = 'skchr_lin_2', S3 = 'skchr_lin_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_weak: dummy('enemy_weak', { hp: 10 }) }; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PLXX, PLXY].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; +/** Her x-1 (and x-2) range from (10, 5) facing RIGHT, as [row, col]. */ +const X1 = [[12, 5], [11, 4], [11, 5], [11, 6], [10, 3], [10, 4], [10, 5], [10, 6], [10, 7], [9, 4], [9, 5], [9, 6], [8, 5]]; + +/** A battle with 林 as uid 1 at (10, 5) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, others = [], seed = 5, setup = null } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LIN, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 5 }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +const glassHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('lin:glass')); +const glassOf = (a) => a.findBuff('lin:glass'); +/** True damage straight to `t` from `src` (no DEF / RES: the exact amount meets the 琉璃璧). */ +const hurt = (h, src, t, n) => h.b.dealDamage(src, t, { amount: n, type: 'true' }); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('林 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, x-1 range, 阵法术师 (no attack and the guard while no skill runs; every enemy of her range, air too, while one does)', () => { + assert.equal(OPERATOR_KITS[LIN], KITS[LIN]); + 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], [LIN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.bat], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 15, 2], `${label(f)}: stats`); + assert.deepEqual([u.profile.noAttackUnlessSkill, u.profile.allInRange, u.profile.canHitFly, u.profile.dmgType, u.profile.projectile], [true, true, true, 'arts', 'beam'], `${label(f)}: 阵法术师`); + const plxx = elite && mod === PLXX; + assert.deepEqual([u.profile.guardDef, u.profile.guardRes], plxx ? [2.15, 25] : [2, 20], `${label(f)}: trait tunables`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: x-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[LIN].bonds, []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`); + done(h); + } + } + // E2 Lv1 1679 / 730 / 212, E2 Lv60 1924 / 809 / 232; PLX-X +62 / +45 → +77 / +53 ATK / DEF, PLX-Y +50 / +20 → +73 / +40 + 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], [1679, 730, 1924, 809]); + assert.deepEqual([modOf(5, PLXX).attr, modOf(6, PLXX).attr, modOf(5, PLXY).attr, modOf(6, PLXY).attr], [{ atk: 62, def: 45 }, { atk: 77, def: 53 }, { atk: 50, def: 20 }, { atk: 73, def: 40 }]); +}); + +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(LIN)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LIN), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: LIN, skillIndex: 0, uniEquipId: PLXX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: LIN, skillIndex: 0, uniEquipId: PLXX } } }); +}); + +test('trait + PLX-X: DEF ×3 / RES +20 while no skill runs (PLX-X ×3.15 / +25); while one runs the guard is off — PLX-X keeps DEF +100 % / RES +10 (stages 1 and 3)', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 1 }); + const plxx = elite && mod === PLXX; + if (plxx) assert.deepEqual(u.def.raw.trait.bb, { def: 2.15, magic_resistance: 25, 'lin_e_002[buff].def': 1, 'lin_e_002[buff].magic_resistance': 10 }, label(f)); + approx(u.s.def, u.base.def * (1 + (plxx ? 2.15 : 2)), `${label(f)}: guard DEF`); + approx(u.s.res, 15 + (plxx ? 25 : 20), `${label(f)}: guard RES`); + h.run(1); + assert.equal(atkHits(h, u).length, 0, `${label(f)}: no attack while no skill runs`); + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: 荫庇`); + approx(u.s.def, u.base.def * (1 + (plxx ? 1 : 0)), `${label(f)}: skill DEF`); + approx(u.s.res, 15 + (plxx ? 10 : 0), `${label(f)}: skill RES`); + h.runUntil(() => !u.skill.active, 30); + approx(u.s.def, u.base.def * (1 + (plxx ? 2.15 : 2)), `${label(f)}: the guard is back`); + done(h); + } +}); + +test('S1 玲珑 (MANUAL, data SEARCH, a 状态切换类 skill: switched on once and kept): cast as soon as an enemy is in her range; ATK +25 % / +40 %, interval 2 → 3 s, every enemy of her range (air too) 停顿 0.8 / 0.9 s per attack', () => { + 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, u.skill.kind, sk.bb.atk, sk.bb['attack@sluggish'], sk.bb.base_attack_time, u.skill.spCost], ['SEARCH', 'toggle', elite ? 0.4 : 0.25, elite ? 0.9 : 0.8, 1, elite ? 6 : 8], `T${tier}`); + u.skill.gainSp(999); + h.run(1); + assert.equal(u.skill.activations, 0, `T${tier}: nobody in range`); + const a = h.spawn('enemy_dummy', { pos: [10, 7] }), b = h.spawn('enemy_fly', { pos: [11, 5] }), off = h.spawn('enemy_dummy', { pos: [10, 8] }); + assert.ok(h.runUntil(() => u.skill.active, 0.2), `T${tier}: cast at once (SEARCH)`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + approx(u.s.interval, 3, `T${tier}: 3 s`); + h.runUntil(() => atkHits(h, u).length >= 2, 4); + const hits = atkHits(h, u); + assert.deepEqual(hits.map((c) => c.target.id).sort(), [a.id, b.id].sort(), `T${tier}: both in range, air too`); + for (const c of hits) { approx(c.amount, u.s.atk, `T${tier}: 100 % ATK`); assert.equal(c.type, 'arts', `T${tier}: arts`); } + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: x-1 while it runs`); + const slow = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish'); + assert.deepEqual(slow.map((c) => c.target.id).sort(), [a.id, b.id].sort(), `T${tier}: 停顿 on each`); + for (const c of slow) approx(c.duration, sk.bb['attack@sluggish'], `T${tier}: 停顿 ${sk.bb['attack@sluggish']} s`); + assert.ok(!hits.some((c) => c.target === off), `T${tier}: outside x-1`); + h.run(120); + assert.deepEqual([u.skill.active, u.skill.activations], [true, 1], `T${tier}: kept on`); + done(h); + } +}); + +test('S2 荫庇 (MANUAL, data SEARCH, 25 s): ASPD +50 / +90, taunt −1; every allied unit of her range gets its own 琉璃璧 (threshold 200, Lin\'s ATK, centred on it, its own regrowth), lost at the end; none outside', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, PLXY]]) { + const sk = skillOf(tier, elite, S2); + const t0 = formOf(tier, elite).modules?.find((m) => m.uniEquipId === mod)?.talentChanges.find((t) => t.talentIndex === 0)?.bb ?? formOf(tier, elite).talents[0].bb; + const others = [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 9 }]; + const { h, u } = field({ tier, elite, mod, skill: 1, others }); + const near = h.unit(2), far = h.unit(3); + assert.deepEqual([u.skill.rule, sk.duration, sk.bb.attack_speed, sk.bb.taunt_level], ['SEARCH', 25, elite ? 90 : 50, -1], `T${tier}`); + assert.equal(glassOf(near), null, `T${tier}: no copy before the skill`); + u.skill.gainSp(999); + const e0 = h.spawn('enemy_dummy', { pos: [11, 5] }); + assert.ok(h.runUntil(() => u.skill.active, 0.5), `T${tier}: cast`); + h.run(0.2); + assert.deepEqual([u.s.aspd, u.s.taunt], [100 + sk.bb.attack_speed, -1], `T${tier}: ASPD / taunt`); + assert.deepEqual([glassOf(near)?.shield, glassOf(near)?.source === u, glassOf(far)], [200, true, null], `T${tier}: a copy in range only`); + // the copy breaks on a hit over 200: burst on Lin's range centred on the ally, Lin's ATK, source Lin + const inBurst = h.spawn('enemy_dummy', { pos: [10, 8] }), outBurst = h.spawn('enemy_dummy', { pos: [10, 9] }); + h.step(); + const hp0 = near.hp; + hurt(h, e0, near, 300); + approx(hp0 - near.hp, 100, `T${tier}: 300 − 200 through`); + assert.equal(glassOf(near), null, `T${tier}: broken`); + const gh = glassHits(h, u); + assert.ok(gh.some((c) => c.target === inBurst) && !gh.some((c) => c.target === outBurst), `T${tier}: centred on the ally`); + approx(gh.find((c) => c.target === inBurst).amount, u.s.atk * t0.atk_scale * (mod === PLXY ? 1 + 0.03 : 1), `T${tier}: ${t0.atk_scale * 100} % Lin ATK`); + assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun' && c.target === inBurst), `T${tier}: stunned`); + h.run(t0.interval - 0.2); + assert.equal(glassOf(near), null, `T${tier}: not before ${t0.interval} s`); + h.run(0.4); + assert.equal(glassOf(near)?.shield, 200, `T${tier}: back after ${t0.interval} s`); + h.runUntil(() => !u.skill.active, 30); + assert.equal(glassOf(near), null, `T${tier}: gone with the skill`); + assert.ok(glassOf(u), `T${tier}: hers stays`); + done(h); + } +}); + +test('S3 流光乍裂 (MANUAL, data CUSTOM_RANGE x-2, 26 / 28 s): cast with an enemy in x-2; range x-2 while on (bursts too), back after; ATK +125 % / +160 %; her 琉璃璧 threshold ×2 / ×2.5; a knock-out by her attack breaks it (burst) and raises it at once', () => { + 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.duration, sk.bb.atk, sk.bb.talent_scale, sk.rangeId], ['CUSTOM_RANGE', elite ? 28 : 26, elite ? 1.6 : 1.25, elite ? 2.5 : 2, 'x-2'], `T${tier}`); + assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid); + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [11, 7] }); // x-2 [1, 2], not x-1 + assert.ok(h.runUntil(() => u.skill.active, 0.5), `T${tier}: cast with an enemy in x-2`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + const thr = 200 * sk.bb.talent_scale; + assert.equal(glassOf(u).shield, thr, `T${tier}: threshold ${thr}`); + const hp0 = u.hp; + hurt(h, e, u, 450); + if (thr > 450) { assert.equal(u.hp, hp0, `T${tier}: 450 absorbed`); assert.ok(glassOf(u)); } else { approx(hp0 - u.hp, 450 - thr, `T${tier}: 450 − ${thr}`); assert.equal(glassOf(u), null); } + // a knock-out by her attack: burst (x-2 around her) and the 琉璃璧 standing again at once + if (!glassOf(u)) { h.run(9); assert.ok(glassOf(u), `T${tier}: regrown`); } + const witness = h.spawn('enemy_dummy', { pos: [9, 3] }); // x-2 [−1, −2] + const weak = h.spawn('enemy_weak', { pos: [10, 6] }); + h.step(); + const g0 = glassHits(h, u).length; + h.runUntil(() => !weak.alive, 4); + h.step(); + assert.ok(glassHits(h, u).slice(g0).some((c) => c.target === witness), `T${tier}: the kill's burst reaches x-2`); + assert.equal(glassOf(u)?.shield, thr, `T${tier}: raised again at once`); + h.runUntil(() => !u.skill.active, 40); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: x-1 again`); + assert.equal(glassOf(u)?.shield, 200, `T${tier}: plain threshold again`); + done(h); + } +}); + +test('T1 计出万全 (every form): a 琉璃璧 from deployment — a hit ≤ 200 absorbed whole, a hit over 200 goes through less 200 and breaks it: stun 1 s first, then 100 % / 110 % ATK arts on her range, regrowing after 8 s / 6 s (PLX-Y stage 3); a lethal hit still breaks it', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const order = []; + const { h, u } = field({ tier, elite, mod, skill: 2, setup: (b) => { + b.on('statusApplied', (c) => { if (c.status === 'stun' && c.source?.uid === 1) order.push('stun'); }); + b.on('damaged', (c) => { if (c.dmg?.tags?.includes('lin:glass')) order.push(c.target.s.flags.stun ? 'hit-stunned' : 'hit'); }); + } }); + const t0 = u.def.raw.talents.find((t) => t.index === 0).bb; + const y3 = elite && tier === 6 && mod === PLXY; + assert.deepEqual([t0.value, t0.atk_scale, t0.stun, t0.interval], [200, y3 ? 1.1 : 1, 1, y3 ? 6 : 8], label(f)); + assert.equal(glassOf(u)?.shield, 200, `${label(f)}: up from deployment`); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }), out = h.spawn('enemy_dummy', { pos: [10, 9] }); + h.step(); + const hp0 = u.hp; + hurt(h, e, u, 150); + hurt(h, e, u, 200); + assert.deepEqual([u.hp, glassOf(u)?.shield], [hp0, 200], `${label(f)}: 150 and 200 absorbed, full again`); + hurt(h, e, u, 300); + approx(hp0 - u.hp, 100, `${label(f)}: 300 − 200`); + assert.equal(glassOf(u), null, `${label(f)}: broken`); + const gh = glassHits(h, u); + assert.deepEqual(gh.map((c) => c.target.id), [e.id], `${label(f)}: her range only`); + const plxy = elite && mod === PLXY ? 1 + 0.03 * 1 : 1; + approx(gh[0].amount, u.s.atk * t0.atk_scale * plxy, `${label(f)}: ${t0.atk_scale * 100} % ATK`); + assert.deepEqual(order, ['stun', 'hit-stunned'], `${label(f)}: stun first`); + assert.ok(!h.hooksOf('statusApplied').some((c) => c.target === out && c.status === 'stun'), `${label(f)}: none outside`); + h.run(t0.interval - 0.1); + assert.equal(glassOf(u), null, `${label(f)}: not yet`); + h.run(0.2); + assert.equal(glassOf(u)?.shield, 200, `${label(f)}: back after ${t0.interval} s`); + // a lethal hit: the burst still comes + const n0 = glassHits(h, u).length; + hurt(h, e, u, u.hp + 1000); + assert.equal(u.alive, false, `${label(f)}: knocked out`); + assert.equal(glassHits(h, u).length, n0 + 1, `${label(f)}: the lethal hit broke it`); + done(h); + } +}); + +test('T2 韬光: each damage instance she takes rolls 50 % for +1 SP (PLX-X stage 3: 75 %, +2) — one the 琉璃璧 absorbs whole too; a 流失 never', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 2, seed: 33 }); + const x3 = elite && tier === 6 && mod === PLXX; + const t1 = u.def.raw.talents.find((t) => t.index === 1).bb; + assert.deepEqual([t1.prob, t1.sp], x3 ? [0.75, 2] : [0.5, 1], label(f)); + const e = h.spawn('enemy_dummy', { pos: [10, 9] }); + h.step(); + const gains = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent'); + const hp0 = u.hp; + for (let i = 0; i < 200; i++) { if (i % 10 === 0) u.skill.sp = 0; hurt(h, e, u, 50); } + assert.equal(u.hp, hp0, `${label(f)}: all absorbed`); + const absorbed = gains(); + assert.ok(Math.abs(absorbed.length / 200 - t1.prob) < 0.12, `${label(f)}: absorbed hits roll too: ${absorbed.length} / 200 ≈ ${t1.prob * 100} %`); + assert.ok(absorbed.every((c) => c.amount === t1.sp), `${label(f)}: +${t1.sp} SP`); + hurt(h, e, u, 400); // breaks it: 200 through + const n0 = gains().length; + for (let i = 0; i < 200; i++) { if (i % 10 === 0) u.skill.sp = 0; hurt(h, e, u, 1); } + const g = gains().slice(n0); + assert.ok(Math.abs(g.length / 200 - t1.prob) < 0.12, `${label(f)}: ${g.length} / 200 ≈ ${t1.prob * 100} %`); + const n1 = gains().length; + for (let i = 0; i < 50; i++) { u.skill.sp = 0; h.b.loseHp(u, 1, { source: e }); } + assert.equal(gains().length, n1, `${label(f)}: a 流失 rolls nothing`); + done(h); + } +}); + +test('PLX-Y “情与义” (stages 1 and 3): +3 % damage per enemy on her range, at most 5 (+15 %); none without it', () => { + for (const f of FORMS_ALL.filter(([, elite]) => elite)) { + const [tier, , mod] = f; + const plxy = mod === PLXY; + for (const n of [2, 7]) { + const { h, u } = field({ tier, elite: true, mod, skill: 0 }); + if (plxy) assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb), [{ damage_scale: 0.03, max_valid_stack_cnt: 5 }], label(f)); + const tiles = [[10, 6], [10, 7], [11, 5], [9, 5], [11, 4], [9, 4], [11, 6]].slice(0, n); + for (const p of tiles) h.spawn('enemy_dummy', { pos: p }); + u.skill.gainSp(999); + h.runUntil(() => atkHits(h, u).length >= n, 5); + for (const c of atkHits(h, u).slice(0, n)) approx(c.amount, u.s.atk * (plxy ? 1 + 0.03 * Math.min(5, n) : 1), `${label(f)}: ${n} enemies`); + done(h); + } + } +}); + +test('the x-1 grid of the tests is her data range (from (10, 5) facing RIGHT)', () => { + const { h, u } = field({}); + assert.deepEqual([...u.rangeKeys].sort((a, b) => a - b), X1.map(([r, c]) => r * 21 + c).sort((a, b) => a - b)); + done(h); +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index d842b158..a288f50f 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -90,130 +90,130 @@ "snaps": "d7b739d1 f5e170c4 bc463f9c 6ebd80ed 93f8e1ae ea1947a7 9541a28f e8dbac5b b0b3c196 14ae4a64 46640828 01c55ea9 0f261678 f447848b 0aad3542 db5850a3 631cc5ca 455c1dbc 68ad7134 24c71ebb 34a3ee93 37b05130 7a58a87e" }, "diy-003": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", - "fast": true, + "about": "5_diy1_a=ifrit/S1 5_diy2_a=ifrit/S2 5_diy1_a=ifrit/S3 5_diy2_b=ifrit/S1 5_diy1_b=ifrit/S2 5_diy2_b=ifrit/S3 5_diy1_b=ifrit/S1/002_ifrit 5_diy2_b=ifrit/S2/002_ifrit 5_diy1_b=ifrit/S3/002_ifrit 5_diy2_b=ifrit/S1/003_ifrit 5_diy1_b=ifrit/S2/003_ifrit 5_diy2_b=ifrit/S3/003_ifrit", + "fast": false, "kind": "normal", "stageId": "act1autochess_m03", "modeId": "mode_single_normal", "round": 12, "seed": 3865246633, "waveId": "act1autochess_h05", - "time": 196.5, - "ticks": 5895, + "time": 193, + "ticks": 5790, "reason": "cleared", - "killed": 8, + "killed": 2, "total": 93, "unspawned": 0, "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":8,"total":93,"perfect":false,"leaks":85,"leaksUncounted":0,"coins":0,"dmg":306215,"bossDmg":0,"heal":22436,"deaths":11,"layerGains":{}}}, + "players": {"p1":{"killed":2,"total":93,"perfect":false,"leaks":91,"leaksUncounted":0,"coins":0,"dmg":212380,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,114,0,690,6384,0,13,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,98,1,7246,10191,0,56,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,116,1,16020,8139,0,28,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,74,3,34176,12589,0,37,2], - ["p1",5,"op","chess_char_5_diy1_a",2,null,871,1,8,1,22261,400,0,129,3], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,12,0,32187,200,0,108,3], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,14,0,30588,200,0,77,2], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1160,1,4,1,71148,200,0,154,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,600,18186,48,3], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,115,0,0,8077,4251,12,2], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,1,40672,2000,0,123,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,15,0,51226,800,0,132,3], + ["p1",1,"op","chess_char_5_diy1_a",0,null,1276,1,6,1,70310,0,0,59,3], + ["p1",2,"op","chess_char_5_diy2_a",1,null,951,1,11,0,0,325,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,951,1,14,0,0,325,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1544,1,4,0,22475,0,0,25,3], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1544,1,5,0,46314,0,0,22,22], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,86,0,7150,3088,0,2,1], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_ifrit",1544,1,12,0,24754,0,0,24,3], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_ifrit",1544,1,15,0,0,0,0,0,0], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_ifrit",0,0,71,0,15609,3088,0,9,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_ifrit",0,0,63,1,9481,3328,0,7,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_ifrit",0,0,64,0,13086,3328,0,7,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_ifrit",0,0,108,0,3201,3328,0,4,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_1267_nhpbr_2":[36,8,28,17650,131613],"enemy_1270_nhstlk_2":[27,0,27,15235,63417],"enemy_9006_actoxi":[30,0,30,16896,111185]}, - "hooks": [1,116,5895,1089,1089,1215,1215,60,19,104,26,24,0,28385,254,28,93,85,1,19,0,0,0], - "events": {"atk":1089,"deploy":20,"die":19,"dmg":1215,"fx":8,"heal":60,"leak":85,"skill":50,"spawn":108,"status":504}, - "fx": {"zone":8}, - "snaps": "b1b2dda9 6ac577d6 84811e80 eb6305ea 52a34c95 c2fd4731 45525f0b dbcc3ddb ddfcbc11 0874e859 206cd671 4c83f44b f4f5331a 194da7d5 1fe13dbf 6b7331b4 4cf19629 7bae5e13 5b7078fc 65a8a1f5" + "enemies": {"enemy_1267_nhpbr_2":[36,2,34,2564,40529],"enemy_1270_nhstlk_2":[27,0,27,10792,82045],"enemy_9006_actoxi":[30,0,30,3328,89806]}, + "hooks": [1,113,5790,196,196,394,432,0,12,103,35,35,0,21952,0,11,93,91,1,12,0,0,0], + "events": {"atk":267,"deploy":17,"die":12,"dmg":432,"fx":31,"leak":91,"skill":70,"spawn":108,"status":14}, + "fx": {"aoe":24,"flame":5,"zone":2}, + "snaps": "56cf436f 3cc21be2 a1422190 160ec33f 40100e49 f78e0fa0 c96e2d27 dd1b7b7d 5f332a9e a52d9e05 2e8215a6 c5943658 d698fcfb 6f1e6367 4540e190 7498e94d 29421eb6 6c64f6a4 fd677a64 67f117f1" }, "diy-004": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "about": "6_diy1_a=ifrit/S1 6_diy2_a=ifrit/S2 6_diy1_a=ifrit/S3 6_diy2_b=ifrit/S1 6_diy1_b=ifrit/S2 6_diy2_b=ifrit/S3 6_diy1_b=ifrit/S1/002_ifrit 6_diy2_b=ifrit/S2/002_ifrit 6_diy1_b=ifrit/S3/002_ifrit 6_diy2_b=ifrit/S1/003_ifrit 6_diy1_b=ifrit/S2/003_ifrit 6_diy2_b=ifrit/S3/003_ifrit", "fast": false, "kind": "normal", "stageId": "act1autochess_m04", "modeId": "mode_multi_abyss", - "round": 12, + "round": 13, "seed": 1210847140, - "waveId": "act1autochess_h05", - "time": 224.77, - "ticks": 6743, + "waveId": "act1autochess_h06", + "time": 211.87, + "ticks": 6356, "reason": "cleared", - "killed": 21, - "total": 117, + "killed": 0, + "total": 111, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 350, + "rngDraws": 7, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":21,"total":117,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":374057,"bossDmg":0,"heal":25487,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":111,"perfect":false,"leaks":111,"leaksUncounted":0,"coins":0,"dmg":631748,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,116,0,0,5208,0,0,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",100.37,1,143,0,0,6642,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1060.28,1,145,0,4912,3527,0,0,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,98,5,85077,5533,0,0,3], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,104,0,0,8356,0,0,0], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,100,0,0,20296,0,0,0], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,132,0,16051,2544,0,16,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,135,5,91286,3174,0,37,1], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,77,0,6653,2744,0,3,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,7,11,170077,1749,0,21,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,144,0,0,5333,10172,24,1], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,94,0,0,8375,15315,21,1], + ["p1",1,"op","chess_char_6_diy1_a",0,null,1276,1,4,0,39233,0,0,36,3], + ["p1",2,"op","chess_char_6_diy2_a",1,null,1276,1,5,0,373699,0,0,69,34], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,15,0,71917,1276,0,29,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",0,0,95,0,17302,3088,0,11,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",1544,1,132,0,10736,3088,0,6,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,117,0,4743,3088,0,4,0], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_ifrit",0,0,72,0,1265,3088,0,2,0], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_ifrit",0,0,68,0,2767,3088,0,3,1], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_ifrit",0,0,7,0,55086,1544,0,6,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_ifrit",0,0,9,0,28024,1744,0,8,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_ifrit",0,0,11,0,14532,1744,0,6,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_ifrit",0,0,13,0,12442,1744,0,6,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_1005_yokai":[51,16,35,0,83756],"enemy_1005_yokai_2":[54,5,49,24107,111017],"enemy_1005_yokai_3":[12,0,12,42273,179284]}, - "hooks": [1,145,6743,501,501,975,936,86,44,140,9,9,0,14181,0,0,117,96,1,44,0,0,39], - "events": {"atk":594,"deploy":25,"die":44,"dmg":936,"fx":87,"heal":78,"leak":96,"skill":18,"spawn":132,"status":8}, - "fx": {"aoe":1,"dodge":39,"dp":9,"slash":32,"spGain":6}, - "snaps": "6b632c98 e087bd9f f5b9da13 f7038fad bb4d7811 5178d072 0c0de684 872f1892 81773c41 3d296a6a 66f1329a 3a99c8b1 aa011a13 319c8ec5 26231cc7 f808b9be 73dd7977 75857859 817f18b6 d1cdd533 cd927f27 64106a51 89f62784" + "enemies": {"enemy_1010_demon":[54,0,54,11302,295081],"enemy_1320_wdrrl_2":[18,0,18,0,136320],"enemy_1422_lrsldr_2":[39,0,39,6907,289736]}, + "hooks": [1,132,6356,214,214,2738,2865,0,15,126,48,48,0,19557,0,13,111,111,1,15,0,0,0], + "events": {"atk":683,"deploy":18,"die":15,"dmg":2865,"fx":95,"leak":111,"skill":96,"spawn":126}, + "fx": {"aoe":39,"flame":56}, + "snaps": "15ae3156 4df4adf4 2977c6c1 e0d52213 58e8d48d 58e30044 7d95eba8 7f9e1d66 04045f1d 7085cc72 0fc7ef9c 01f90e91 843e294f 4157ed40 a8c7c494 665ef7d6 ab124299 ad09efb8 777ca74e a3f282fa 55f36d42 939853cb" }, "diy-005": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "5_diy1_a=aphris/S1 5_diy2_a=aphris/S2 5_diy1_a=aphris/S3 5_diy2_b=aphris/S1 5_diy1_b=aphris/S2 5_diy2_b=aphris/S3 5_diy1_b=aphris/S1/002_aphris 5_diy2_b=aphris/S2/002_aphris 5_diy1_b=aphris/S3/002_aphris 6_diy1_a=aphris/S1 6_diy2_a=aphris/S2 6_diy1_a=aphris/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_multi_funny", - "round": 13, + "round": 12, "seed": 823748262, - "waveId": "act1autochess_h06", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 63, - "total": 123, + "waveId": "act1autochess_h05", + "time": 180.03, + "ticks": 5401, + "reason": "cleared", + "killed": 60, + "total": 60, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 117, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":63,"total":123,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":274732,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, + "players": {"p1":{"killed":60,"total":60,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":750844,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,1834,1,132,5,19446,3374,0,80,2], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2142.36,1,141,5,29488,4720,0,101,2], - ["p1",3,"op","chess_char_5_diy1_a",0,null,1865,1,154,3,27639,3730,0,42,1], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,152,11,43106,4748,0,39,1], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,135,3,18986,4587,0,18,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1808.52,1,143,18,92036,3903,0,40,4], - ["p1",7,"op","chess_char_5_diy1_a",2,null,117.2,1,130,12,39744,6807,0,115,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,1,0,0,0,0,0,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,3,0,0,0,0,0,0], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,5,0,0,0,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,153,0,1743,2320,0,7,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1120.4,1,145,0,2545,3170,0,7,1] + ["p1",1,"op","chess_char_5_diy1_a",0,null,1261,1,10,0,38386,0,0,30,1], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1261,1,11,0,28644,0,0,13,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1261,1,1,0,0,0,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1526,1,3,2,33190,0,0,20,1], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1526,1,5,0,12390,0,0,12,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1526,1,7,4,60738,0,0,24,1], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_aphris",1646,1,9,0,0,0,0,0,1], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_aphris",1646,1,12,4,24648,0,0,12,3], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_aphris",1646,1,2,21,97631,0,0,46,2], + ["p1",10,"op","chess_char_6_diy1_a",0,null,1261,1,4,4,108085,0,0,52,1], + ["p1",11,"op","chess_char_6_diy2_a",1,null,1261,1,6,12,149605,0,0,42,6], + ["p1",12,"op","chess_char_6_diy1_a",2,null,1261,1,8,12,197528,0,0,70,2] ], - "enemies": {"enemy_1019_jshoot_2":[57,37,20,9953,112024],"enemy_1299_ymkilr_2":[27,6,20,25821,123649],"enemy_1422_lrsldr_2":[39,20,19,1584,123193]}, - "hooks": [1,154,6900,835,835,3335,3375,0,83,142,12,15,0,16427,38,83,123,59,1,83,0,0,0], - "events": {"atk":851,"deploy":31,"die":83,"dmg":3367,"fx":150,"leak":59,"skill":22,"spawn":135,"status":47}, - "fx": {"aoe":83,"displace":3,"dp":26,"slash":38}, - "snaps": "aa4b712f 51a2360b 59868653 c815c7eb 7dd47d06 3f3f03f4 89e883c3 60e1e7f6 2355716a 5d734980 75cde05f c0dd657c 4e5bd257 0f8a040d b1aafe05 c04cbb36 e6d37890 8457998a a19143e5 d709bc2b 4283543b 0d9edf54 6ef9963d" + "enemies": {"enemy_1045_hammer_2":[12,12,0,0,230400],"enemy_1061_zomshd":[48,48,0,0,523980]}, + "hooks": [1,72,5401,326,321,776,776,9477,60,60,24,18,91,22038,109,0,60,0,1,60,0,0,0], + "events": {"atk":512,"deploy":12,"die":60,"dmg":776,"fx":15,"skill":42,"spawn":72,"status":207}, + "fx": {"beam":15}, + "snaps": "b782344a 4123b437 470e30be a5359654 5b808731 98b610ce 45341aa3 6db13889 a7e4c72b 43ed264f c02b64d7 e54f9075 4a8cdbb1 c769ba09 95cd8fb8 3cc9e27d 705f9f55 a43d7c40" }, "diy-006": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "6_diy1_b=aphris/S1 6_diy2_b=aphris/S2 6_diy1_b=aphris/S3 6_diy2_b=aphris/S1/002_aphris 6_diy1_b=aphris/S2/002_aphris 6_diy2_b=aphris/S3/002_aphris 5_diy1_a=lin/S1 5_diy2_a=lin/S2 5_diy1_a=lin/S3 5_diy2_b=lin/S1 5_diy1_b=lin/S2 5_diy2_b=lin/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m06", @@ -221,35 +221,275 @@ "round": 13, "seed": 2464539579, "waveId": "act1autochess_h06", - "time": 210.23, - "ticks": 6307, + "time": 209.03, + "ticks": 6271, "reason": "cleared", - "killed": 5, + "killed": 12, "total": 81, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 123, + "rngDraws": 196, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":5,"total":81,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":185202,"bossDmg":0,"heal":4048,"deaths":30,"layerGains":{}}}, + "players": {"p1":{"killed":12,"total":81,"perfect":false,"leaks":69,"leaksUncounted":0,"coins":0,"dmg":345592,"bossDmg":0,"heal":0,"deaths":19,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,3,0,11325,1865,0,7,1], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,6,3,52957,2494,0,16,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,112,0,15444,8178,0,22,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,109,2,51553,12663,0,57,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,113,0,4256,2544,0,15,0], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,105,0,10180,3286,0,19,0], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,5,0,3060,1372,0,2,0], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,2,0,30589,1819,0,4,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,8,0,0,2028,2464,7,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,61,0,0,3890,1584,6,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,58,0,2490,3291,0,7,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,60,0,3347,3320,0,8,0] + ["p1",1,"op","chess_char_6_diy1_b",0,"none",0,0,1,1,6642,1526,0,7,1], + ["p1",2,"op","chess_char_6_diy2_b",1,"none",0,0,3,2,30451,1526,0,4,1], + ["p1",3,"op","chess_char_6_diy1_b",2,"none",0,0,5,1,23037,1526,0,9,1], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_aphris",0,0,2,1,42219,1726,0,11,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_aphris",0,0,8,1,39917,1726,0,3,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_aphris",0,0,56,0,5914,3452,0,4,0], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,91,0,22776,5037,0,5,3], + ["p1",8,"op","chess_char_5_diy2_a",1,null,1679,1,102,3,38137,3358,0,13,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1679,1,99,0,14710,3358,0,1,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,55,0,18105,3848,0,5,2], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,82,0,65772,3848,0,21,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,6,3,37914,1924,0,5,1] ], - "enemies": {"enemy_1267_nhpbr_2":[27,5,22,7765,89733],"enemy_1270_nhstlk_2":[36,0,36,14278,68347],"enemy_1272_nhtank_2":[18,0,18,24706,27121]}, - "hooks": [1,113,6307,262,262,449,476,16,35,111,6,21,0,5828,86,24,81,76,1,35,0,0,0], - "events": {"atk":283,"deploy":32,"die":35,"dmg":441,"fx":58,"heal":15,"leak":76,"skill":12,"spawn":93,"status":142}, - "fx": {"aoe":7,"displace":21,"spGain":6,"zone":24}, - "snaps": "1dcf4706 8bd55683 d93e4d02 ab832ad5 132699ab f37b039d f12175ca fe30225b 6b2b77c0 2caefa3a 1018f9e1 a60a2364 f81df3c0 d0f2780e 0e940f53 372c90ef 46af3a81 a6b2adf1 63234976 3b8b7c59 b62aa8d4" + "enemies": {"enemy_1267_nhpbr_2":[27,5,22,1106,79212],"enemy_1270_nhstlk_2":[36,4,32,10553,141643],"enemy_1272_nhtank_2":[18,3,15,21196,124737]}, + "hooks": [1,102,6271,163,162,654,654,0,31,100,16,16,8,9094,113,17,81,69,1,31,0,0,0], + "events": {"atk":325,"deploy":21,"die":31,"dmg":566,"fx":78,"leak":69,"skill":32,"spawn":93,"status":233}, + "fx": {"aoe":21,"beam":2,"shield":29,"zone":26}, + "snaps": "943ca947 29dae2e9 28a4b453 93611866 203f486a 284978b2 c87f5ac4 16619fd5 47aeeed1 4b0b5c6b 05ab5128 07bbedd8 c032ce24 04dc408e f2204c84 89b75d97 c5948750 f1d8bf82 334a06ee f05caab3 a894b9ad" + }, + "diy-007": { + "about": "5_diy1_b=lin/S1/002_lin 5_diy2_b=lin/S2/002_lin 5_diy1_b=lin/S3/002_lin 5_diy2_b=lin/S1/003_lin 5_diy1_b=lin/S2/003_lin 5_diy2_b=lin/S3/003_lin 6_diy1_a=lin/S1 6_diy2_a=lin/S2 6_diy1_a=lin/S3 6_diy2_b=lin/S1 6_diy1_b=lin/S2 6_diy2_b=lin/S3", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m07", + "modeId": "mode_multi_normal", + "round": 13, + "seed": 1837403644, + "waveId": "act1autochess_h06", + "time": 208.33, + "ticks": 6250, + "reason": "cleared", + "killed": 88, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 10, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":88,"total":123,"perfect":false,"leaks":35,"leaksUncounted":0,"coins":0,"dmg":2554844,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_lin",1924,1,5,8,116946,0,0,49,1], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_lin",1924,1,7,14,140489,0,0,69,4], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_lin",1924,1,9,12,371672,0,0,38,3], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_lin",1924,1,11,2,216993,0,0,63,1], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_lin",1924,1,13,6,239471,0,0,78,4], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_lin",1924,1,6,12,287066,0,0,38,3], + ["p1",7,"op","chess_char_6_diy1_a",0,null,1679,1,8,6,195028,0,0,63,1], + ["p1",8,"op","chess_char_6_diy2_a",1,null,1679,1,10,8,174423,0,0,57,3], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1679,1,12,17,395011,0,0,29,3], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",1924,1,14,2,364997,0,0,65,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,99,1,27507,3848,0,31,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,89,0,25241,3848,0,12,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1006_shield_3":[21,1,20,7501,881513],"enemy_1043_zomsbr":[66,64,2,0,520601],"enemy_1402_tgshd_2":[36,23,13,195,1152730]}, + "hooks": [1,139,6250,602,602,2257,2257,18339,92,127,28,19,0,26946,972,5,123,35,1,92,0,0,0], + "events": {"atk":2157,"deploy":14,"die":92,"dmg":2257,"fx":1036,"leak":35,"skill":47,"spawn":137,"status":1817}, + "fx": {"aoe":33,"expose":896,"shield":107}, + "snaps": "f51b40c7 6a247d88 56a55bc5 b9af1702 370a0a3e 35aa110d cd26a731 558ea021 ff979a04 0a0cb57e 9f65ecea 7ee9e03d 4720e443 ae99a198 443298d7 5450a31d 106ec065 746c2f14 273a8f2a 07f0f7e6 3b9f0d99" + }, + "diy-008": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=lin/S1/002_lin 6_diy2_b=lin/S2/002_lin 6_diy1_b=lin/S3/002_lin 6_diy2_b=lin/S1/003_lin 6_diy1_b=lin/S2/003_lin 6_diy2_b=lin/S3/003_lin 5_diy1_a=csnipe 5_diy2_b=csnipe", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m01", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 3478524561, + "waveId": "act1autochess_h06", + "time": 220.33, + "ticks": 6610, + "reason": "cleared", + "killed": 3, + "total": 147, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 45, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":3,"total":147,"perfect":false,"leaks":144,"leaksUncounted":0,"coins":0,"dmg":901295,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,103,0,878,4256,0,10,1], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,153,0,4984,5816,0,37,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,161,0,3949,7692,0,15,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",3334,1,172,0,4881,6668,0,15,1], + ["p1",5,"op","chess_char_6_diy1_b",0,"uniequip_002_lin",1924,1,2,0,141406,0,0,60,1], + ["p1",6,"op","chess_char_6_diy2_b",1,"uniequip_002_lin",1924,1,3,1,155307,0,0,74,4], + ["p1",7,"op","chess_char_6_diy1_b",2,"uniequip_002_lin",1924,1,4,1,287056,0,0,41,3], + ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_003_lin",0,0,95,0,61305,3848,0,10,2], + ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_003_lin",0,0,139,0,88073,3848,0,34,2], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_003_lin",0,0,91,0,145901,3848,0,6,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,169,0,2047,3813,0,10,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,171,1,5507,4530,0,47,1] + ], + "enemies": {"enemy_1166_dusbr_2":[39,2,37,9772,207538],"enemy_1169_duphlx_2":[57,1,56,13789,308650],"enemy_1170_dushld_2":[51,0,51,20758,385107]}, + "hooks": [1,172,6610,510,510,2280,2280,0,24,168,18,15,0,17338,665,31,147,144,1,24,0,0,0], + "events": {"atk":2086,"deploy":25,"die":24,"dmg":2258,"fx":54,"leak":144,"skill":33,"spawn":159,"status":1467}, + "fx": {"aoe":18,"shield":36}, + "snaps": "b96efac4 8ae420e7 063c1971 0c883e9c 920e27df 7c38e90c f535424c 869b4f0a 08e53408 95c453ed fb21d3d6 1de3707d a197a338 f027ac70 fdf1564f 674c96f2 d1db6833 a7a2d441 7f4b0a75 b9f6178d e249232d 9921a0b4" + }, + "diy-009": { + "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_m02", + "modeId": "mode_single_normal", + "round": 12, + "seed": 2612428600, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 18, + "total": 42, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 100, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":18,"total":42,"perfect":false,"leaks":24,"leaksUncounted":0,"coins":0,"dmg":60423,"bossDmg":0,"heal":11501,"deaths":27,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,3,0,0,2604,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,5,0,0,3404,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,64,0,12281,4302,0,0,2], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,69,1,19550,7605,0,0,3], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,47,0,0,7726,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,44,0,0,6822,0,0,0], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,52,0,0,2266,0,0,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,50,0,16580,4004,0,35,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,67,0,0,5674,2020,7,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,71,0,0,5543,9480,20,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,70,0,5609,3380,0,15,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,68,0,6403,3963,0,17,1], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1005_yokai_2":[18,18,0,830,5156],"enemy_1005_yokai_3":[12,0,11,28478,30434],"enemy_9009_acfort":[12,0,12,27987,24833]}, + "hooks": [1,71,6900,462,462,467,442,27,45,68,10,10,0,12457,32,0,42,23,1,28,0,0,25], + "events": {"atk":462,"deploy":27,"die":45,"dmg":442,"fx":121,"heal":27,"leak":23,"skill":20,"spawn":56,"status":107}, + "fx": {"beam":55,"dodge":25,"dp":6,"slash":35}, + "snaps": "8f962d08 3056b267 beb14b31 2394cb94 2b9de697 194c89b5 3a08d0d2 1d09774e 959dc47d 270d0004 f0773a73 3566bde9 b4abb17e 8201dc8f a0766de3 267f5f64 24e29cf1 b763dc25 c2f98e66 db226502 88b350d1 0dc0611a 604e4306" + }, + "diy-010": { + "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": "act2autochess_m03", + "modeId": "mode_multi_abyss", + "round": 12, + "seed": 4252812069, + "waveId": "act1autochess_h05", + "time": 208.93, + "ticks": 6268, + "reason": "cleared", + "killed": 0, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 113, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":0,"total":114,"perfect":false,"leaks":114,"leaksUncounted":0,"coins":0,"dmg":100335,"bossDmg":0,"heal":6344,"deaths":30,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,5,0,15630,1865,0,9,1], + ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,7,0,18138,3029,0,5,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,145,0,25048,10069,0,63,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",0,0,147,0,19052,13642,0,19,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,445.63,1,148,0,6079,3370,0,15,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,141,0,5804,5761,0,12,0], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,13,0,1663,1372,0,2,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,73,0,1993,5153,0,2,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,4,0,0,1657,3351,5,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,10,0,0,1414,2993,6,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,6,0,2988,1160,0,7,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,8,0,3939,1430,0,7,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,0,51,9812,8520],"enemy_1381_winman":[27,0,27,9427,90722],"enemy_1389_winbab_2":[36,0,36,30683,1093]}, + "hooks": [1,148,6268,331,331,310,319,18,30,144,5,21,0,5538,61,21,114,114,1,30,0,0,0], + "events": {"atk":343,"deploy":31,"die":30,"dmg":304,"fx":16,"heal":16,"leak":114,"skill":10,"spawn":129,"status":58}, + "fx": {"displace":8,"spGain":8}, + "snaps": "2971e9ff e77895c1 13abdad5 03f3c6e3 025969bd f22bbc34 92c20fec fd2f44ee 674a7f68 561c16b2 a94168d2 1b34b5cf 1d6ac33f b46b69d8 57111593 2c0e5d03 c4e78702 e9d8f752 1a9f888a c9d4b090 82ba1d23" + }, + "diy-011": { + "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": "act2autochess_m04", + "modeId": "mode_multi_funny", + "round": 13, + "seed": 538212341, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 83, + "total": 144, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 754, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":83,"total":144,"perfect":false,"leaks":61,"leaksUncounted":0,"coins":0,"dmg":727357,"bossDmg":0,"heal":57340,"deaths":20,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,7,8,27898,13166,0,80,2], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2829.8,1,167,7,27469,8851,0,66,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,6,16,140111,3022,0,54,6], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,144,14,159200,6358,0,8,5], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,143,2,11845,7595,0,14,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,136,20,76014,33300,0,54,3], + ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,3,2,30581,1272,0,62,2], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,4,12,238233,1643,0,161,2], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,153,1,9117,5500,0,8,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,169,0,6889,7367,0,6,0], + ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,5,0,0,1644,40995,55,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,168,0,0,8573,16345,21,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_2021_syfish":[99,73,24,10687,302163],"enemy_2025_syufo":[45,10,35,87604,438052]}, + "hooks": [1,169,6900,1412,1412,2733,2987,143,103,162,26,26,0,27540,0,64,144,59,1,104,0,33,62], + "events": {"atk":1560,"deploy":23,"die":103,"dmg":2987,"fx":320,"heal":134,"leak":59,"skill":52,"spawn":158,"status":123}, + "fx": {"aoe":80,"burst":33,"dodge":62,"dp":41,"slash":88,"spGain":16}, + "snaps": "55a12ce8 172ad35e b858882a 66672e2c bf89e3e3 9be6cb9e a0514ec0 d7b44a56 39783f17 ba8e20c8 09cd87af 746573df b9e27578 e7dd7dfc cb04e699 92d3e0b3 7bdb1773 b6882cdd a0d06e3c 5eab91e4 24e99439 b044b69e 7d8c5fa0" + }, + "diy-012": { + "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_m01", + "modeId": "mode_single_hard", + "round": 13, + "seed": 3192456040, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 10, + "total": 117, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 178, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":10,"total":117,"perfect":false,"leaks":107,"leaksUncounted":0,"coins":0,"dmg":228417,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,145,1,34634,5595,0,56,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,143,7,57826,7482,0,40,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,146,0,22026,9232,0,68,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,147,2,58947,13356,0,67,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,4,0,23667,0,0,150,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,5,0,31317,0,0,155,3], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1166_dusbr_2":[39,8,31,3534,97703],"enemy_1170_dushld_2":[39,2,36,10197,64140],"enemy_9011_acrefr":[39,0,37,21934,66574]}, + "hooks": [1,147,6900,622,622,893,944,0,33,137,8,23,0,11211,11900,38,117,104,1,33,0,0,0], + "events": {"atk":668,"deploy":27,"die":33,"dmg":838,"fx":20,"leak":104,"skill":14,"spawn":126,"status":1135}, + "fx": {"displace":16,"dodge":4}, + "snaps": "d28bb789 63eb985d 4d0f9eff 5acb7032 16fffd96 ad116d10 106aebb2 764edc40 ffc24dbd d20cfab0 2ec7d68f c9765510 e0639444 cbfcf6d4 de2fc904 6c97bbcb 69043b03 56f9d1aa c7b63229 79cdb25e 4e2e6e65 d377e48b d6594021" } } }