feedback5: merge O1 — 自选 kits for 伊芙利特, 谬因 (with her 中继器) and 林; golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 00:22:52 +08:00
commit fef4ea3d4b
8 files changed
+1936 -120

No files matched your search

+3
View File
@@ -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',
]);
/**
+294
View File
@@ -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 });
},
};
},
};
+215
View File
@@ -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 });
}
},
};
},
};
+228
View File
@@ -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 });
}
},
};
},
};
+277
View File
@@ -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);
});
+294
View File
@@ -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);
}
});
+292
View File
@@ -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);
});
+333 -120
View File
@@ -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"
}
}
}