diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index 034f4936..99f513f5 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -100,6 +100,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([ 'op-huang.js', 'op-shwaz.js', 'op-bgsnow.js', + 'op-ifrit.js', + 'op-aphris.js', + 'op-lin.js', ]); /** 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 a7702438..0793c28c 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -1726,132 +1726,132 @@ "snaps": "20b7fc37 08678156 135cc57c 89dbd854 b8ecd492 a19c7df4 24b1162b 55c5c742 28fd3c49 bfc80994 5c821609 77fc27bc 3f2ff411 6b8a048a 187c1e4b 9c39986d 0fa15a34 4ad95b9b 09abaf91 eb92a8c4 b64e626c" }, "diy-043": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=bgsnow/S1/002_bgsnow 6_diy2_b=bgsnow/S2/002_bgsnow 6_diy1_b=bgsnow/S3/002_bgsnow 6_diy2_b=bgsnow/S1/003_bgsnow 6_diy1_b=bgsnow/S2/003_bgsnow 6_diy2_b=bgsnow/S3/003_bgsnow 5_diy1_a=csnipe 5_diy2_b=csnipe", - "fast": true, + "about": "6_diy1_b=bgsnow/S1/002_bgsnow 6_diy2_b=bgsnow/S2/002_bgsnow 6_diy1_b=bgsnow/S3/002_bgsnow 6_diy2_b=bgsnow/S1/003_bgsnow 6_diy1_b=bgsnow/S2/003_bgsnow 6_diy2_b=bgsnow/S3/003_bgsnow 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", + "fast": false, "kind": "normal", "stageId": "act2autochess_m03", "modeId": "mode_multi_normal", "round": 13, "seed": 1440042731, "waveId": "act1autochess_h06", - "time": 215.17, - "ticks": 6455, + "time": 212.63, + "ticks": 6379, "reason": "cleared", - "killed": 6, + "killed": 13, "total": 117, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 194, + "rngDraws": 189, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":6,"total":117,"perfect":false,"leaks":111,"leaksUncounted":0,"coins":0,"dmg":700578,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}}, + "players": {"p1":{"killed":13,"total":117,"perfect":false,"leaks":104,"leaksUncounted":0,"coins":0,"dmg":790369,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,143,0,620,6384,0,33,1], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,119,0,8344,5816,0,64,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,135,0,8794,7692,0,27,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,139,1,12488,10002,0,31,1], - ["p1",5,"op","chess_char_6_diy1_b",0,"uniequip_002_bgsnow",1826,1,9,0,122692,0,0,122,1], - ["p1",6,"op","chess_char_6_diy2_b",1,"uniequip_002_bgsnow",1826,1,11,1,130874,0,0,138,20], - ["p1",7,"op","chess_char_6_diy1_b",2,"uniequip_002_bgsnow",1826,1,13,0,57438,0,0,74,3], - ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_003_bgsnow",1656,1,15,0,129749,0,0,122,1], - ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_003_bgsnow",1656,1,4,3,123659,0,0,136,21], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_003_bgsnow",1656,1,10,1,103257,0,0,148,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,130,0,1630,2542,0,22,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,126,0,1035,3020,0,16,0], + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_bgsnow",1826,1,8,1,112177,0,0,118,1], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_bgsnow",1826,1,11,2,135473,0,0,135,20], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_bgsnow",1826,1,13,1,119294,0,0,152,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_bgsnow",1656,1,15,0,135005,0,0,122,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_bgsnow",1656,1,4,4,129402,0,0,134,21], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_bgsnow",1656,1,9,4,119117,0,0,148,3], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,99,0,1285,2552,0,4,0], + ["p1",8,"op","chess_char_5_diy2_a",1,null,1276,1,138,1,16520,2552,0,10,3], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1276,1,122,0,6585,1276,0,6,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,76,0,7495,3088,0,7,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,73,0,6862,3088,0,5,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,6,0,1155,1544,0,0,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_1166_dusbr_2":[39,5,34,8549,238889],"enemy_1169_duphlx_2":[57,1,56,14552,262496],"enemy_1172_dugago":[21,0,21,12355,199192]}, - "hooks": [1,143,6455,1126,1126,1207,1207,0,21,132,52,50,0,30589,0,27,117,111,1,21,0,0,0], - "events": {"atk":1126,"deploy":23,"die":21,"dmg":1207,"fx":56,"leak":111,"skill":102,"spawn":132,"status":117}, - "fx": {"crit":56}, - "snaps": "16b2f305 eafa3ff3 2a1c8165 ce308737 9eeb163c 87863b4e 5bb3a0af 894ca696 0f98dafb bc8b6e4c e73802d2 2a71c651 37a948e1 2e3a11c8 10d22fe5 1c634514 db620b5b 9bc32a6a 22c16473 34cae7d7 0f1a1234 6153da56" + "enemies": {"enemy_1166_dusbr_2":[39,11,28,1544,288518],"enemy_1169_duphlx_2":[57,2,55,12402,317083],"enemy_1172_dugago":[21,0,21,0,184767]}, + "hooks": [1,138,6379,872,872,1020,1025,0,23,127,54,52,0,26374,0,10,117,104,1,23,0,0,0], + "events": {"atk":911,"deploy":18,"die":23,"dmg":1025,"fx":64,"leak":104,"skill":106,"spawn":132,"status":117}, + "fx": {"aoe":4,"crit":55,"flame":5}, + "snaps": "91853125 06afd857 6529ba9a f61610bb 5a37fb46 84fbfac3 a7259db8 4abbf5aa 98dc5387 c2ef870c e54be7cc af4fa409 04b9c085 bc99cf63 66c832fd cf31a2ad 26ab8065 9070c369 a2bfad79 28042255 68a00a5e 4abf7baf" }, "diy-044": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "about": "5_diy1_b=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 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", "fast": false, "kind": "normal", "stageId": "act2autochess_m04", "modeId": "mode_multi_hard", - "round": 12, + "round": 13, "seed": 4094229119, - "waveId": "act1autochess_h05", - "time": 221.97, - "ticks": 6659, + "waveId": "act1autochess_h06", + "time": 224, + "ticks": 6720, "reason": "cleared", - "killed": 3, - "total": 102, + "killed": 1, + "total": 123, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 31, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":3,"total":102,"perfect":false,"leaks":99,"leaksUncounted":0,"coins":0,"dmg":192904,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}}, + "players": {"p1":{"killed":1,"total":123,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":1382234,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,123,0,1971,7812,0,20,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3371,1,127,0,7190,6742,0,35,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,125,0,16252,4587,0,13,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,124,0,20294,5712,0,11,2], - ["p1",5,"op","chess_char_5_diy1_a",2,null,2638,1,126,2,18375,5276,0,20,1], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,87,1,58519,6822,0,47,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,3,0,7285,0,0,52,3], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,4,0,27011,0,0,164,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,13,0,0,0,0,0,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,14,0,0,0,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,5,0,15405,0,0,132,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,11,0,20602,0,0,148,4], + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_ifrit",1544,1,3,0,100236,0,0,72,3], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_ifrit",1544,1,4,0,491349,0,0,70,34], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_ifrit",1544,1,11,0,0,0,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_ifrit",1664,1,13,0,0,0,0,0,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_ifrit",1664,1,5,1,304625,0,0,68,35], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_ifrit",1664,1,143,0,135980,1664,0,24,3], + ["p1",7,"op","chess_char_6_diy1_a",0,null,1276,1,12,0,13371,0,0,25,3], + ["p1",8,"op","chess_char_6_diy2_a",1,null,1276,1,14,0,0,0,0,0,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,9,0,286283,1276,0,29,3], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,84,0,22176,3088,0,13,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,131,0,23327,4632,0,11,3], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,124,0,4887,4632,0,7,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] ], - "enemies": {"enemy_1166_dusbr_2":[45,3,42,12104,88284],"enemy_1170_dushld_2":[27,0,27,14317,15722],"enemy_1174_duholy_2":[30,0,30,10530,116257]}, - "hooks": [1,127,6659,779,779,1569,1558,0,18,117,20,19,0,23824,278,35,102,99,1,22,0,0,11], - "events": {"atk":779,"deploy":23,"die":18,"dmg":1558,"fx":52,"leak":99,"skill":39,"spawn":116,"status":532}, - "fx": {"aoe":14,"dodge":11,"dp":3,"slash":24}, - "snaps": "30cbe1b0 05bcd998 fbb42f1f 6da22ddd 354379d2 708b0c83 454a4a54 8e01e4f7 67033904 4f9606c5 e038a3b1 5e41e6cd 5cbebe3c 58f3a062 143d8571 d0f375cb b2ce9936 615804ca 181c3efa 947f6da4 ae4a580a 18f00b15 c2c220ef" + "enemies": {"enemy_1010_demon":[54,0,54,4632,542827],"enemy_1422_lrsldr_2":[39,1,38,1544,449425],"enemy_1425_lrcmra_2":[30,0,30,6176,427622]}, + "hooks": [1,143,6720,332,332,3525,4443,0,11,133,85,85,0,30704,0,8,123,122,1,11,0,0,0], + "events": {"atk":1136,"deploy":18,"die":11,"dmg":4443,"fx":174,"leak":122,"skill":170,"spawn":137}, + "fx": {"aoe":72,"flame":102}, + "snaps": "4b47af19 26fae251 334aea64 90cf1356 632bd915 6b208509 56956bc7 fb0fde6c b8e1589a 5b0fd4fa 0a256b44 e3c05507 0d0fd3cb 6f50e0c0 1ebb781e d811c4ad 1c9aa388 05dc4236 151056b9 29a180ce 98531a71 1b1e582c db0bf003" }, "diy-045": { - "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "6_diy1_b=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 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", "fast": false, "kind": "normal", "stageId": "act1autochess_m01", "modeId": "mode_single_normal", - "round": 12, + "round": 13, "seed": 426256309, - "waveId": "act1autochess_h05", - "time": 211.8, - "ticks": 6354, + "waveId": "act1autochess_h06", + "time": 211.6, + "ticks": 6348, "reason": "cleared", - "killed": 79, - "total": 114, + "killed": 82, + "total": 150, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 551, + "rngDraws": 89, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":79,"total":114,"perfect":false,"leaks":35,"leaksUncounted":0,"coins":0,"dmg":667046,"bossDmg":0,"heal":56212,"deaths":22,"layerGains":{}}}, + "players": {"p1":{"killed":82,"total":150,"perfect":false,"leaks":68,"leaksUncounted":0,"coins":0,"dmg":1367204,"bossDmg":0,"heal":0,"deaths":13,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,95,6,46896,3730,0,55,2], - ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,92,16,112690,4748,0,57,2], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,146,6,25757,8380,0,71,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",876.42,1,141,11,49351,7812,0,116,0], - ["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,4,5,50224,0,0,126,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,5,10,72378,0,0,143,3], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,11,13,93765,8448,0,61,2], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,13,11,193691,4022,0,80,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,6,0,0,8270,28921,60,3], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,14,0,0,33436,27292,59,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,129,0,2407,3480,0,8,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,90,1,19885,5965,0,35,2], + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_ifrit",1544,1,4,29,535201,0,0,93,4], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_ifrit",1544,1,5,14,235845,0,0,72,39], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_ifrit",1544,1,171,1,98866,1544,0,17,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_ifrit",1744,1,13,0,87409,0,0,74,4], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_ifrit",1744,1,6,18,156957,0,0,66,34], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_ifrit",0,0,14,0,63597,1744,0,20,3], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,121,0,2158,2522,0,3,0], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,154,8,58152,2522,0,10,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,100,2,21405,2522,0,10,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,95,2,14759,3052,0,9,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,90,3,27322,3052,0,3,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,8,5,65534,1526,0,20,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,39,12,64550,167399],"enemy_1299_ymkilr_2":[18,7,11,21876,152985],"enemy_1422_lrsldr_2":[45,33,12,1866,346662]}, - "hooks": [1,146,6354,1427,1427,1690,1753,214,101,136,22,31,0,25801,317,77,114,35,1,101,0,0,0], - "events": {"atk":1533,"deploy":29,"die":101,"dmg":1744,"fx":27,"heal":167,"leak":35,"skill":41,"spawn":129,"status":132}, - "fx": {"aoe":15,"displace":5,"dodge":4,"spGain":3}, - "snaps": "13f5f790 ae100a1a ac170e75 549900c8 7eb0a835 6f629aae 12139fb3 6a9fdaa0 cb8b3c58 b1ccd449 1dead90f 6213ba54 943b6234 debe5fb6 2c8e086b 523ea4e6 24a3d7e7 9aaa0b94 f1c67fcf b71b3e8a a988a786 4d3be5fd" + "enemies": {"enemy_1249_lysdb_2":[60,34,26,5486,596279],"enemy_1251_lysyta":[51,35,16,909,240893],"enemy_1329_cbshld":[39,13,26,8801,530032]}, + "hooks": [1,171,6348,433,431,1867,2367,0,95,163,92,92,18,29289,22,25,150,68,1,95,0,0,0], + "events": {"atk":1247,"deploy":18,"die":95,"dmg":2367,"fx":236,"leak":68,"skill":184,"spawn":165,"status":44}, + "fx": {"aoe":73,"beam":3,"flame":100,"shieldBreak":60}, + "snaps": "43307da7 4ff2063f 2a153be0 3aba4c54 30fdf3ac 1f9215af cf66cfaa 8012d31d 985f38b3 a23ece2e 38d610e7 33eab3e4 0832bad4 fdd45552 4c989016 b99015e8 e5e90c65 02b8067a c5784c78 5f21d2f4 59cec77b fe00505d" }, "diy-046": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc", + "about": "5_diy1_b=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 6_diy2_b=aphris/S1 6_diy1_b=aphris/S2 6_diy2_b=aphris/S3 6_diy1_b=aphris/S1/002_aphris 6_diy2_b=aphris/S2/002_aphris 6_diy1_b=aphris/S3/002_aphris", "fast": false, "kind": "normal", "stageId": "act1autochess_m02", @@ -1859,71 +1859,284 @@ "round": 13, "seed": 3081100192, "waveId": "act1autochess_h06", - "time": 216.3, - "ticks": 6489, + "time": 215.8, + "ticks": 6474, "reason": "cleared", - "killed": 4, + "killed": 1, "total": 171, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 77, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":4,"total":171,"perfect":false,"leaks":167,"leaksUncounted":0,"coins":0,"dmg":120617,"bossDmg":0,"heal":20221,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":1,"total":171,"perfect":false,"leaks":170,"leaksUncounted":0,"coins":0,"dmg":50292,"bossDmg":0,"heal":0,"deaths":19,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,3,0,1544,2604,0,8,0], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,1,0,1214,3431,0,3,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,133.31,1,195,1,22421,4454,0,16,2], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1904,1,196,2,37478,4938,0,19,2], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,10,0,10366,6707,0,10,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,11,1,22693,11669,0,13,1], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,190,0,2299,4595,0,6,0], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,180,0,4791,5469,0,11,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,116,0,17234,2744,0,6,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,111,0,578,6634,0,1,0], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,172,0,0,5772,2100,5,0], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,160,0,0,3397,18121,12,1] + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_aphris",0,0,189,0,2178,4938,0,3,0], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_aphris",1646,1,191,0,3484,3292,0,1,1], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_aphris",0,0,172,0,1307,3292,0,2,1], + ["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,101,0,1091,2522,0,2,0], + ["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,1,0,6347,1261,0,4,1], + ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,3,0,5889,1261,0,6,0], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,6,0,1487,1526,0,2,0], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,9,0,19328,1526,0,1,1], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,97,0,3221,3052,0,4,0], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_aphris",0,0,92,0,806,3452,0,1,0], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_aphris",0,0,2,1,4708,1726,0,2,1], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_aphris",0,0,10,0,448,1726,0,1,0] ], - "enemies": {"enemy_1009_lurker":[66,2,64,3709,43347],"enemy_1019_jshoot_2":[57,2,55,27981,61793],"enemy_1404_msnip":[48,0,48,30723,15477]}, - "hooks": [1,196,6489,256,256,357,333,29,27,194,9,9,0,11531,30,30,171,167,1,34,0,0,24], - "events": {"atk":264,"deploy":25,"die":27,"dmg":333,"fx":62,"heal":29,"leak":167,"skill":18,"spawn":183,"status":48}, - "fx": {"aoe":9,"dodge":24,"dp":7,"slash":18,"spGain":4}, - "snaps": "f6bb9115 32785fce 7671d335 6ad496ca 51e64b0d b0287abb c3e8e27b 23bf00a1 17a804a0 22dde806 36b68342 0932e99f d42af37c ec3a5482 1815af63 40c22b65 f5cfdd7c 207a1f75 e0042953 a56964f2 27c42850 e92e1f19" + "enemies": {"enemy_1009_lurker":[66,1,65,4068,23824],"enemy_1019_jshoot_2":[57,0,57,19510,13600],"enemy_1404_msnip":[48,0,48,5997,12869]}, + "hooks": [1,191,6474,95,94,146,146,0,20,190,5,5,0,5466,7,9,171,170,1,20,0,0,0], + "events": {"atk":109,"deploy":20,"die":20,"dmg":146,"fx":4,"leak":170,"skill":10,"spawn":183,"status":14}, + "fx": {"beam":4}, + "snaps": "08c9fbae 67a87bb0 a3072327 eccaf1f9 f8cc45b2 3a1ad7d5 e64c4b62 ab4241de 246cedcc 6587f984 c0ab6846 be85b076 9cb57eb2 08c0757d adab1273 206fd5e6 00edb24d ffef6b41 6f2fa874 cff77e70 c9148e99 27dc3559" }, "diy-047": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "5_diy1_a=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 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", "fast": false, "kind": "normal", "stageId": "act1autochess_m03", "modeId": "mode_multi_funny", - "round": 13, + "round": 12, "seed": 3707706975, - "waveId": "act1autochess_h06", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 27, - "total": 129, + "waveId": "act1autochess_h05", + "time": 199.73, + "ticks": 5992, + "reason": "cleared", + "killed": 126, + "total": 135, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 278, + "rngDraws": 81, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":27,"total":129,"perfect":false,"leaks":102,"leaksUncounted":0,"coins":0,"dmg":245743,"bossDmg":0,"heal":0,"deaths":16,"layerGains":{}}}, + "players": {"p1":{"killed":126,"total":135,"perfect":false,"leaks":9,"leaksUncounted":0,"coins":0,"dmg":528132,"bossDmg":0,"heal":0,"deaths":7,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,1865,1,152,3,10159,3730,0,51,1], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,111,13,115470,4988,0,75,2], - ["p1",3,"op","chess_char_6_diy1_a",2,null,30,1,151,6,25325,8048,0,120,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,150,5,29008,8904,0,131,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,6,0,24610,0,0,77,2], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,9,0,41171,0,0,110,2], + ["p1",1,"op","chess_char_5_diy1_a",0,null,879,1,6,1,8329,800,0,26,1], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1679,1,142,2,10460,1679,0,29,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,14,29,116125,1679,0,23,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",924,1,4,8,14192,1000,0,28,1], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1124,1,5,4,12651,800,0,41,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1124,1,7,10,28433,800,0,25,3], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_lin",0,0,12,25,113277,1924,0,51,1], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_lin",1924,1,140,26,106667,1924,0,59,3], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_lin",0,0,8,1,10531,1924,0,14,3], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_lin",0,0,10,5,33307,1924,0,43,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_lin",1924,1,152,7,40970,1924,0,48,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_lin",624,1,9,8,33190,1300,0,24,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_10124_uashld_2":[18,0,18,6585,52204],"enemy_1043_zomsbr":[66,19,47,384,72934],"enemy_1061_zomshd":[45,8,36,18701,120605]}, - "hooks": [1,152,6900,733,733,951,1065,14528,43,144,7,19,0,11946,5594,47,129,101,1,43,0,0,0], - "events": {"atk":802,"deploy":20,"die":43,"dmg":1008,"fx":22,"leak":101,"skill":14,"spawn":138,"status":695}, - "fx": {"displace":18,"dodge":4}, - "snaps": "e0e91a81 0a9ea352 26f09f4f 99eb0a4b 8b68119f 5c2640c9 3c4cd73f 0225f4b1 58a50a40 b28249ff 4c4af077 845ea84d 52b7882c 796342c8 e75aa812 ae38af25 adcc2100 66fe32f4 5cf3bc1f d66728f6 5b750bf1 c9f87d9a 96e966b3" + "enemies": {"enemy_1148_dssbr":[63,63,0,0,169344],"enemy_1148_dssbr_2":[60,60,0,111,207360],"enemy_1439_dslntf":[12,3,9,17567,151428]}, + "hooks": [1,153,5992,470,470,918,2429,0,133,142,27,21,0,25469,300,15,135,9,1,133,0,22,0], + "events": {"atk":843,"deploy":15,"die":133,"dmg":2411,"fx":161,"leak":9,"skill":48,"spawn":150,"status":575}, + "fx": {"aoe":43,"burst":22,"shield":96}, + "snaps": "64e9afd8 f7e3e19c 35675c6f 6529822b 79c0e605 6f9bb98a 7f7c9d88 bb8293ae 6fa5be86 2444d933 98c0b198 c38bac2f ab6665c7 3b336f16 31d08ef6 c2ac0db3 851538ad dff947cd e1b9cd7c 11a92dbf" + }, + "diy-048": { + "about": "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 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", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m04", + "modeId": "mode_single_hard", + "round": 13, + "seed": 2066918090, + "waveId": "act1autochess_h06", + "time": 219.2, + "ticks": 6576, + "reason": "cleared", + "killed": 45, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 618, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":45,"total":123,"perfect":false,"leaks":78,"leaksUncounted":0,"coins":0,"dmg":451878,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,130,2,6898,3358,0,11,2], + ["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,142,2,26976,3358,0,18,3], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,92,4,57986,3358,0,11,2], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",1924,1,147,1,6030,1924,0,7,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,8,2,36118,1924,0,24,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,116,3,39350,3848,0,15,2], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_lin",0,0,12,1,22790,1924,0,9,1], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_lin",0,0,14,15,143345,1924,0,79,4], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_lin",0,0,85,0,0,3848,0,0,0], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_lin",0,0,82,1,7649,3848,0,4,2], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_lin",0,0,80,8,57836,3848,0,23,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_lin",0,0,76,6,46900,3848,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":[57,19,38,15913,131511],"enemy_1299_ymkilr_2":[27,4,23,18724,121648],"enemy_1422_lrsldr_2":[39,22,17,836,285939]}, + "hooks": [1,147,6576,726,726,2486,2486,0,65,143,22,22,0,10635,102,34,123,78,1,65,0,0,0], + "events": {"atk":1089,"deploy":21,"die":65,"dmg":2027,"fx":87,"leak":78,"skill":44,"spawn":138,"status":182}, + "fx": {"aoe":29,"shield":58}, + "snaps": "8eac4d32 fa5c2376 bc1e9184 84fb1db6 b4b3b807 cc6f6399 c665e4ff 261094cd 5d5c61fd 1bf27396 b526ffa6 e433a3a2 7d8bd521 d7eb687d 4d18ecc9 f640c5fd 0912a7d6 cc22df71 1a833e56 d877d28b 8a887ef8 8014c7dd" + }, + "diy-049": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": true, + "kind": "normal", + "stageId": "act1autochess_m05", + "modeId": "mode_multi_normal", + "round": 12, + "seed": 2215018795, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 2, + "total": 60, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":2,"total":60,"perfect":false,"leaks":58,"leaksUncounted":0,"coins":0,"dmg":267738,"bossDmg":0,"heal":59968,"deaths":6,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,76,0,304,6384,0,22,1], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,75,0,652,8724,0,41,2], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,6,0,21282,21790,0,137,3], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",3334,1,8,2,22607,38177,0,115,4], + ["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,10,0,2272,0,0,99,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,11,0,2916,0,0,108,3], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,1,0,0,0,0,0,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,3,0,97722,0,0,178,4], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,5,0,0,0,29529,69,4], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,7,0,0,0,30439,60,4], + ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,9,0,56880,0,0,144,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,12,0,63104,0,0,136,3] + ], + "enemies": {"enemy_1045_hammer_2":[12,0,11,37471,77249],"enemy_1061_zomshd":[48,2,45,37605,269489]}, + "hooks": [1,76,6900,1205,1205,3051,3051,29902,8,64,33,29,0,37113,285,23,60,56,1,8,0,0,0], + "events": {"atk":1205,"deploy":16,"die":8,"dmg":3051,"heal":129,"leak":56,"skill":62,"spawn":72,"status":555}, + "fx": {}, + "snaps": "c6bdc18a 6fb12fe1 c3c94d6f f42e9bba 6ea4c93a ff3506ac f551dbb2 248e7a29 e877982d 0b8ac60e d21ffc26 89eda7b5 7ef13ac0 2d2fce6f 32484e38 3407d0b5 1b59f452 55141007 fc87be97 6239dcab e11e96f9 250fe5b6 a75b766c" + }, + "diy-050": { + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m06", + "modeId": "mode_multi_hard", + "round": 12, + "seed": 574294847, + "waveId": "act1autochess_h05", + "time": 214.8, + "ticks": 6444, + "reason": "cleared", + "killed": 0, + "total": 60, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 115, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":157435,"bossDmg":0,"heal":12039,"deaths":23,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,82,0,28,5208,0,2,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3371,1,6,0,0,0,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,9,0,20856,0,0,0,7], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,72,0,174,5712,0,6,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,67,0,13430,5276,0,29,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,78,0,61051,12366,0,48,2], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,85,0,698,3816,0,13,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,84,0,2755,4761,0,39,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,52,0,19051,2744,0,7,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,51,0,39393,3498,0,7,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,47,0,0,2448,1260,3,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,54,0,0,9324,10779,8,1] + ], + "enemies": {"enemy_1045_hammer_2":[12,0,12,15694,22503],"enemy_1061_zomshd":[48,0,48,39458,134932]}, + "hooks": [1,85,6444,245,245,412,396,10112,23,83,15,15,0,16629,0,28,60,60,1,26,0,0,16], + "events": {"atk":263,"deploy":25,"die":23,"dmg":396,"fx":86,"heal":23,"leak":60,"skill":30,"spawn":72,"status":14}, + "fx": {"aoe":5,"dodge":16,"dp":7,"slash":56,"spGain":2}, + "snaps": "580b238a aa91295b 3af047b3 a833e612 f6a0b595 1eba1563 0c047aab 7838a66c 34fc5adc 627a4814 4d7c5d84 41a4b79a 721f8563 f6d5a347 76cda6c2 74e73fdb 220b1dd6 2f7b90e4 d3a2ce26 6a84c6a3 1aae6574 0e8ef572" + }, + "diy-051": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m07", + "modeId": "mode_single_normal", + "round": 13, + "seed": 3864737019, + "waveId": "act1autochess_h06", + "time": 204.8, + "ticks": 6144, + "reason": "cleared", + "killed": 197, + "total": 290, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 282, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":197,"total":290,"perfect":false,"leaks":93,"leaksUncounted":0,"coins":0,"dmg":444885,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,4,7,21881,2604,0,65,1], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,7,11,35637,3431,0,87,2], + ["p1",3,"op","chess_char_5_diy1_a",0,null,1560.61,1,279,11,28493,2169,0,91,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",1890.36,1,277,51,98505,2858,0,110,3], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,241,13,39114,3058,0,54,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,163,43,73993,3808,0,55,4], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,285,5,26351,8078,0,87,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,3,7,19,0,0,16,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,5,10,60,0,0,60,3], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",2339.65,1,8,13,64107,1191,0,82,3], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,6,10,18397,0,0,78,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,9,16,38328,0,0,93,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": { + "enemy_1195_sfyin": [39,30,9,4143,113683], + "enemy_1196_msfyin": [60,32,28,0,65], + "enemy_1197_sfshu": [48,38,10,10130,184299], + "enemy_1198_msfshu": [114,78,36,0,243], + "enemy_1199_sfjin": [18,11,7,12925,146349], + "enemy_1200_msfjin": [11,8,3,0,246] + }, + "hooks": [1,314,6144,1470,1470,1969,2082,0,212,305,25,30,0,33234,6218,91,290,93,1,212,0,0,11], + "events": {"atk":1512,"deploy":22,"die":212,"dmg":2076,"fx":273,"leak":93,"skill":48,"spawn":304,"status":488}, + "fx": {"aoe":56,"displace":1,"dodge":22,"dp":63,"slash":52,"summon":79}, + "snaps": "cf051c06 587dcba3 94ed6adb bba4fd81 815661ad cbf17744 a8d0c19b 32b88916 1e8bde83 51a1d669 b023b185 df725cf4 bf4681aa 2c372e04 1a001766 3d81d3d6 7f8effe5 11826d80 b4fb05d0 0eed6d6c 65f6503d" + }, + "diy-052": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m01", + "modeId": "mode_multi_abyss", + "round": 13, + "seed": 1210502478, + "waveId": "act1autochess_h06", + "time": 218.57, + "ticks": 6557, + "reason": "cleared", + "killed": 0, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 81, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":76446,"bossDmg":0,"heal":2762,"deaths":33,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,6,0,2101,1865,0,8,0], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,9,0,4660,2494,0,8,0], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,157,0,13058,11540,0,21,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,156,0,24836,14840,0,17,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,151,0,3559,2544,0,18,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,3,0,11692,1643,0,23,0], + ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,4,0,9700,1372,0,5,1], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,11,0,1022,1819,0,1,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,86,0,0,4120,2092,4,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,74,0,0,4018,670,2,0], + ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,1,0,1487,1160,0,3,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,5,0,4330,1450,0,7,1] + ], + "enemies": {"enemy_1019_jshoot_2":[57,0,57,29550,4113],"enemy_1299_ymkilr_2":[27,0,27,14576,26756],"enemy_1422_lrsldr_2":[39,0,39,4739,45577]}, + "hooks": [1,157,6557,277,277,283,291,7,33,156,4,24,0,6272,192,29,123,123,1,33,0,0,0], + "events": {"atk":278,"deploy":34,"die":33,"dmg":269,"fx":19,"heal":7,"leak":123,"skill":8,"spawn":135,"status":92}, + "fx": {"aoe":2,"displace":15,"spGain":2}, + "snaps": "21be44ba 23e43266 29788d55 2f1037e9 363ad836 051606c5 26acdfeb 8dc54607 7a7c6a86 7ebe59b3 d1661fdb 203d434c 2e82423b f7e9ffc1 7a1afa0e 8998df31 0ca38cb6 f3ac5677 87bcc7cf 0f5f3879 0e082cac 62241cef" } } }