feedback5: merge O12 — 自选 kits for W (with her 此面向敌), 菲亚梅塔 and 维什戴尔 (with her 魂灵之影); golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 01:46:16 +08:00
commit 9efe0110be
9 files changed
+2506 -126

No files matched your search

+1 -1
View File
@@ -1183,7 +1183,7 @@ header of professions.js).
| fastshot | FLY first; module `atk_scale` vs FLY |
| closerange, underminer, primcaster, corecaster, ritualist, summoner, counsellor, pioneer, fearless, fighter, protector, guardian, primprotector, executor, duelist | plain profile (numbers from data; skills/talents via kits). underminer module: weaken 10 % ATK 2 s on hit |
| longrange | lowest DEF first |
| aoesniper / splashcaster | splash 1.1 tiles around the struck target at full damage (PRTS 溅射半径一览: 扩散术师 1.1; Arknights Terra Wiki, Splash Caster); 格雷伊 1.0 (the same table's 特殊 row; TUNE.splashcaster, 1.1 until 0.1.1). The table's 炮手 1.0 has no chess in the pool |
| aoesniper / splashcaster | splash 1.1 tiles around the struck target at full damage (PRTS 溅射半径一览: 扩散术师 1.1; Arknights Terra Wiki, Splash Caster); 格雷伊 1.0 (the same table's 特殊 row; TUNE.splashcaster, 1.1 until 0.1.1). The table's 炮手 1.0 has no chess in the pool; the 自选 炮手 kits (W `op-cqbw.js`, 菲亚梅塔 `op-phenxi.js`) set it in their kit trait |
| blastcaster | `rangeAoe`: every selectable enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (primary: PRTS 作战机制 §AOE伤害判定 names 伊芙利特's 炎爆 a 锁定攻击范围 AoE, and 炎爆 is her next-attack skill "下次攻击造成…" (PRTS 伊芙利特 S2), so a 轰击术师 normal attack; secondary: Terra Wiki, Blast Caster; supporting: PRTS 溅射半径一览 documents no splash radius for it; community report E3). A stealthed enemy is not struck unless it is revealed or blocked (a 锁定范围 AoE cannot hit a 隐匿 unit, PRTS 作战机制) |
| bombarder | ground-only splash 0.9 (PRTS 溅射半径一览: 投掷手 0.9; 1.0 until 0.1.1) + aftershock(s) at 50 % ATK (bb append_atk_scale / times); 迷迭香's S2 末梢阻断 1.5 (the same table) |
| hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty |
+3
View File
@@ -112,6 +112,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-pallas.js',
'op-lessng.js',
'op-demetr.js',
'op-cqbw.js',
'op-phenxi.js',
'op-wisdel.js',
]);
/**
+354
View File
@@ -0,0 +1,354 @@
// server/sim/content/kits/ops/op-cqbw.js — W (char_113_cqbw) 自选 operator kit: 6★ 炮手 (狙击), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (ART-X “佣兵的行囊”, ART-Y “刺棱钝刃”) at
// every form, and the kit of her summon “此面向敌” (token_10008_cqbw_box). Kit contract and the 自选 rules: ../README.md
// ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_113_cqbw, 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) — the owner's decision of 2026-10-05.
// Potential 0 [ASSUMED: no account]. Sources: character_table / skill_table / battle_equip_table and the token record
// (zh_CN, as built into backups.json); PRTS W, 此面向敌 and 溅射半径一览 (the 备注 quoted below); the client's skill prefabs
// ([uc]skills skchr_cqbw_1 / 2 / 3, sktok_cqbw_token) and buff templates (cqbw_t_1, cqbw_t_2, cqbw_e_dmg,
// cqbw_e_sp[kill_with_projectile] / [kill_with_token], cqbw_e_003_t).
// - Trait (炮手) "攻击造成群体物理伤害": ranged physical, every enemy within SPLASH of the struck one takes the hit in full
// (the aoesniper profile), air units too (data canHitFly); her 3-10, blocks 1, ground enemies target her (no flag).
// PRTS 溅射半径一览 gives the 炮手 1.0 — the profile keeps the 扩散术师's 1.1 — so the kit's trait sets 1.0.
// - Module ART-X “佣兵的行囊” "攻击被阻挡的敌人时攻击力提升至110%" (trait bb atk_scale; cqbw_e_dmg: AtkScaleUp on every damage
// she calculates, the cached D12 bombs included, when the target is blocked): ×atk_scale on her damage to a blocked
// enemy — D12 only while she is on the field (PRTS "撤退W…炸弹无法享受W的天赋、模组的增益"); the module's token part gives
// 此面向敌 the same (its trait atk_scale, below). Stage 2+ changes 落井下石 (below).
// - Module ART-Y “刺棱钝刃” "攻击时无视敌人100点的防御力" (def_penetrate_fixed): defIgnoreFlat on her damage (D12 is 无来源, so
// no source penetration reaches it — the engine's sourceless rule, PRTS 伤害分类). Stage 2+ changes 设伏 (below).
// - T1 设伏 "在战场停留10秒后获得60%的物理和法术闪避,且不容易成为敌人的攻击目标" (cqbw_t_1: the evade buff with taunt level −1,
// lifetime ∞): `interval` s after each deployment, dodgePhys / dodgeArts `prob` and taunt taunt_level until she leaves.
// ART-Y stage 3: 8 s, plus the hidden talent 2 (cqbw_e_003_t; PRTS 备注 "部署后每秒获得一层永久的攻击力提升效果(…3级时每层
// 1.25%攻击力,上限16层),受到伤害时…清空攻击力提升效果"): one stack of ATK +atk (直接乘算) every second from her deployment,
// at most max_stack_cnt, all cleared by any damage she takes (a 流失, an element 损伤 or a dodged hit is none; a hit a
// shield absorbs is one [ASSUMED]).
// - T2 落井下石 "攻击范围内的敌人在被晕眩时受到的物理伤害+18%" (cqbw_t_2 on the enemies of her range: ON_TAKE_DAMAGE, STUNNED,
// DamageScale PHYSICAL): any physical damage, whoever deals it, on an enemy standing in her attack range while it is
// 晕眩 (the catalogue stun — not 冻结 / 浮空) ×damage_scale; two W keep the strongest (同名 buff). ART-X stage 2+ (+24 % at
// stage 3) adds "击倒敌人时获得1点技力" — PRTS 修正 "被自身的远程攻击击倒时" and 备注 "当受天赋影响的单位被W的弹道(包括
// D12的炸弹)击倒,W将恢复1点技力(无视阻回)…当此面向敌击倒敌人时,不论该敌人是否受天赋效果影响,也会令W获得1点技力(无视
// 阻回)": +sp SP (forced) when her projectile — a normal attack, 红桃K, a D12 bomb — knocks out an enemy of her range,
// or one of her mines knocks out any enemy.
// - S1 红桃K (MANUAL, data DEFAULT): "立即发射一枚榴弹…并使命中目标晕眩" — cast like an attack (prefab
// `_shouldCastLikeAttack`): the attack it is cast for throws the grenade at her target instead (an instant skill with
// an attack override): atk_scale × ATK physical to every enemy within GRENADE_RADIUS (PRTS 备注 "榴弹爆炸范围半径为1.2"),
// air units too (selector `_targetMotion` 3), each stunned `stun` s after its hit.
// - S2 惊吓盒子 (AUTO, data DEFAULT — "下次攻击变为…埋下地雷"): the attack it is cast for plants a 此面向敌 on a free deployable
// tile of her range instead (melee or ranged: 此面向敌 "部署位置=全部位"). PRTS 备注: "攻击范围内不存在可部署位时,技能不会
// 发动" (the cast is refused and keeps its SP — prefab `_cancelIfSearchTargetFailed`), "召唤物部署优先级:可选敌人中点所在
// 地块(按地块上所有可选敌人中最高仇恨值降序)>距离其它可选敌人最近地块>其它地块(随机排序)" — targetable ground enemies
// (the selector's `_targetMotion` 1), 仇恨值 read as her target order [ASSUMED], the nearest tile measured from its centre
// to any such enemy of the field [ASSUMED], ties and the rest drawn with battle.rng — and "W撤退时撤退全部此面向敌" (her
// knock-out too [ASSUMED]). No cap on the mines [ASSUMED: the token's maxDeployCount 1 is its deck limit; no source
// caps what the skill lays].
// 此面向敌 (mineKit): untargetable [ASSUMED: a trap no enemy selects, as 香槟炸弹 — PRTS 实体类型 装置], blocks nothing, no
// attack, lasts its talent `duration` (120 s). PRTS 此面向敌 备注 "受害者启动模式的触发及爆炸的半径均为1.35,触发后延迟1.5s
// 爆炸,不可对空", W 备注 "触发瞬间实时读取W的攻击力", 技能 备注 "该技能伤害会借用持有者的攻击力计算": a targetable ground
// enemy within 1.35 arms it (W's ATK read then); 1.5 s later every targetable ground enemy within 1.35 takes atk_scale ×
// that ATK physical (×its trait atk_scale on a blocked one: ART-X) and is stunned `stun` s; the mine is used up
// (sktok_cqbw_token's suicide buff). The token skill's blackboard is the S2 blackboard of the form.
// - S3 D12 (MANUAL, data DEFAULT): cast like an attack — the attack it is cast for throws instead one bomb (a shell at
// BOMB_SPEED [ASSUMED]) at each of the max_target enemies of her range with the most HP (current HP; air units too:
// selector `_targetMotion` 3). PRTS 备注 "炸弹使用技能开启瞬间W的缓存攻击力,爆炸半径为1.2,延迟3s引爆,造成无来源的物理普通
// 伤害…被安装炸弹的目标被击倒/离场,或进入重生/消失状态时,炸弹会立刻提前引爆": BOMB_DELAY s after it lands — at once when
// its target falls, leaves, hides or turns 无敌 + 无法选中 (重生), or when it had fallen before the bomb landed
// [ASSUMED] — every targetable enemy within 1.2 of the bomb takes atk_scale × the cached ATK physical, 无来源 (credited to
// her), and is stunned `stun` s.
import { num, traitBb, skillRec, up, statBuff } from '../shared/tier1.js';
import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { hasHp, isHpLoss } from '../../../damage.js';
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
const S1 = 'skchr_cqbw_1';
const S2 = 'skchr_cqbw_2';
const S3 = 'skchr_cqbw_3';
/** Her summon “此面向敌” (S2 惊吓盒子). */
export const MINE = 'token_10008_cqbw_box';
/** PRTS 溅射半径一览: 炮手 1.0. */
const SPLASH = 1.0;
/** PRTS S1 备注 "榴弹爆炸范围半径为1.2". */
const GRENADE_RADIUS = 1.2;
/** PRTS 此面向敌 备注 "触发及爆炸的半径均为1.35,触发后延迟1.5s爆炸". */
const MINE_RADIUS = 1.35;
const MINE_DELAY = 1.5;
/** 此面向敌's lifetime when its token record carries none (token talent `duration`: 120 s). */
const MINE_LIFE = 120;
/** PRTS S3 备注 "爆炸半径为1.2,延迟3s引爆". */
const BOMB_RADIUS = 1.2;
const BOMB_DELAY = 3;
/** D12's bombs fly as shells [ASSUMED: the engine's bomb projectile speed]. */
const BOMB_SPEED = PROJECTILE_SPEEDS.bomb;
/** 设伏 ART-Y: one ATK stack every second from the deployment (PRTS 备注 "部署后每秒获得一层"). */
const RAMP_IV = 1;
const AMBUSH_KEY = 'talent:cqbw:ambush';
const RAMP_KEY = 'talent:cqbw:ramp';
const MINE_TAG = 'cqbw:mine';
const D12_TAG = 'cqbw:d12';
const GROUND = Object.freeze({ canHitFly: false });
const AIR = Object.freeze({ canHitFly: true });
/** 落井下石's factor already applied to a damage instance (two W on one field keep the strongest). */
const STUN_SCALED = new WeakMap();
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** Talent of data index `i` of the composed record (ART-Y's hidden talent 2 has no name). */
const talentAt = (chess, i) => (chess?.talents ?? []).find((t) => t && t.index === i) ?? null;
/** First talent of a normalised token def holding `key` (token talents keep no index). */
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
/** A mine of `unit`. */
const isMineOf = (t, unit) => !!t && t.kind === 'token' && t.defId === MINE && t.ownerUnit === unit;
/** 晕眩 (cqbw_t_2 checks STUNNED: the catalogue stun, not 冻结 / 浮空). */
const stunned = (e) => e.hasBuff('stun');
/** D12's early burst: the target fell, left, hides (消失) or is 无敌 + 无法选中 (重生). */
const gone = (e) => !e || !e.alive || e.hidden || !!(e.s.flags.invulnerable && e.s.flags.untargetable);
/** Free deployable tiles of her current range (melee or ranged: 此面向敌 "部署位置=全部位"). */
function mineTiles(battle, unit) {
const out = [];
for (const k of unit.rangeKeys ?? []) {
const r = Math.floor(k / COLS), c = k % COLS;
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
out.push([r, c]);
}
return out;
}
/** The tile 惊吓盒子 plants on (PRTS's priority, header), or null when her range has none free. */
function mineTile(battle, unit) {
const tiles = mineTiles(battle, unit);
if (!tiles.length) return null;
const foes = battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND));
if (!foes.length) return battle.rng.pick(tiles);
// 可选敌人中点所在地块, by the enemies' order
sortEnemyTargets(battle, unit, foes, null);
const byKey = new Map(tiles.map((t) => [t[0] * COLS + t[1], t]));
for (const e of foes) {
const t = byKey.get(Math.round(e.y) * COLS + Math.round(e.x));
if (t) return t;
}
// 距离其它可选敌人最近地块
let best = [], bd = Infinity;
for (const t of tiles) {
let d = Infinity;
for (const e of foes) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0]));
if (d < bd - 1e-9) { bd = d; best = [t]; } else if (Math.abs(d - bd) <= 1e-9) best.push(t);
}
return best.length === 1 ? best[0] : battle.rng.pick(best);
}
/**
* 此面向敌's kit (header). `owner` = W, `def` = the token def of her pick (the S2 blackboard of the form; ART-X: the
* token trait atk_scale).
*/
function mineKit(owner, def) {
const sk = def?.skill?.bb ?? {};
const scale = num(sk.atk_scale), stun = num(sk.stun);
const blocked = num(def?.traitBb?.atk_scale, 1);
return {
skill: {
kind: 'passive',
onTick({ battle, unit }) {
if (!unit.alive || unit.mem.armed) return;
if (!battle.foesInRadius(unit.x, unit.y, MINE_RADIUS, true).some((e) => !e.isFlying)) return;
unit.mem.armed = true;
const atk = up(owner) ? owner.s.atk : 0; // 触发瞬间实时读取W的攻击力
battle.fx('telegraph', { x: unit.x, y: unit.y, id: unit.id, r: MINE_RADIUS, dur: MINE_DELAY });
battle.after(MINE_DELAY, () => {
if (!unit.alive) return; // withdrawn meanwhile (W left the field, its time ran out)
for (const e of battle.foesInRadius(unit.x, unit.y, MINE_RADIUS, true)) {
if (e.isFlying) continue; // 不可对空
const mul = blocked !== 1 && e.blockedBy ? blocked : 1;
battle.dealDamage(unit, e, { amount: atk * scale * mul, type: 'phys', isSkill: true, tags: ['summon', MINE_TAG] });
if (stun > 0 && hasHp(e)) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
}
battle.fx('aoe', { x: unit.x, y: unit.y, radius: MINE_RADIUS, id: unit.id, skill: MINE_TAG, consumed: true });
battle.retreat(unit, { reason: 'expired', permanent: true });
}, { owner: unit });
},
},
trait: { noAttack: true },
};
}
/**
* D12: a bomb thrown at `target` (its cached ATK × scale, `mul` = ART-X's blocked factor while she stays on the field).
* It sticks to the target and goes off BOMB_DELAY s after it landed, or at once when the target is gone.
*/
function throwBomb(battle, unit, target, cached, b3, blocked) {
const scale = num(b3.atk_scale), stun = num(b3.stun);
const burst = (x, y) => {
const mul = up(unit) ? blocked : 1; // a withdrawn W lends her bombs no module
battle.fx('aoe', { x, y, radius: BOMB_RADIUS, id: unit.id, skill: D12_TAG });
for (const v of battle.foesInRadius(x, y, BOMB_RADIUS, true)) {
const m = mul !== 1 && v.blockedBy ? mul : 1;
battle.dealDamage(unit, v, { amount: cached * scale * m, type: 'phys', sourceless: true, isSkill: true, tags: ['skill', D12_TAG] });
if (stun > 0 && hasHp(v)) battle.applyStatus(v, 'stun', { duration: stun, source: unit });
}
};
battle.addProjectile({
from: unit, target, speed: BOMB_SPEED, visual: 'bomb', source: unit, hitDead: true,
onHit: (c) => {
const e = c.target;
if (gone(e)) { burst(c.x, c.y); return; } // its target fell before it landed: it goes off there [ASSUMED]
const spot = { x: e.x, y: e.y };
let done = false, watch = null, timer = null;
const fire = () => {
if (done) return;
done = true;
if (watch) battle.off(watch);
if (timer) timer.cancel();
burst(spot.x, spot.y);
};
watch = battle.on('tick', () => {
if (gone(e)) { fire(); return; }
spot.x = e.x; spot.y = e.y;
});
timer = battle.after(BOMB_DELAY, () => { if (!gone(e)) { spot.x = e.x; spot.y = e.y; } fire(); });
battle.fx('lock', { x: e.x, y: e.y, id: e.id, skill: D12_TAG, dur: BOMB_DELAY });
},
});
}
export default {
char_113_cqbw: (bb, chess) => {
const t0 = talentAt(chess, 0)?.bb ?? {}; // 设伏 (ART-Y stage 3: interval 8)
const ramp = talentAt(chess, 2)?.bb ?? {}; // ART-Y stage 3 hidden talent: atk / max_stack_cnt
const t1 = talentAt(chess, 1)?.bb ?? {}; // 落井下石 (ART-X stage 2+: damage_scale, sp)
const tb = traitBb(chess); // ART-X atk_scale · ART-Y def_penetrate_fixed
const blocked = num(tb.atk_scale, 1);
const b1 = bbOf(chess, S1), b3 = bbOf(chess, S3);
return {
trait: { splashRadius: SPLASH },
skills: {
[S1]: {
kind: 'instant',
attack: {
atkScale: num(b1.atk_scale, 1),
splashRadius: GRENADE_RADIUS,
onEachHit({ battle, unit, target }) {
const d = num(b1.stun);
if (d > 0 && target && target.side === 'enemy' && hasHp(target)) battle.applyStatus(target, 'stun', { duration: d, source: unit });
},
},
},
[S2]: {
kind: 'instant',
onStart({ battle, unit }) {
const tile = mineTile(battle, unit);
if (!tile) return; // (the activate guard below refuses such a cast)
unit.mem.cqbwCastAt = battle.time; // this attack plants the mine instead
const def = battle.tokenDef(MINE, unit);
const life = num(tokTalent(def, 'duration')?.bb?.duration, MINE_LIFE);
const m = battle.spawnToken(unit, MINE, tile[0], tile[1], { kit: mineKit(unit, def), untargetable: true, duration: life });
if (m) battle.fx('summon', { x: m.x, y: m.y, id: unit.id, token: MINE });
},
},
[S3]: {
kind: 'instant',
onStart({ battle, unit }) {
unit.mem.cqbwCastAt = battle.time; // this attack throws the bombs instead
const cached = unit.s.atk * unit.s.atkScaleMul; // 技能开启瞬间的缓存攻击力
const n = Math.max(1, Math.floor(num(b3.max_target, 3)));
const pool = battle.enemiesInKeys(unit.rangeKeys, unit, AIR);
for (const e of battle.blockedTargets(unit, AIR)) if (!pool.includes(e)) pool.push(e);
sortEnemyTargets(battle, unit, pool, null);
pool.sort((a, b) => b.hp - a.hp); // 生命值最多 (stable: her order breaks ties)
for (const e of pool.slice(0, n)) throwBomb(battle, unit, e, cached, b3, blocked);
},
},
},
talents: [
{ install(battle, unit) { // 设伏 (+ ART-Y stage 3: ATK ramp, reset by damage)
const iv = Math.max(0, num(t0.interval)), prob = num(t0.prob), taunt = num(t0.taunt_level);
const per = num(ramp.atk), cap = Math.floor(num(ramp.max_stack_cnt));
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit) return;
const seq = unit.deploySeq;
if (prob > 0 || taunt) {
battle.after(iv, () => {
if (!up(unit) || unit.deploySeq !== seq) return;
battle.addBuff(unit, { key: AMBUSH_KEY, mods: { dodgePhys: prob, dodgeArts: prob, taunt }, tags: ['talent'], visible: true });
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'cqbw:ambush' });
}, { owner: unit });
}
if (per > 0 && cap > 0) {
battle.every(RAMP_IV, (b, sched) => {
if (!up(unit) || unit.deploySeq !== seq) { sched.cancel(); return; }
battle.addBuff(unit, { key: RAMP_KEY, refresh: 'stack', maxStacks: cap, mods: { atkPct: per }, tags: ['talent'] });
}, { owner: unit });
}
}, { owner: unit });
if (per > 0 && cap > 0) {
battle.on('damaged', (ctx) => {
if (ctx.target !== unit || ctx.type === 'element' || isHpLoss(ctx.dmg)) return;
if (unit.findBuff(RAMP_KEY)) battle.removeBuff(unit, RAMP_KEY);
}, { owner: unit });
}
} },
{ install(battle, unit) { // 落井下石 (+ ART-X stage 2+: SP on her projectiles' / mines' knock-outs)
const scale = num(t1.damage_scale, 1), sp = num(t1.sp);
if (scale !== 1) {
battle.on('hit', (ctx) => {
const e = ctx.target, d = ctx.dmg;
if (!e || e.side !== 'enemy' || d.type !== 'phys' || !up(unit) || !stunned(e) || !bodyInKeys(e, unit.rangeKeySet)) return;
const prev = STUN_SCALED.get(d) ?? 1;
if (!(scale > prev)) return;
d.mul *= scale / prev;
STUN_SCALED.set(d, scale);
}, { owner: unit });
}
if (sp > 0) {
battle.on('damaged', (ctx) => {
const e = ctx.target, d = ctx.dmg;
if (!e || e.side !== 'enemy' || !d || d.type === 'element' || hasHp(e) || !up(unit) || !unit.skill) return;
const src = ctx.source;
const shot = (src === unit && d.isAttack) || ((ctx.credit ?? src) === unit && d.tags?.includes(D12_TAG));
if (isMineOf(src, unit) || (shot && bodyInKeys(e, unit.rangeKeySet))) unit.skill.gainSp(sp, 'init'); // 无视阻回
}, { owner: unit });
}
} },
],
install(battle, unit) {
const sid = unit.skill?.id;
// ART-X: 攻击被阻挡的敌人时攻击力提升至110% (her own damage; D12 and the mines apply it themselves)
if (blocked !== 1) {
battle.on('hit', (ctx) => {
const t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !t.blockedBy || ctx.dmg.type === 'element') return;
ctx.dmg.amount *= blocked;
}, { owner: unit });
}
// ART-Y: 攻击时无视敌人100点的防御力
statBuff(battle, unit, 'trait:cqbw:pierce', { defIgnoreFlat: num(tb.def_penetrate_fixed) });
// 惊吓盒子 / D12 are cast like an attack: the attack they are cast for makes no hit of its own
if (sid === S2 || sid === S3) {
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || unit.mem.cqbwCastAt !== battle.time) return;
unit.mem.cqbwCastAt = null;
ctx.targets = [];
}, { owner: unit, priority: 100 });
}
if (sid === S2) {
// "攻击范围内不存在可部署位时,技能不会发动": every activation is refused while her range has no free deployable
// tile — the SP stays (non-enumerable: never serialised; the pattern of 6_06 莎草)
const sk = unit.skill;
if (sk && !Object.prototype.hasOwnProperty.call(sk, 'activate')) {
const activate = sk.activate;
Object.defineProperty(sk, 'activate', {
configurable: true, writable: true, enumerable: false,
value(reason, opts) { return mineTiles(battle, unit).length ? activate.call(this, reason, opts) : false; },
});
}
// W撤退时撤退全部此面向敌
battle.on('death', (ctx) => {
if (ctx.unit !== unit) return;
for (const t of battle.allyUnits) if (t.alive && isMineOf(t, unit)) battle.retreat(t, { reason: 'expired', permanent: true });
}, { owner: unit });
}
},
};
},
};
+245
View File
@@ -0,0 +1,245 @@
// server/sim/content/kits/ops/op-phenxi.js — 菲亚梅塔 (char_300_phenxi) 自选 operator kit: 6★ 炮手 (狙击), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and all three modules (ART-Y “‘律外’特种弹药配给组”,
// ART-X “漫长的旅途”, ISW-A “菲亚梅塔特限证章”) at every form. Kit contract and the 自选 rules: ../README.md ("How to add an
// operator (自选)").
//
// Forms (data/backups.json units.char_300_phenxi, 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) — the owner's decision of 2026-10-05.
// Potential 0 [ASSUMED: no account]. Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into
// backups.json; battle_equip_table marks every ISW-A part but its attributes `validInGameTag` roguelike); PRTS 菲亚梅塔
// and 溅射半径一览 (the 备注 quoted below); the client's charpack char_300_phenxi (the S1 mode's attack selector), skill
// prefabs ([uc]skills skchr_phenxi_1 / 2 / 3) and buff templates (phenxi_t_1 / [bleeding] / [peak], phenxi_t_2,
// phenxi_e_t_2, phenxi_e_003_t / [bleeding], phenxi_e_003_tr).
// - Trait (炮手) "攻击造成群体物理伤害": ranged physical, every enemy within SPLASH of the struck one takes the hit in full
// (the aoesniper profile), air units too; her 3-10, blocks 1, ground enemies target her (no flag). PRTS 溅射半径一览 gives
// the 炮手 1.0 (the profile keeps the 扩散术师's 1.1): the kit's trait sets 1.0. Bonds (拉特兰) are the slot's business.
// - Module ART-Y “‘律外’特种弹药配给组” "攻击时无视敌人100点的防御力" (def_penetrate_fixed): defIgnoreFlat on all her damage.
// Stage 3 changes 宣告终局 (below).
// - Module ART-X “漫长的旅途” "攻击被阻挡的敌人时攻击力提升至110%" (trait bb atk_scale; phenxi_e_003_tr: AtkScaleUp on every
// damage she calculates when the target is blocked): ×atk_scale on her damage to a blocked enemy — normal attacks and
// their splash, both skills' blasts. Stage 3 changes 陈述苦难 (below).
// - Module ISW-A “菲亚梅塔特限证章”: its trait and every talent part are 集成战略-only (battle_equip_table validInGameTag
// roguelike: 首次部署费用 −50 %, 倒下时立即重新部署, the slower 生命流失, the doubled 精力充沛, the 再度施放 echo, +30 攻速) ⇒
// N/A here; only its attributes (HP / ATK) count, and both talents are the base ones (the record's talentsBase — the
// composed talents would carry the 集成战略 blackboard).
// - T1 陈述苦难 "自身生命会不断流失(该效果不会使生命降至0);生命值高于50%时获得+25%攻击力的精力充沛;高于80%效果翻倍"
// (phenxi_t_1: from each deployment phenxi_t_1[bleeding] every `interval` s; PRTS 备注 "每0.1s结算一次,每次结算时菲亚梅塔
// 流失当前生命值的0.5%(向上取整),且始终不会致命(至多令菲亚梅塔生命值降至1点)"): a 流失 (battle.loseHp — no hit, no SP,
// no shield; PRTS calls it 生命流失) of ⌈hp_ratio × current HP⌉ every `interval` s while she has more than 1 HP, never
// below 1. 精力充沛 (peak_performance; ba.strong "生命值高于一定比例时获得一定属性加成"): ATK +peak_1 atk while HP > peak_1
// hp_ratio (50 %), +peak_2 atk (twice that) while HP > peak_2 hp_ratio (80 %) — the higher replaces the lower.
// ART-X stage 3 (phenxi_e_003_t[bleeding]: FilterByTargetHpRatio min_hp_ratio 0.503): no loss unless HP > 50.3 % ("不会
// 使生命降至50%以下"), +30 % / +60 %.
// - T2 宣告终局 "技能持续期间外,攻击速度+27" (phenxi_t_2: an ASPD buff, overridden while a skill runs): ASPD +attack_speed
// except while a timed skill runs (S1's 30 s, S3 for good; S2 is instant). ART-Y stage 3 (phenxi_e_t_2): +30 outside,
// and ASPD +phenxi_e_t_2[in_skill] (10) while one runs.
// - S1 “你须直面” (MANUAL, attack SP, 30 s, data ACTIVE_RANGE on her 3-10 grown by ability_range_forward_extend): ATK +atk.
// PRTS 备注 "自2026年8月1日(客户端版本 2.7.61)版本更新后:技能期间的普通攻击不再以飞行单位为目标(但弹道依旧能击中飞行
// 单位),技能期间的攻击范围不再受攻击距离属性影响,因此技能提供的攻击距离加成不会产生攻击范围扩展效果" — the client agrees
// (charpack: the S1 mode's attack selector `_targetMotion` 1, owner range): while it runs she selects ground enemies
// only, her shells still splash air units (attack `splashHitsFly`), and her range stays 3-10 (S1_SINCE_2_7_61; false =
// the skill text: range +1 forward, air targets). The data trigger still reads the +1 grid — reported as an open
// question (by the owner's rule the trigger would be DEFAULT once the range does not grow).
// - S2 “你须愧悔” (MANUAL, attack SP, data DEFAULT): the attack it is cast for fires the 灼痕弹 instead (its cast animation;
// no hit of its own [ASSUMED]). PRTS 备注 "灼痕弹飞行终点为干员正前方第4格中点,每飞行0.66格留下一道灼痕,飞行速度8格/秒。
// 灼痕弹爆炸半径1.5;灼痕生成后0.8s爆炸,爆炸半径1.1": from her tile along her facing to the 4th tile ahead at 8 tiles/s,
// a 灼痕 every `dist` tiles of its flight (none on the end point [ASSUMED]); at the end atk_scale × ATK physical to every
// targetable enemy within 1.5, each 灼痕 0.8 s after it was left atk_scale_2 × ATK within 1.1 (中点判定; air units too
// [ASSUMED: no 不可对空 note]). Her ATK when each blast goes off [ASSUMED].
// - S3 “你须偿还” (MANUAL, attack SP, data DEFAULT, 持续时间无限 — a toggle; ba.permanentatk 持续攻击 "无论攻击范围内是否有攻击
// 目标,都会持续进行攻击"): every attack interval, enemy or not, a shell (S3_SPEED [ASSUMED]) to the centre of the farthest
// tile of her range on her own line ahead; PRTS 备注 "爆炸范围扩大至半径2.0…爆炸中心为半径1.1的圆(中点判定);爆炸中心攻击倍率
// 提升效果不叠加,取最高": every targetable enemy within 2.0 takes attack@atk_scale_2 × ATK, within attack@dist (1.1) of
// the centre attack@atk_scale instead, physical, air units too [ASSUMED: no 不可对空 note]. These shells are her attacks
// (isAttack, every victim a splash victim: the shot aims at a tile); a stun / freeze holds the cycle, disarm stops it.
import { num, talentBb, traitBb, moduleOn, skillRec, up, statBuff } from '../shared/tier1.js';
import { toLocal } from '../../../dir.js';
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
const S1 = 'skchr_phenxi_1';
const S2 = 'skchr_phenxi_2';
const S3 = 'skchr_phenxi_3';
/** PRTS 溅射半径一览: 炮手 1.0. */
const SPLASH = 1.0;
/**
* “你须直面” since the 2026-08-01 client (PRTS S1 备注, 客户端版本 2.7.61): her skill attacks select ground enemies only (the
* shell still hits air units) and the skill's 攻击距离+1 no longer grows her range. false = the skill text.
*/
const S1_SINCE_2_7_61 = true;
/** PRTS S2 备注: the 灼痕弹 ends at the centre of the 4th tile ahead, flies 8 tiles/s, bursts 1.5; each 灼痕 0.8 s / 1.1. */
const SHELL_REACH = 4;
const SHELL_SPEED = 8;
const SHELL_RADIUS = 1.5;
const MARK_DELAY = 0.8;
const MARK_RADIUS = 1.1;
/** PRTS S3 备注 "爆炸范围扩大至半径2.0". */
const S3_RADIUS = 2.0;
/** S3's shells fly as bombs [ASSUMED: the engine's bomb projectile speed]. */
const S3_SPEED = PROJECTILE_SPEEDS.bomb;
const PEAK_KEY = 'talent:phenxi:peak';
const ASPD_KEY = 'talent:phenxi:aspd';
const BLEED_TAG = 'phenxi:bleed';
const P1 = 'phenxi_t_1[peak_1].peak_performance';
const P2 = 'phenxi_t_1[peak_2].peak_performance';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 集成战略 module (ISW-*) is picked: none of its talent parts applies outside 集成战略. */
const isRoguelikeModule = (chess) => moduleOn(chess) && /^ISW/i.test(String(chess?.module?.type ?? ''));
/** Every targetable enemy within `r` of (x, y) (中点判定) takes scale × her ATK physical. */
function blast(battle, unit, x, y, r, scale, skill) {
battle.fx('aoe', { x, y, radius: r, id: unit.id, skill });
for (const e of battle.foesInRadius(x, y, r, true)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * scale * unit.s.atkScaleMul, type: 'phys', isSkill: true, tags: ['skill', skill] });
}
}
/** “你须愧悔”: the 灼痕弹 along her facing and the 灼痕 it leaves (header). */
function fireShell(battle, unit, b2) {
const [fr, fc] = unit.fwd;
const x0 = unit.x, y0 = unit.y;
const at = (d) => ({ x: x0 + fc * d, y: y0 + fr * d });
const end = at(SHELL_REACH);
const scale = num(b2.atk_scale, 1), scale2 = num(b2.atk_scale_2, scale), step = num(b2.dist);
battle.addProjectile({ from: unit, to: end, speed: SHELL_SPEED, visual: 'bomb', source: unit,
onHit: () => blast(battle, unit, end.x, end.y, SHELL_RADIUS, scale, 'phenxi:s2') });
if (!(step > 0)) return;
for (let k = 1; k * step < SHELL_REACH - 1e-9; k++) {
const p = at(k * step);
battle.after((k * step) / SHELL_SPEED, () => {
battle.fx('zone', { x: p.x, y: p.y, radius: MARK_RADIUS, dur: MARK_DELAY, id: unit.id, skill: 'phenxi:mark' });
battle.after(MARK_DELAY, () => blast(battle, unit, p.x, p.y, MARK_RADIUS, scale2, 'phenxi:mark'));
});
}
}
/** “你须偿还”'s aim: the farthest tile of her current range on her own line ahead ([row, col]), or null. */
function farthestAhead(unit) {
let best = null, bc = -1;
for (const k of unit.rangeKeys ?? []) {
const r = Math.floor(k / COLS), c = k % COLS;
const [lr, lc] = toLocal(r - unit.tileR, c - unit.tileC, unit.dir);
if (lr === 0 && lc > bc) { bc = lc; best = [r, c]; }
}
return best;
}
/** One “你须偿还” shell (header). */
function s3Shot(battle, unit, b3) {
const tile = farthestAhead(unit);
if (!tile) return;
const x = tile[1], y = tile[0];
const lo = num(b3['attack@atk_scale_2'], 1), hi = Math.max(lo, num(b3['attack@atk_scale'], lo)), core = num(b3['attack@dist'], 1.1);
unit.stats.attacks++;
unit.lastAttackAt = battle.time;
battle.addProjectile({ from: unit, to: { x, y }, speed: S3_SPEED, visual: 'bomb', source: unit, onHit: () => {
battle.fx('aoe', { x, y, radius: S3_RADIUS, id: unit.id, skill: 'phenxi:s3' });
for (const e of battle.foesInRadius(x, y, S3_RADIUS, true)) {
const sc = Math.hypot(e.x - x, e.y - y) <= core + 1e-9 ? hi : lo;
battle.dealDamage(unit, e, { amount: unit.s.atk * sc * unit.s.atkScaleMul, type: 'phys', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'phenxi:s3'] });
}
} });
}
export default {
char_300_phenxi: (bb, chess) => {
const src = isRoguelikeModule(chess) ? { talents: chess.talentsBase ?? chess.talents } : chess;
const t0 = talentBb(src, 0); // 陈述苦难 (ART-X stage 3: min_hp_ratio, +30 % / +60 %)
const t1 = talentBb(src, 1); // 宣告终局 (ART-Y stage 3: 30, in-skill 10)
const tb = traitBb(chess); // ART-Y def_penetrate_fixed · ART-X atk_scale
const blocked = num(tb.atk_scale, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
return {
trait: { splashRadius: SPLASH },
skills: {
[S1]: S1_SINCE_2_7_61
? { kind: 'duration', mods: { atkPct: num(b1.atk) }, targeting: { canHitFly: false }, attack: { splashHitsFly: true } }
: { kind: 'duration', mods: { atkPct: num(b1.atk) }, targeting: { rangeExtend: num(b1.ability_range_forward_extend) } },
[S2]: {
kind: 'instant',
onStart({ battle, unit }) {
unit.mem.phenxiCastAt = battle.time; // the cast takes this attack's place
fireShell(battle, unit, b2);
},
},
[S3]: {
kind: 'toggle',
attack: { noAttack: true }, // 持续攻击: her shells below are her attacks
onStart({ unit }) { unit.mem.phenxiCd = 0; },
onTick({ battle, unit, dt }) {
if (!unit.canAct) return; // 晕眩 / 冻结 hold the cycle, as her attack cooldown
unit.mem.phenxiCd = num(unit.mem.phenxiCd) - dt;
if (unit.mem.phenxiCd > 1e-9 || unit.s.flags.disarm) return;
unit.mem.phenxiCd = unit.s.interval;
s3Shot(battle, unit, b3);
},
},
},
talents: [
{ install(battle, unit) { // 陈述苦难: the 流失 and 精力充沛
const iv = Math.max(0.05, num(t0.interval, 0.1)), ratio = num(t0.hp_ratio), floor = num(t0.min_hp_ratio);
const a1 = num(t0[`${P1}.atk`]), h1 = num(t0[`${P1}.hp_ratio`], 0.5);
const a2 = num(t0[`${P2}.atk`]), h2 = num(t0[`${P2}.hp_ratio`], 0.8);
if (ratio > 0) {
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit) return;
const seq = unit.deploySeq;
battle.every(iv, (b, sched) => {
if (!unit.alive || !unit.deployed || unit.deploySeq !== seq) { sched.cancel(); return; }
if (!(unit.hp > 1) || (floor > 0 && !(unit.hpRatio > floor))) return;
const n = Math.min(Math.ceil(unit.hp * ratio - 1e-9), unit.hp - 1); // 向上取整, never lethal
if (n > 0) battle.loseHp(unit, n, { source: unit, silent: true, tags: [BLEED_TAG] });
}, { owner: unit });
}, { owner: unit });
}
if (!(a1 > 0) && !(a2 > 0)) return;
const sync = () => {
const r = unit.hpRatio;
const v = !up(unit) ? 0 : r > h2 ? a2 : r > h1 ? a1 : 0;
const cur = unit.findBuff(PEAK_KEY);
if (v > 0 && (!cur || cur.mods?.atkPct !== v)) battle.addBuff(unit, { key: PEAK_KEY, mods: { atkPct: v }, tags: ['talent'], visible: true });
else if (!(v > 0) && cur) battle.removeBuff(unit, PEAK_KEY);
};
battle.on('tick', sync, { owner: unit });
battle.on('battleStart', sync, { owner: unit });
battle.on('deploy', (ctx) => { if (ctx.unit === unit && battle.started) sync(); }, { owner: unit });
} },
{ install(battle, unit) { // 宣告终局: ASPD outside a running skill (ART-Y stage 3: and a smaller one inside)
const out = num(t1.attack_speed), inside = num(t1['phenxi_e_t_2[in_skill].attack_speed']);
if (!out && !inside) return;
const sync = () => {
const running = !!(unit.skill && unit.skill.active && unit.skill.isTimed);
const v = up(unit) ? (running ? inside : out) : 0;
const cur = unit.findBuff(ASPD_KEY);
if (v && (!cur || cur.mods?.aspd !== v)) battle.addBuff(unit, { key: ASPD_KEY, mods: { aspd: v }, tags: ['talent'] });
else if (!v && cur) battle.removeBuff(unit, ASPD_KEY);
};
battle.on('tick', sync, { owner: unit });
battle.on('battleStart', sync, { owner: unit });
for (const ev of ['deploy', 'skillStart', 'skillEnd']) battle.on(ev, (ctx) => { if (ctx.unit === unit && battle.started) sync(); }, { owner: unit });
} },
],
install(battle, unit) {
// ART-X: 攻击被阻挡的敌人时攻击力提升至110% (every damage she calculates)
if (blocked !== 1) {
battle.on('hit', (ctx) => {
const t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !t.blockedBy || ctx.dmg.type === 'element') return;
ctx.dmg.amount *= blocked;
}, { owner: unit });
}
// ART-Y: 攻击时无视敌人100点的防御力
statBuff(battle, unit, 'trait:phenxi:pierce', { defIgnoreFlat: num(tb.def_penetrate_fixed) });
// “你须愧悔”: the attack it is cast for makes no hit of its own
if (unit.skill?.id === S2) {
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || unit.mem.phenxiCastAt !== battle.time) return;
unit.mem.phenxiCastAt = null;
ctx.targets = [];
}, { owner: unit, priority: 100 });
}
},
};
},
};
+311
View File
@@ -0,0 +1,311 @@
// server/sim/content/kits/ops/op-wisdel.js — 维什戴尔 (char_1035_wisdel) 自选 operator kit: 6★ 投掷手 (狙击), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module BOM-X “‘祖宗发射器’” at every form, and
// the kit of her summon “魂灵之影” (token_10035_wisdel_wward). Kit contract and the 自选 rules: ../README.md ("How to add an
// operator (自选)").
//
// Forms (data/backups.json units.char_1035_wisdel, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0
// [ASSUMED: no account]. Sources: character_table / skill_table / battle_equip_table and the token record (zh_CN, as
// built into backups.json); PRTS 维什戴尔, 魂灵之影 and 溅射半径一览 (the 备注 quoted below); the client's skill prefabs
// ([uc]skills skchr_wisdel_1 / 2 / 3, sktok_wisdel_wward) and buff templates (wisdel_t_1[bomb] / [projectile_shock],
// wisdel_token_t_1[bomb], token_wisdel_passive[to_host], token_wisdel_host[Camouflage], token_wisdel_skill_end,
// wisdel_s_2[switch] / [overload_start], wisdel_s_3[trigger_spawn], wisdel_s_3_token[add_sp]).
// - Trait (投掷手) "攻击对小范围的地面敌人造成两次物理伤害(第二次为余震,伤害降低至攻击力的一半)": the bombarder profile — a
// ground-only splash of SPLASH (PRTS 溅射半径一览 投掷手 0.9) at full damage, then 余震 (aftershocks) on the same spot
// SHOCK_GAP s apart (the engine's spacing [ASSUMED there]) at attack@append_atk_scale × the attack's ATK — her 3-9,
// blocks 1, never targets air units (data canHitFly false), ground enemies target her. The kit replaces the profile's
// aftershock (`afterHit`) to carry 好礼 and the skills' changes. Module BOM-X “‘祖宗发射器’” "三次物理伤害(后两次为余震…)"
// (trait bb attack@enable_third_attack 1): two aftershocks; ATK / 攻击速度 in the stats; stage 3 changes 好礼 (below).
// - T1 好礼 "攻击时对主目标的攻击力提升至115%并为其附着残影,残影受到维什戴尔的余震影响时有15%概率爆炸,对周围所有敌人造成
// 150%攻击力的物理伤害并使其晕眩1秒" (BOM-X stage 3: 125 % / 175 %). PRTS 备注 "攻击倍率提升效果仅对普通攻击(含余震)生效",
// "残影持续时间无限,不可叠加,维什戴尔退场后消失;每次余震独立判定残影爆炸,爆炸半径1.1(中点判定),爆炸效果可对空": every
// main target of her attacks (each of S2's targets, each overload round) takes ×main_atk_scale on that hit and on her
// aftershocks, and carries her 残影 (one per enemy, removed when she leaves the field — a knock-out too); each
// aftershock hit on a marked enemy that survives it rolls `prob` (wisdel_t_1[projectile_shock]: Dice, then the AOE) —
// on success the mark is spent and every targetable enemy within attack@range_radius of it, air units too, takes
// bomb_atk_scale × ATK physical and is stunned attack@stun s.
// - T2 死魂灵的余息 "部署后立刻在攻击范围内召唤一个魂灵之影,在魂灵之影周围时获得迷彩": each deployment summons one 魂灵之影 on a
// free deployable tile of her range — PRTS 备注 "部署时优先在距离自身最近的>地图下方行>地图左侧列可部署位部署" (nearest to
// her, then the lower row, then the left column; at once — the deploy animation is not modelled [ASSUMED]) — while fewer
// than SHADOW_CAP stand ("最多存在3个"; beyond it nothing is summoned — S3_Spawn `_checkTokenMaxDeployCnt`, the talent's
// summon read alike [ASSUMED]). A shadow coming in gives her 迷彩 while it
// stays when she stands on its x-5 (token_wisdel_passive[to_host] + token_wisdel_host[Camouflage]: only the host keeps
// it; PRTS "仅在登场时尝试给予维什戴尔迷彩…可受孤立干扰", "本天赋的迷彩不会因维什戴尔阻挡敌人而解除" — the engine's 迷彩
// never lifts by blocking). Shadows stay when she leaves [ASSUMED: no source withdraws them; their skill needs her range].
// 魂灵之影 (shadowKit): its stats are the form's (token variant), 禁疗, no normal attack, no range of its own (PRTS 备注 "持有
// 禁疗 / 无视野范围 / 不进行普通攻击"); its skill (AUTO, 5 SP over time) "对一名在维什戴尔攻击范围内的敌人造成法术伤害,对其造成
// 1秒停顿并附着残影,之后获得0-2点技力" — PRTS "技能仅攻击维什戴尔攻击范围内的敌人,优先攻击未携带残影的敌人,不可对空" (prefab
// SkillTrigger: `_fetchHost`, `_targetMotion` 1, `_withoutThisBuff` wisdel_t_1[bomb]): cast by this kit once ready with a
// targetable ground enemy in her current range (the data's DEFAULT read on her range, which the engine's rule cannot see),
// unmarked ones first, then the usual order: its ATK × 1 arts (`_atkScale` 1, `_damageType` magical), 停顿 `sluggish` s,
// her 残影 (while she is on the field — wisdel_token_t_1[bomb] hangs it on her talent), then
// sp_min … sp_max − 1 SP (token_wisdel_skill_end: RandomSetter, forced). It can be attacked (实体类型 默认).
// - S1 定点清算 (AUTO, attack SP, data DEFAULT — the next attack): that attack splashes S1_SPLASH (PRTS 备注 "溅射半径1.1";
// its aftershocks too [ASSUMED]), makes 2 more aftershocks, each at append_atk_scale × ATK, and stuns every enemy it hits
// (main, splash, aftershocks) stun_duration s.
// - S2 饱和复仇 (MANUAL, 25 s, data DEFAULT): ATK +atk, attack interval +base_attack_time s (−0.5 / −0.7 on 2.1 s), 3 targets
// at once. 过载 (ba.overdrive "技能持续拥有两段计量槽,技能进行到一半时触发额外效果": the second half, as 号角 S3 —
// OVERLOAD_AT): every attack becomes OVERLOAD_SHOTS rounds of attack@atk_scale_ol × ATK, each on a random enemy of her
// range (PRTS "过载模式下不再同时攻击3个目标,4次攻击分别随机索敌"; one draw per round, repeats allowed), the rounds of one
// attack at once [ASSUMED], their aftershocks at append_atk_scale × that ATK (PRTS S3 备注 "攻击倍率提升效果仅对普通攻击
// 与余震生效" read for the overload rounds too [ASSUMED]). Manual close: N/A in the auto battle.
// - S3 爆裂黎明 (MANUAL, 6 发弹药 — attack@trigger_time, data SP_FULL from the official ALWAYS row): at once max_cnt shadows
// (the first one with `sp` SP: wisdel_s_3_token[add_sp] — PRTS "初始技力分别为3sp(优先召唤)与0sp"), then ATK +atk, attack
// interval +base_attack_time s, each attack attack@atk_scale_3 × ATK with a splash of S3_SPLASH (PRTS 备注 "攻击溅射半径
// 2.5"), 好礼 at attack@prob (100 %). PRTS "施加锁定、普通攻击、溅射均可对空": she targets and splashes air units meanwhile;
// the aftershocks stay on ground enemies [ASSUMED: PRTS names only the lock, the attack and its splash] on the 2.5 spot
// [ASSUMED], at append_atk_scale × the boosted ATK (the same 备注). Its <爆裂锁定> picks the target her own selection
// picks [ASSUMED: one 维什戴尔, no shared lock].
import { num, talentBb, traitBb, skillRec, up, batMod } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_wisdel_1';
const S2 = 'skchr_wisdel_2';
const S3 = 'skchr_wisdel_3';
/** Her summon “魂灵之影” (T2 死魂灵的余息, S3 爆裂黎明). */
export const SHADOW = 'token_10035_wisdel_wward';
/** PRTS 溅射半径一览: 投掷手 0.9 (the bombarder profile's). */
const SPLASH = 0.9;
/** PRTS S1 备注 "溅射半径1.1" / S3 备注 "攻击溅射半径2.5". */
const S1_SPLASH = 1.1;
const S3_SPLASH = 2.5;
/** Seconds between two aftershocks (professions.js bombarder's spacing) [ASSUMED there]. */
const SHOCK_GAP = 0.3;
/** "最多存在3个魂灵之影" (the token's maxDeployCount 3). */
const SHADOW_CAP = 3;
/** 过载 from the second half of the skill (ba.overdrive). */
const OVERLOAD_AT = 0.5;
/** "4连发". */
const OVERLOAD_SHOTS = 4;
/** The shadow's x-5 (PRTS 魂灵之影 备注 "登场时给予自身及周围4格的其他我方单位迷彩"). */
const X5 = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
const GROUND = Object.freeze({ canHitFly: false });
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** Buff key of 维什戴尔 `host`'s 残影 on an enemy (one per enemy: "不可叠加"). */
const markKey = (host) => `wisdel:mark:${host.id}`;
/** Buff key of the 迷彩 a shadow gave its host. */
const camoKey = (t) => `wisdel:camou:${t.id}`;
const isShadowOf = (t, host) => !!t && t.kind === 'token' && t.defId === SHADOW && t.ownerUnit === host;
const shadowsOf = (battle, host) => battle.allyUnits.filter((t) => t.alive && isShadowOf(t, host));
/** Hang `host`'s 残影 on enemy `e` (while she is on the field). */
function markOf(battle, host, e) {
if (!e || !e.alive || e.side !== 'enemy' || !up(host)) return;
const key = markKey(host);
if (!e.findBuff(key)) battle.addBuff(e, { key, source: host, tags: ['wisdel:mark'] });
}
/** 好礼's explosion on marked enemy `e` (the mark is spent). */
function detonate(battle, unit, e, bomb) {
battle.removeBuff(e, markKey(unit));
const x = e.x, y = e.y;
battle.fx('aoe', { x, y, radius: bomb.radius, id: unit.id, skill: 'wisdel:bomb' });
for (const v of battle.foesInRadius(x, y, bomb.radius, true)) { // 爆炸效果可对空
battle.dealDamage(unit, v, { amount: unit.s.atk * bomb.scale * unit.s.atkScaleMul, type: 'phys', isSplash: true, tags: ['wisdel:bomb'] });
if (bomb.stun > 0 && hasHp(v)) battle.applyStatus(v, 'stun', { duration: bomb.stun, source: unit });
}
}
/**
* The aftershocks of one hit (the profile's `afterHit`): `count` shocks SHOCK_GAP s apart on the hit's spot, each
* hitting every targetable ground enemy within `radius` for `atkScale` × `shockScale` × ATK (×`mainScale` on the main
* target), stunning them `stun` s, and rolling 好礼 on each marked survivor (`prob`).
*/
function shocks({ count, atkScale, shockScale, radius, prob, stun, mainScale, bomb }) {
return (battle, unit, target, hctx) => {
for (let i = 1; i <= count; i++) {
battle.after(SHOCK_GAP * i, () => {
for (const e of battle.foesInRadius(hctx.x, hctx.y, radius, true)) {
if (e.isFlying) continue; // 余震: 地面敌人
const boost = e === target ? mainScale : 1;
battle.dealDamage(unit, e, { amount: unit.s.atk * atkScale * shockScale * boost * unit.s.atkScaleMul, type: 'phys', isSplash: true, isSkill: !!hctx.isSkill, tags: ['aftershock', 'wisdel:shock'] });
if (!hasHp(e)) continue;
if (stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
if (prob > 0 && e.findBuff(markKey(unit)) && (prob >= 1 || battle.rng.chance(prob))) detonate(battle, unit, e, bomb);
}
});
}
};
}
/** The free deployable tile of her range a shadow takes (PRTS: nearest to her > lower row > left column), or null. */
function shadowTile(battle, host) {
let best = null, bd = Infinity;
for (const k of host.rangeKeys ?? []) {
const r = Math.floor(k / COLS), c = k % COLS;
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
const d = Math.hypot(r - host.tileR, c - host.tileC);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && (r < best[0] || (r === best[0] && c < best[1])))) { bd = d; best = [r, c]; }
}
return best;
}
/** The enemy a shadow strikes: a targetable ground enemy of `host`'s range, unmarked first, then the usual order. */
function shadowTarget(battle, host, t) {
const pool = battle.enemiesInKeys(host.rangeKeys, t, GROUND);
if (!pool.length) return null;
const key = markKey(host);
const fresh = pool.filter((e) => !e.findBuff(key));
const list = fresh.length ? fresh : pool;
sortEnemyTargets(battle, t, list, null);
return list[0];
}
/** 魂灵之影's kit (header); `host` = 维什戴尔. */
function shadowKit(host) {
return {
skill: {
kind: 'instant',
trigger: 'NEVER', // cast by the install below on her range
onStart({ battle, unit, bb }) {
const e = unit.mem.wisdelTarget;
unit.mem.wisdelTarget = null;
if (e && e.alive) {
battle.fx('strike', { x: e.x, y: e.y, id: unit.id, target: e.id });
battle.dealDamage(unit, e, { amount: unit.s.atk * unit.s.atkScaleMul, type: 'arts', isSkill: true, tags: ['skill', 'wisdel:shadow'] });
if (hasHp(e)) {
const slug = num(bb.sluggish);
if (slug > 0) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit });
markOf(battle, host, e);
}
}
// token_wisdel_skill_end: 之后获得0-2点技力 (an integer of [sp_min, sp_max), forced)
const lo = Math.floor(num(bb.sp_min)), hi = Math.floor(num(bb.sp_max, lo + 1));
const add = lo + battle.rng.int(Math.max(1, hi - lo));
if (add > 0) unit.skill.gainSp(add, 'init');
},
},
trait: { noAttack: true },
install(battle, t) {
battle.addBuff(t, { key: 'trait:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
battle.on('tick', () => {
const sk = t.skill;
if (!t.alive || !t.deployed || !sk || !sk.ready || sk.active || !t.canAct || t.s.flags.silence || !up(host)) return;
const e = shadowTarget(battle, host, t);
if (!e) return;
t.mem.wisdelTarget = e;
sk.activate('DEFAULT');
}, { owner: t });
battle.on('death', (ctx) => {
if (ctx.unit === t && host.findBuff(camoKey(t))) battle.removeBuff(host, camoKey(t));
}, { owner: t });
},
};
}
/** A shadow coming in: 迷彩 for its host while it stays, when she stands on its x-5 and may be selected by it. */
function grantCamo(battle, host, t) {
if (!up(host) || !battle.allySelectable(host, t)) return;
if (!absoluteRangeKeys(X5, t.tileR, t.tileC, t.dir, 0).includes(host.tileR * COLS + host.tileC)) return;
battle.addBuff(host, { key: camoKey(t), flags: { camou: true }, source: t, tags: ['talent'], visible: true });
battle.fx('camouflage', { x: host.x, y: host.y, id: host.id });
}
/** Summon up to `n` shadows for `host` (never beyond SHADOW_CAP); the first one starts with `firstSp` SP. */
function summonShadows(battle, host, n, firstSp = 0) {
for (let i = 0; i < n; i++) {
if (shadowsOf(battle, host).length >= SHADOW_CAP) return;
const tile = shadowTile(battle, host);
if (!tile) return;
const t = battle.spawnToken(host, SHADOW, tile[0], tile[1], { kit: shadowKit(host) });
if (!t) return;
if (i === 0 && firstSp > 0 && t.skill) t.skill.gainSp(firstSp, 'init');
battle.fx('summon', { x: t.x, y: t.y, id: host.id, token: SHADOW });
grantCamo(battle, host, t);
}
}
export default {
char_1035_wisdel: (bb, chess) => {
const tb = traitBb(chess);
const shockScale = num(tb['attack@append_atk_scale'], 0.5);
const count = num(tb['attack@enable_third_attack']) > 0 ? 2 : Math.max(1, Math.floor(num(tb['attack@times'], 2)) - 1);
const t0 = talentBb(chess, 0);
const mainScale = num(t0['attack@main_atk_scale'], 1);
const bomb = { scale: num(t0['attack@bomb_atk_scale']), stun: num(t0['attack@stun']), radius: num(t0['attack@range_radius'], 1.1) };
const base = { count, atkScale: 1, shockScale, radius: SPLASH, prob: num(t0['attack@prob']), stun: 0, mainScale, bomb };
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1);
const s3Scale = num(b3['attack@atk_scale_3'], 1);
return {
trait: {
splashRadius: SPLASH,
...(mainScale !== 1 ? { dmgMul: () => mainScale } : null), // 好礼: 主目标 ×main_atk_scale (ai.js: the main target only)
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main') markOf(battle, unit, victim); }, // 附着残影
afterHit: shocks(base),
},
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
splashRadius: S1_SPLASH,
onEachHit({ battle, unit, target }) {
const d = num(b1.stun_duration);
if (d > 0 && target && target.side === 'enemy' && hasHp(target)) battle.applyStatus(target, 'stun', { duration: d, source: unit });
},
afterHit: shocks({ ...base, count: count + 2, shockScale: num(b1.append_atk_scale, shockScale), radius: S1_SPLASH, stun: num(b1.stun_duration) }),
},
},
[S2]: {
kind: 'duration',
mods: { atkPct: num(b2.atk), batPct: batMod(b2.base_attack_time, chess) },
targeting: { maxTargets: 3 },
onStart({ unit }) { unit.mem.wisdelOverload = false; },
onTick({ battle, unit, skill }) {
if (unit.mem.wisdelOverload || skill.timeLeft > skill.duration * (1 - OVERLOAD_AT) + 1e-9) return;
unit.mem.wisdelOverload = true;
battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id });
},
onEnd({ unit }) { unit.mem.wisdelOverload = false; },
},
[S3]: {
kind: 'ammo',
ammo: Math.max(1, Math.floor(num(b3['attack@trigger_time'], 6))),
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
targeting: { canHitFly: true },
attack: {
atkScale: s3Scale,
splashRadius: S3_SPLASH,
groundOnly: false,
afterHit: shocks({ ...base, atkScale: s3Scale, radius: S3_SPLASH, prob: num(b3['attack@prob'], 1) }),
},
onStart({ battle, unit }) { summonShadows(battle, unit, Math.max(0, Math.floor(num(b3.max_cnt, 1))), num(b3.sp)); },
},
},
talents: [
{ install(battle, unit) { // 好礼: her 残影 leave with her
battle.on('death', (ctx) => {
if (ctx.unit !== unit) return;
const key = markKey(unit);
for (const e of battle.enemies) if (e.findBuff(key)) battle.removeBuff(e, key);
}, { owner: unit });
} },
{ install(battle, unit) { // 死魂灵的余息: a shadow at each deployment
battle.on('deploy', (ctx) => { if (ctx.unit === unit) summonShadows(battle, unit, 1); }, { owner: unit });
} },
],
install(battle, unit) {
// 过载: every attack becomes 4 rounds at attack@atk_scale_ol, each on a random enemy of her range
if (unit.skill?.id !== S2) return;
const ol = num(b2['attack@atk_scale_ol'], 1);
const olShock = shocks({ ...base, atkScale: ol });
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.mem.wisdelOverload || !unit.skill?.active || !ctx.profile) return;
const pool = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!pool.includes(e)) pool.push(e);
if (!pool.length) return;
const picks = [];
for (let i = 0; i < OVERLOAD_SHOTS; i++) picks.push(battle.rng.pick(pool));
ctx.targets = picks;
// (a per-attack copy while the skill runs: its targeting override makes effectiveProfile copy the profile)
ctx.profile.atkScale = ol;
ctx.profile.afterHit = olShock;
}, { owner: unit, priority: -100 });
},
};
},
};
+440
View File
@@ -0,0 +1,440 @@
// test/content/op_cqbw.test.js — the 自选 operator kit of W (char_113_cqbw, 6★ 炮手; kit
// server/sim/content/kits/ops/op-cqbw.js) and of her summon “此面向敌” (token_10008_cqbw_box), 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, ART-X “佣兵的行囊” or ART-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.
// Run: node --test test/content/op_cqbw.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 W = 'char_113_cqbw';
const MINE = 'token_10008_cqbw_box';
const FORMS = BACKUPS.units[W].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const X = 'uniequip_002_cqbw', Y = 'uniequip_003_cqbw';
const S1 = 'skchr_cqbw_1', S2 = 'skchr_cqbw_2', S3 = 'skchr_cqbw_3';
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusOf(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);
/** The mine's variant of a form with the pick's skill / module (getDiyToken). */
const mineOf = (tier, elite, mod) => {
let v = BACKUPS.tokens[MINE].variants[`${W}@${statusOf(tier, elite)}`];
v = { ...v, ...v.bySkill['1'] };
if (mod && v.byModule?.[mod]) v = { ...v, ...v.byModule[mod] };
return v;
};
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_def: dummy('enemy_def', { def: 300 }),
enemy_weak: dummy('enemy_weak', { hp: 100 }),
};
for (const hp of [2e8, 3e8, 4e8, 5e8]) ENEMIES[`enemy_hp${hp / 1e8}`] = dummy(`enemy_hp${hp / 1e8}`, { hp });
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))];
/** Flat-stage rows with W's 3-10 at (10, 4) left without a free deployable tile, or with only (9, 5) and (11, 8). */
const NO_TILE = { 9: '##ErfffffrSrrrrrrrS##', 10: '##hrrffffrfrrrrrrrf##', 11: '##hrfffffrfrrrrrrrf##' };
const TWO_TILES = { 9: '##ErfrfffrSrrrrrrrS##', 10: '##hrrffffrfrrrrrrrf##', 11: '##hrffffrrfrrrrrrrf##' };
/** A battle with W as uid 1 at (row, col) facing RIGHT (her 3-10: the 3 rows around her, her column and the 4 ahead). */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5, rows = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, flat: rows ? { rows } : {},
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'spGain', 'dodge'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: W, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
const on = (h, e) => h.hooksOf('damaged').filter((c) => c.target === e);
const minesOf = (h, u) => h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === MINE && t.ownerUnit === u);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
test('W in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-10, ranged physical 炮手 (splash 1.0), hits air, blocks 1, ground-targetable, the data triggers; offered as a pick', () => {
assert.equal(OPERATOR_KITS[W], KITS[W]);
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], [W, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat, u.profile.splashRadius, u.profile.sub],
[1, 'ranged', true, 'phys', 2.8, 1, 'aoesniper'], `${label(f)}: 炮手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-10`);
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
assert.deepEqual(formOf(6, true).skills.map((s) => [s.skillType, s.trigger.rule]), [['MANUAL', 'DEFAULT'], ['AUTO', 'DEFAULT'], ['MANUAL', 'DEFAULT']]);
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], [1284, 711, 1497, 844]);
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr], [{ maxHp: 130, atk: 54 }, { maxHp: 210, atk: 78 }, { maxHp: 160, atk: 45 }, { maxHp: 200, atk: 85 }]);
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(W) && [5, 6].every((t) => diyPool(t, { data, kitted: KITTED_CHARS }).includes(W)));
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: W, skillIndex: 1, uniEquipId: X } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: W, skillIndex: 1, uniEquipId: X } } });
});
test('trait 炮手: every enemy within 1.0 of the struck one takes the hit in full (PRTS 溅射半径一览 1.0, not the profile\'s 1.1), air units too', () => {
const { h, u } = field({ skill: 2 });
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
const near = h.spawn('enemy_dummy', { pos: [11, 8.9] }); // 1.35 away, outside her range (col 9)
const one = h.spawn('enemy_fly', { pos: [9, 8.6] }); // √(1 + 0.36) ≈ 1.166: outside 1.0 as well
const fly = h.spawn('enemy_fly', { pos: [10, 9] }); // 1.0 away
const edge = h.spawn('enemy_dummy', { pos: [10, 9.08] }); // 1.08: inside 1.1, outside 1.0
assert.ok(h.runUntil(() => from(h, u).some((c) => c.target === main), 6), 'she attacks the one enemy of her range');
h.run(0.5);
const id = from(h, u).find((c) => c.target === main).dmg.attackId;
const hit = from(h, u).filter((c) => c.dmg.attackId === id);
assert.deepEqual(new Set(hit.map((c) => c.target)), new Set([main, fly]), 'the struck one and the flyer 1.0 away');
for (const c of hit) approx(c.amount, u.s.atk, 'full damage');
assert.deepEqual([near, one, edge].map((e) => on(h, e).length), [0, 0, 0], 'beyond 1.0 untouched (1.08 too)');
done(h);
});
test('S1 红桃K (MANUAL, data DEFAULT, 22 / 19 SP): cast like an attack — that attack is the grenade: 270 % / 310 % ATK physical to every enemy within 1.2 (air too), each stunned 1.8 / 2.1 s; no extra hit; plain attacks after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.spCost, sk.initSp, sk.spType, sk.bb.atk_scale, sk.bb.stun], elite ? [19, 0, 'INCREASE_WITH_TIME', 3.1, 2.1] : [22, 0, 'INCREASE_WITH_TIME', 2.7, 1.8], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.kind, u.skill.rule], ['instant', 'DEFAULT'], `T${tier}`);
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
const a = h.spawn('enemy_dummy', { pos: [10, 9.15] }); // 1.15: inside 1.2, outside her 1.0
const b = h.spawn('enemy_dummy', { pos: [10, 9.3] }); // 1.3: outside 1.2
const f = h.spawn('enemy_fly', { pos: [9, 8.55] }); // √(1 + 0.3025) ≈ 1.141, outside her range (col 9)
assert.ok(h.runUntil(() => from(h, u).length > 0, 6), `T${tier}: a plain attack first`);
assert.deepEqual(from(h, u).map((c) => c.target), [main], `T${tier}: her 1.0 splash reaches none of them`);
u.skill.gainSp(999);
const n0 = from(h, u).length;
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `T${tier}: cast on her next attack`);
h.run(0.6);
const shot = from(h, u).slice(n0);
const id = shot[0].dmg.attackId;
assert.ok(shot.every((c) => c.dmg.attackId === id), `T${tier}: one attack — the grenade, nothing else`);
assert.deepEqual(new Set(shot.map((c) => c.target)), new Set([main, a, f]), `T${tier}: everything within 1.2`);
for (const c of shot) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true], `T${tier}: physical skill damage`);
}
assert.equal(on(h, b).length, 0, `T${tier}: 1.3 away untouched`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.deepEqual(new Set(stuns.map((c) => c.target)), new Set([main, a, f]), `T${tier}: each victim stunned`);
for (const c of stuns) approx(c.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
const n1 = from(h, u).length;
h.run(6);
const later = from(h, u).slice(n1);
assert.ok(later.length >= 2 && later.every((c) => !c.dmg.isSkill && Math.abs(c.amount - u.s.atk) < 1e-6), `T${tier}: plain attacks again`);
done(h);
}
});
test('S2 惊吓盒子 (AUTO, data DEFAULT, 11 / 10 SP): the next attack plants a 此面向敌 under the targetable ground enemy instead of hitting; 1.5 s after an enemy comes within 1.35, W\'s ATK × 220 % / 250 % to every ground enemy within 1.35 + stun 1.6 / 1.8 s; then it is gone', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2), mv = mineOf(tier, elite, null);
assert.deepEqual([sk.skillType, sk.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.stun, sk.overrideTokenKey], elite ? ['AUTO', 10, 0, 2.5, 1.8, MINE] : ['AUTO', 11, 0, 2.2, 1.6, MINE], `T${tier}`);
assert.deepEqual([mv.skill.bb.atk_scale, mv.skill.bb.stun, mv.talents[0].bb.duration], [sk.bb.atk_scale, sk.bb.stun, 120], `T${tier}: the token carries the form's S2 blackboard`);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.kind, u.skill.rule], ['instant', 'DEFAULT'], `T${tier}`);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
const g = h.spawn('enemy_dummy', { pos: [11, 7] }); // 1.0 from the mine
const fly = h.spawn('enemy_fly', { pos: [10, 8] }); // 1.0 from the mine, an air unit
const far = h.spawn('enemy_dummy', { pos: [9, 8.3] }); // √(1 + 1.69) ≈ 1.64
u.skill.gainSp(999);
const n0 = from(h, u).length;
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `T${tier}: cast`);
assert.equal(from(h, u).length, n0, `T${tier}: that attack made no hit`);
const [m] = minesOf(h, u);
assert.ok(m && m.alive, `T${tier}: a mine`);
assert.deepEqual([m.tileR, m.tileC], [10, 7], `T${tier}: under the targetable ground enemy (of the four, by her order)`);
assert.deepEqual([!!m.s.flags.untargetable, m.profile.noAttack, m.s.blockCnt, m.base.maxHp], [true, true, 0, mv.stats.maxHp], `T${tier}: untargetable trap, no attack, blocks nothing`);
const atk = u.s.atk;
const t0 = h.b.time;
assert.ok(h.runUntil(() => !m.alive, 3), `T${tier}: goes off`);
assert.ok(Math.abs(h.b.time - t0 - 1.5) < 0.07, `T${tier}: 1.5 s after it was armed (at once: an enemy on it) — ${h.b.time - t0}`);
const blast = from(h, m);
assert.deepEqual(new Set(blast.map((c) => c.target)), new Set([e, g]), `T${tier}: the ground enemies within 1.35`);
for (const c of blast) {
approx(c.amount, atk * sk.bb.atk_scale, `T${tier}: W's ATK × ${sk.bb.atk_scale}`);
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true], `T${tier}: physical`);
}
assert.deepEqual([on(h, fly).filter((c) => c.source === m).length, on(h, far).filter((c) => c.source === m).length], [0, 0], `T${tier}: not the flyer, not 1.64 away`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === m && c.status === 'stun');
assert.deepEqual(new Set(stuns.map((c) => c.target)), new Set([e, g]), `T${tier}: stunned`);
for (const c of stuns) approx(c.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
assert.equal(h.hooksOf('death').find((c) => c.unit === m)?.reason, 'expired', `T${tier}: used up, no knock-out`);
done(h);
}
});
test('S2 惊吓盒子: no free deployable tile in her range ⇒ the skill does not fire (SP kept, she attacks); else the tile nearest to the enemy; flyers neither pick the tile nor arm it; 120 s life; her retreat withdraws every mine', () => {
const blocked = field({ skill: 1, rows: NO_TILE });
blocked.h.spawn('enemy_dummy', { pos: [10, 7] });
blocked.u.skill.gainSp(999);
blocked.h.run(10);
assert.deepEqual([blocked.u.skill.activations, blocked.u.skill.charges, minesOf(blocked.h, blocked.u).length], [0, 1, 0], 'refused, SP kept');
assert.ok(from(blocked.h, blocked.u).length >= 3, 'her attacks go on');
done(blocked.h);
const two = field({ skill: 1, rows: TWO_TILES });
two.h.spawn('enemy_dummy', { pos: [10, 8] }); // on floor: 1.0 from (11, 8), √(1 + 9) from (9, 5)
two.u.skill.gainSp(999);
assert.ok(two.h.runUntil(() => two.u.skill.activations === 1, 4));
assert.deepEqual(minesOf(two.h, two.u).map((m) => [m.tileR, m.tileC]), [[11, 8]], 'the free tile nearest to the enemy');
done(two.h);
const { h, u } = field({ skill: 1 });
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), 'a flyer in range: cast');
const [m] = minesOf(h, u);
assert.ok(m && m.alive, 'a mine on a free tile of her range (drawn: no ground enemy to place it by)');
h.run(30);
assert.ok(m.alive && !m.mem.armed && on(h, fly).every((c) => c.source !== m), 'the flyer never arms it');
assert.ok(h.runUntil(() => !m.alive, 100), 'gone at last');
approx(m.deathAt - m.deployedAt, 120, 'after 120 s', 0.01);
assert.equal(h.hooksOf('death').find((c) => c.unit === m)?.reason, 'expired');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => minesOf(h, u).filter((t) => t.alive).length >= 1, 8), 'another one');
h.b.retreat(u);
h.step();
assert.equal(minesOf(h, u).filter((t) => t.alive).length, 0, 'W撤退时撤退全部此面向敌');
done(h);
});
test('S3 D12 (MANUAL, data DEFAULT, 42 / 39 SP from 14 / 17): bombs on the 3 enemies of her range with the most HP (air too), cached ATK × 250 % / 280 % 无来源 physical within 1.2 of each 3 s after it lands + stun 3.5 / 4 s; early when its target falls; no hit of its own', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.spCost, sk.initSp, sk.bb.max_target, sk.bb.atk_scale, sk.bb.stun], elite ? [39, 17, 3, 2.8, 4] : [42, 14, 3, 2.5, 3.5], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.kind, u.skill.rule, Math.floor(u.skill.sp)], ['instant', 'DEFAULT', sk.initSp], `T${tier}: from ${sk.initSp} SP`);
const lo = h.spawn('enemy_hp2', { pos: [9, 5] });
const e3 = h.spawn('enemy_hp3', { pos: [11, 5] });
const e4 = h.spawn('enemy_hp4', { pos: [10, 8] });
const f5 = h.spawn('enemy_hp5', { pos: [9, 8] });
const fly = h.spawn('enemy_fly', { pos: [11, 8] }); // 1e9 HP: the most of all
const side = h.spawn('enemy_dummy', { pos: [10, 9.15] }); // outside her range, 1.15 from e4
u.skill.gainSp(999);
const n0 = from(h, u).length;
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `T${tier}: cast`);
const atk = u.s.atk;
h.b.addBuff(u, { key: 'test:atk', mods: { atkPct: 1 } });
h.run(0.1);
assert.equal(from(h, u).length, n0, `T${tier}: that attack made no hit`);
h.run(1);
assert.ok(h.hooksOf('damaged').every((c) => !c.dmg.tags?.includes('cqbw:d12')), `T${tier}: nothing before 3 s`);
h.run(2.6);
const blasts = h.hooksOf('damaged').filter((c) => c.dmg.tags?.includes('cqbw:d12'));
const victims = new Set(blasts.map((c) => c.target));
assert.ok([fly, f5, e4].every((e) => victims.has(e)), `T${tier}: the three with the most HP (the flyer too)`);
assert.ok(victims.has(side), `T${tier}: and what stands within 1.2 of a bomb`);
assert.ok(!victims.has(lo) && !victims.has(e3), `T${tier}: the others untouched`);
// the ATK cached at the cast (her +100 % since then counts for nothing); a victim of her range that an earlier bomb
// stunned takes 落井下石's ×1.18 on top, the one outside her range never
const base = atk * sk.bb.atk_scale;
for (const c of blasts) {
const stunnedBefore = c.target !== side && Math.abs(c.amount - base * 1.18) < 1e-6 * base;
assert.ok(Math.abs(c.amount - base) < 1e-6 * base || stunnedBefore, `T${tier}: the cached ATK × ${sk.bb.atk_scale} (${c.amount} vs ${base})`);
assert.deepEqual([c.type, c.source, c.credit, !!c.dmg.sourceless], ['phys', null, u, true], `T${tier}: 无来源, credited to her`);
}
assert.ok(on(h, side).filter((c) => c.dmg.tags?.includes('cqbw:d12')).every((c) => Math.abs(c.amount - base) < 1e-6 * base), `T${tier}: outside her range: no 落井下石`);
for (const e of [fly, f5, e4]) approx(blasts.find((c) => c.target === e).amount, base, `T${tier}: its first blast, not stunned yet`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.ok(stuns.length >= 4 && stuns.every((c) => Math.abs(c.duration - sk.bb.stun) < 1e-6), `T${tier}: stun ${sk.bb.stun} s`);
done(h);
}
// early burst: the bombed enemy falls before its 3 s; a bomb still goes off after she leaves the field
const { h, u } = field({ skill: 2 });
const target = h.spawn('enemy_dummy', { pos: [10, 8] });
const by = h.spawn('enemy_dummy', { pos: [10, 9.1] }); // outside her range and her 1.0 splash, 1.1 from the target
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), 'cast');
h.run(0.8);
assert.ok(on(h, by).length === 0, 'the bomb landed, nothing yet');
h.b.kill(target, null);
h.step();
assert.ok(on(h, by).some((c) => c.dmg.tags?.includes('cqbw:d12')), 'it went off at once');
const two = field({ skill: 2 });
const t2 = two.h.spawn('enemy_dummy', { pos: [10, 8] });
two.u.skill.gainSp(999);
assert.ok(two.h.runUntil(() => two.u.skill.activations === 1, 4));
two.h.run(0.8);
two.h.b.retreat(two.u);
two.h.run(3);
assert.ok(on(two.h, t2).some((c) => c.dmg.tags?.includes('cqbw:d12')), 'her retreat keeps the bomb');
done(h);
done(two.h);
});
test('T1 设伏: 10 s after each deployment 60 % physical and arts dodge, taunt −1, until she leaves; ART-Y stage 3: after 8 s, and ATK +1.25 % a second up to +20 %, reset by damage taken (not by a dodge or a 流失)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const y3 = elite && mod === Y && tier === 6;
const iv = y3 ? 8 : 10;
assert.deepEqual(u.def.raw.talents.find((t) => t.index === 0).bb, { interval: iv, prob: 0.6, taunt_level: -1 }, label(f));
h.run(iv - 0.3);
assert.equal(u.findBuff('talent:cqbw:ambush'), null, `${label(f)}: not before ${iv} s`);
h.run(0.5);
assert.deepEqual(u.findBuff('talent:cqbw:ambush')?.mods, { dodgePhys: 0.6, dodgeArts: 0.6, taunt: -1 }, `${label(f)}: after ${iv} s`);
assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts, u.s.taunt], [0.6, 0.6, -1], label(f));
const ramp = u.findBuff('talent:cqbw:ramp');
if (!y3) assert.equal(ramp, null, `${label(f)}: no ramp`);
else {
assert.equal(ramp.stacks, Math.floor(iv + 0.2), `${label(f)}: a stack a second`);
approx(u.s.atk, u.base.atk * (1 + 0.0125 * ramp.stacks), `${label(f)}: ATK +1.25 % each`);
h.run(10);
assert.equal(u.findBuff('talent:cqbw:ramp').stacks, 16, `${label(f)}: at most 16`);
approx(u.s.atk, u.base.atk * 1.2, `${label(f)}: +20 %`);
h.b.loseHp(u, 10, { source: u });
assert.equal(u.findBuff('talent:cqbw:ramp')?.stacks, 16, `${label(f)}: a 流失 keeps it`);
const src = h.spawn('enemy_dummy', { pos: [10, 12] });
h.b.dealDamage(src, u, { amount: 10, type: 'phys', canDodge: false });
assert.equal(u.findBuff('talent:cqbw:ramp'), null, `${label(f)}: damage clears it`);
h.run(1.01);
assert.equal(u.findBuff('talent:cqbw:ramp')?.stacks, 1, `${label(f)}: and it climbs again`);
}
h.b.retreat(u);
assert.equal(u.findBuff('talent:cqbw:ambush'), null, `${label(f)}: gone when she leaves`);
done(h);
}
// a dodged hit does not reset the ramp (dodges come from the 60 % of 设伏)
const { h, u } = field({ tier: 6, elite: true, mod: Y, skill: 2, seed: 9 });
h.run(12);
const e = h.spawn('enemy_dummy', { pos: [10, 12] });
let checked = 0;
for (let i = 0; i < 60 && checked < 3; i++) {
if (!u.findBuff('talent:cqbw:ramp')) h.run(1.05);
const before = u.findBuff('talent:cqbw:ramp')?.stacks ?? 0;
const n = h.hooksOf('dodge').length;
h.b.dealDamage(e, u, { amount: 1, type: 'phys' });
if (h.hooksOf('dodge').length > n) {
assert.ok(before > 0 && u.findBuff('talent:cqbw:ramp')?.stacks === before, 'a dodge keeps the ramp');
checked++;
} else assert.equal(u.findBuff('talent:cqbw:ramp'), null, 'a hit that lands clears it');
}
assert.equal(checked, 3, 'three dodges seen');
done(h);
});
test('T2 落井下石: a stunned enemy in her range takes ×1.18 physical damage from anyone (×1.24 with ART-X stage 3); not arts, not unstunned, not frozen, not outside her range; two W keep the strongest', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x3 = elite && mod === X && tier === 6;
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 12, col: 9 }] });
const ally = h.unit(2);
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
const inR = h.spawn('enemy_dummy', { pos: [11, 7] }), outR = h.spawn('enemy_dummy', { pos: [12, 7] });
const scale = x3 ? 1.24 : 1.18;
assert.equal(u.def.raw.talents.find((t) => t.index === 1).bb.damage_scale, scale, label(f));
const deal = (e, type) => h.b.dealDamage(ally, e, { amount: 1000, type, canDodge: false });
assert.equal(deal(inR, 'phys'), 1000, `${label(f)}: not stunned`);
for (const e of [inR, outR]) h.b.applyStatus(e, 'stun', { duration: 5 });
approx(deal(inR, 'phys'), 1000 * scale, `${label(f)}: stunned in her range`);
assert.equal(deal(inR, 'arts'), 1000, `${label(f)}: physical only`);
assert.equal(deal(outR, 'phys'), 1000, `${label(f)}: outside her range`);
h.b.removeStatus(inR, 'stun');
h.b.applyStatus(inR, 'freeze', { duration: 5 });
assert.equal(deal(inR, 'phys'), 1000, `${label(f)}: 冻结 is no 晕眩`);
done(h);
}
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 60, autoFinish: false, seed: 1, flags: { dpPerSec: 0, dpMax: 999 },
units: [{ uid: 1, diy: { slot: 5, charId: W, skillIndex: 2 }, row: 10, col: 4 }, { uid: 2, diy: { slot: 6, charId: W, skillIndex: 2, uniEquipId: X }, elite: true, row: 11, col: 4 }],
});
h.step();
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
h.b.applyStatus(e, 'stun', { duration: 5 });
approx(h.b.dealDamage(null, e, { amount: 1000, type: 'phys', canDodge: false }), 1240, 'two W: ×1.24, not ×1.18 × 1.24');
checkInvariants(h.b);
});
test('ART-X stage 3 落井下石: +1 SP (forced, under 阻回 too) when her projectile — a normal attack, a D12 bomb — knocks out an enemy of her range, or her mine knocks out any enemy; not stage 1, not others\' kills', () => {
for (const f of [[5, true, X], [6, true, X], [6, true, Y], [6, false, null]]) {
const [tier, elite, mod] = f;
const x3 = tier === 6 && mod === X;
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 12, col: 9 }] });
assert.equal(u.def.raw.talents.find((t) => t.index === 1).bb.sp ?? 0, x3 ? 1 : 0, label(f));
const forced = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'init').length;
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } });
const n0 = forced();
const weak = h.spawn('enemy_weak', { pos: [10, 7] });
assert.ok(h.runUntil(() => !weak.alive, 6), `${label(f)}: her attack knocks it out`);
assert.equal(forced() - n0, x3 ? 1 : 0, `${label(f)}: +1 SP under 阻回`);
const other = h.spawn('enemy_weak', { pos: [12, 12] });
h.b.dealDamage(h.unit(2), other, { amount: 1e6, type: 'true' });
assert.equal(forced() - n0, x3 ? 1 : 0, `${label(f)}: not another unit's kill`);
done(h);
}
// her mine's knock-out: any enemy (S2), and a D12 bomb's (S3)
for (const skill of [1, 2]) {
const { h, u } = field({ tier: 6, elite: true, mod: X, skill });
const forced = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'init').length;
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
const weak = h.spawn('enemy_weak', { pos: [10, 7] });
h.b.removeBuff(u, 'test:disarm');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `S${skill + 1}: cast`);
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
assert.ok(h.runUntil(() => !weak.alive, 5), `S${skill + 1}: the ${skill === 1 ? 'mine' : 'bomb'} knocks it out`);
const killer = h.hooksOf('death').find((c) => c.unit === weak)?.killer;
assert.equal(skill === 1 ? killer?.defId : killer, skill === 1 ? MINE : u, `S${skill + 1}: the killer`);
assert.equal(forced(), 1, `S${skill + 1}: +1 SP`);
done(h);
}
});
test('ART-X trait: ×1.1 on her damage to a blocked enemy (main and splash), stage 1 and 3; its token part: the mine ×1.1 on a blocked one; ART-Y trait: ignores 100 DEF', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x = elite && mod === X, y = elite && mod === Y;
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 8, dir: 'LEFT' }] });
const blockedE = h.spawn('enemy_dummy', { pos: [10, 8] }); // on 角峰's tile: blocked by him
const free = h.spawn('enemy_dummy', { pos: [11, 8] }); // 1.0 away: her splash
h.step();
assert.ok(blockedE.blockedBy, `${label(f)}: blocked`);
assert.ok(h.runUntil(() => from(h, u).some((c) => c.target === free), 6), `${label(f)}: an attack`);
const id = from(h, u).find((c) => c.target === free).dmg.attackId;
const hits = from(h, u).filter((c) => c.dmg.attackId === id);
const amt = (e) => hits.find((c) => c.target === e)?.amount;
approx(amt(free), u.s.atk, `${label(f)}: not blocked`);
approx(amt(blockedE), u.s.atk * (x ? 1.1 : 1), `${label(f)}: blocked`);
if (x) assert.deepEqual(u.def.raw.trait.bb, { atk_scale: 1.1 }, label(f));
if (y) assert.deepEqual(u.def.raw.trait.bb, { def_penetrate_fixed: 100 }, label(f));
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
const d = h.spawn('enemy_def', { pos: [9, 12] });
approx(h.b.dealDamage(u, d, { amount: 1000, type: 'phys', canDodge: false }), y ? 800 : 700, `${label(f)}: DEF 300 ${y ? '− 100' : ''}`);
done(h);
}
// the mine of an ART-X W: ×1.1 on the blocked enemy within its blast
for (const [tier, mod] of [[5, X], [6, X], [6, Y]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 1, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 9, col: 7, dir: 'LEFT' }] });
const blockedE = h.spawn('enemy_dummy', { pos: [9, 7] });
const free = h.spawn('enemy_dummy', { pos: [10, 7] });
h.step();
assert.ok(blockedE.blockedBy && !free.blockedBy);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 4));
const [m] = minesOf(h, u);
assert.deepEqual([m.tileR, m.tileC], [10, 7], `T${tier} ${mod}: the free tile under an enemy`);
assert.equal(m.def.traitBb.atk_scale ?? 1, mod === X ? 1.1 : 1, `T${tier} ${mod}: the token's trait`);
assert.ok(h.runUntil(() => !m.alive, 3));
const amt = (e) => from(h, m).find((c) => c.target === e)?.amount;
approx(amt(blockedE), amt(free) * (mod === X ? 1.1 : 1), `T${tier} ${mod}: ×1.1 on the blocked one`);
done(h);
}
});
+309
View File
@@ -0,0 +1,309 @@
// test/content/op_phenxi.test.js — the 自选 operator kit of 菲亚梅塔 (char_300_phenxi, 6★ 炮手; kit
// server/sim/content/kits/ops/op-phenxi.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, ART-Y
// “‘律外’特种弹药配给组”, ART-X “漫长的旅途” or ISW-A “菲亚梅塔特限证章” at stage 1 (tier 5) / 3 (tier 6). Every number is read
// back from data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_phenxi.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 PX = 'char_300_phenxi';
const FORMS = BACKUPS.units[PX].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const Y = 'uniequip_002_phenxi', X = 'uniequip_003_phenxi', ISW = 'uniequip_004_phenxi';
const S1 = 'skchr_phenxi_1', S2 = 'skchr_phenxi_2', S3 = 'skchr_phenxi_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_def: dummy('enemy_def', { def: 300 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, Y, X, ISW].map((m) => [t, true, m]))];
const P1 = 'phenxi_t_1[peak_1].peak_performance', P2 = 'phenxi_t_1[peak_2].peak_performance';
/** 菲亚梅塔 as uid 1 at (10, 4) facing RIGHT (3-10: rows 9–11, columns 4–8; her 4th tile ahead is (10, 8)). `pin` keeps
* her HP full at the end of every tick (陈述苦难's 流失 would move 精力充沛 under exact damage checks). */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5, pin = false } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'hit'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: PX, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
const u = h.unit(1);
if (pin) h.b.on('tick', () => { if (u.alive) u.hp = u.s.maxHp; });
h.step();
return { h, u };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src && !c.dmg.tags?.includes('hpLoss'));
const on = (h, e) => h.hooksOf('damaged').filter((c) => c.target === e);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** The talents a form fights with: the base ones under the 集成战略 module. */
const talentsOf = (tier, elite, mod) => {
const form = formOf(tier, elite);
const m = elite ? modOf(tier, mod) : null;
if (!m || mod === ISW) return form.talents;
return form.talents.map((t) => {
const ch = m.talentChanges.find((c) => c.talentIndex === t.index);
return ch ? { ...t, bb: { ...t.bb, ...ch.bb } } : t;
});
};
test('菲亚梅塔 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-10, ranged physical 炮手 (splash 1.0), hits air, blocks 1, ground-targetable, the data triggers; offered as a pick', () => {
assert.equal(OPERATOR_KITS[PX], KITS[PX]);
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], [PX, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat, u.profile.splashRadius, u.profile.sub],
[1, 'ranged', true, 'phys', 2.8, 1, 'aoesniper'], `${label(f)}: 炮手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-10`);
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
assert.deepEqual(formOf(6, true).skills.map((s) => [s.skillType, s.trigger.rule]), [['MANUAL', 'ACTIVE_RANGE'], ['MANUAL', 'DEFAULT'], ['MANUAL', 'DEFAULT']]);
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], [1540, 671, 1796, 797]);
assert.deepEqual([Y, X, ISW].flatMap((id) => [modOf(5, id).attr, modOf(6, id).attr]),
[{ atk: 48, def: 26 }, { atk: 70, def: 43 }, { atk: 60, def: 17 }, { atk: 85, def: 32 }, { maxHp: 85, atk: 65 }, { maxHp: 115, atk: 90 }]);
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(PX) && [5, 6].every((t) => diyPool(t, { data, kitted: KITTED_CHARS }).includes(PX)));
assert.equal(validateDiyPicks({ [SLOT[5]]: { charId: PX, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }).ok, true);
});
test('T1 陈述苦难: every 0.1 s a 流失 of ⌈0.5 % of her current HP⌉ (no hit, no SP), never below 1; ART-X stage 3: none at or under 50.3 %; ISW-A: the base talent (its 集成战略 0.2 s / 0.2 % floor ignored)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const t0 = talentsOf(tier, elite, mod)[0].bb;
const x3 = elite && mod === X && tier === 6;
assert.deepEqual([t0.interval, t0.hp_ratio, t0.min_hp_ratio ?? 0], [0.1, 0.005, x3 ? 0.503 : 0], label(f));
const { h, u } = field({ tier, elite, mod, skill: 2 });
if (elite && mod === ISW && tier === 6) assert.equal(u.def.raw.talents.find((t) => t.index === 0).bb.interval, 0.2, 'the composed record carries the 集成战略 blackboard');
h.run(1.02);
const loss = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg.tags?.includes('phenxi:bleed'));
assert.equal(loss.length, 10, `${label(f)}: ten in a second`);
let hp = u.s.maxHp;
for (const c of loss) {
const want = Math.ceil(hp * 0.005 - 1e-9);
assert.equal(c.amount, want, `${label(f)}: ⌈0.5 % of ${hp}⌉`);
assert.ok(c.dmg.tags.includes('hpLoss') && c.dmg.noSp, `${label(f)}: a 流失`);
hp -= want;
}
assert.equal(h.hooksOf('hit').filter((c) => c.target === u).length, 0, `${label(f)}: no hit`);
approx(u.hp, hp, `${label(f)}: HP`);
h.run(120);
if (x3) {
assert.ok(u.hpRatio > 0.5 && u.hpRatio <= 0.503, `${label(f)}: stops just above 50 % (${u.hpRatio})`);
const n = h.hooksOf('damaged').filter((c) => c.target === u).length;
h.run(2);
assert.equal(h.hooksOf('damaged').filter((c) => c.target === u).length, n, `${label(f)}: no more`);
} else assert.equal(u.hp, 1, `${label(f)}: down to 1, never 0`);
assert.ok(u.alive, label(f));
done(h);
}
});
test('T1 精力充沛: ATK +25 % above 50 % HP, +50 % above 80 % (ART-X stage 3: +30 % / +60 %; ISW-A: +25 % / +50 %, not its 集成战略 double)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const t0 = talentsOf(tier, elite, mod)[0].bb;
const x3 = elite && mod === X && tier === 6;
assert.deepEqual([t0[`${P1}.atk`], t0[`${P1}.hp_ratio`], t0[`${P2}.atk`], t0[`${P2}.hp_ratio`]], x3 ? [0.3, 0.5, 0.6, 0.8] : [0.25, 0.5, 0.5, 0.8], label(f));
const { h, u } = field({ tier, elite, mod, skill: 2 });
const at = (ratio) => {
u.hp = u.s.maxHp * ratio;
h.step();
return u.findBuff('talent:phenxi:peak')?.mods.atkPct ?? 0;
};
assert.equal(at(1), t0[`${P2}.atk`], `${label(f)}: full HP`);
approx(u.s.atk, u.base.atk * (1 + t0[`${P2}.atk`]), `${label(f)}: ATK`);
assert.equal(at(0.79), t0[`${P1}.atk`], `${label(f)}: under 80 %`);
assert.equal(at(0.49), 0, `${label(f)}: under 50 %`);
approx(u.s.atk, u.base.atk, `${label(f)}: none`);
done(h);
}
});
test('T2 宣告终局: ASPD +27 outside a running skill, none while S1 / S3 run (S2 is instant); ART-Y stage 3: +30 outside, +10 inside', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const t1 = talentsOf(tier, elite, mod)[1].bb;
const y3 = elite && mod === Y && tier === 6;
assert.deepEqual([t1.attack_speed, t1['phenxi_e_t_2[in_skill].attack_speed'] ?? 0], y3 ? [30, 10] : [27, 0], label(f));
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const own = formOf(tier, elite).stats.aspd + (elite ? modOf(tier, mod)?.attr.aspd ?? 0 : 0);
assert.equal(u.s.aspd, own + t1.attack_speed, `${label(f)} S${skill + 1}: outside`);
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 6), `${label(f)} S${skill + 1}: cast`);
h.step();
assert.equal(u.s.aspd, own + (skill === 1 ? t1.attack_speed : (t1['phenxi_e_t_2[in_skill].attack_speed'] ?? 0)), `${label(f)} S${skill + 1}: while it runs`);
if (skill === 0) {
u.skill.end('test');
h.step();
assert.equal(u.s.aspd, own + t1.attack_speed, `${label(f)}: back after S1`);
}
done(h);
}
}
});
test('S1 “你须直面” (MANUAL, attack SP 17 / 14 from 5, 30 s, data ACTIVE_RANGE on 3-10 + 1): ATK +45 % / +60 %; since the 2026-08 client her attacks pick ground enemies only (the splash still hits air) and her range stays 3-10', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.spType, sk.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.ability_range_forward_extend], ['INCREASE_WHEN_ATTACK', elite ? 14 : 17, 5, 30, elite ? 0.6 : 0.45, 1], `T${tier}`);
assert.equal(sk.trigger.customRangeGrid.length, formOf(tier, elite).rangeGrid.length + 3, `T${tier}: the data's trigger grid is the +1 range`);
const { h, u } = field({ tier, elite, skill: 0, pin: true });
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spType], ['duration', 'ACTIVE_RANGE', 'attack'], `T${tier}`);
const far = h.spawn('enemy_dummy', { pos: [10, 9] }); // 5 tiles ahead: on the trigger grid, outside 3-10
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: an enemy on the +1 column casts it (the data rule)`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
assert.deepEqual(u.findBuff(`skill:${u.id}`)?.mods, { atkPct: sk.bb.atk }, `T${tier}: ATK +${sk.bb.atk * 100} %`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: her range stays 3-10`);
h.run(6);
assert.equal(on(h, far).length, 0, `T${tier}: 5 tiles ahead stays out of reach`);
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
h.run(6);
assert.equal(on(h, fly).length, 0, `T${tier}: no air target while it runs`);
const g = h.spawn('enemy_dummy', { pos: [11, 7] });
assert.ok(h.runUntil(() => on(h, g).length > 0, 4), `T${tier}: a ground target`);
h.run(0.3);
const id = on(h, g)[0].dmg.attackId;
assert.ok(on(h, fly).some((c) => c.dmg.attackId === id && c.dmg.isSplash), `T${tier}: her shell still splashes the flyer 1.0 away`);
for (const c of from(h, u).filter((c) => c.dmg.attackId === id)) approx(c.amount, u.base.atk * (1 + sk.bb.atk + u.findBuff('talent:phenxi:peak').mods.atkPct), `T${tier}: ATK`);
u.skill.end('test');
h.b.kill(g, null);
assert.ok(h.runUntil(() => on(h, fly).some((c) => !c.dmg.isSplash), 4), `T${tier}: flyers are targets again once it ends`);
done(h);
}
});
test('S2 “你须愧悔” (MANUAL, attack SP 11 / 10, data DEFAULT): the attack it is cast for fires the 灼痕弹 instead — 260 % / 320 % within 1.5 of her 4th tile ahead ≈ 0.5 s later, a 灼痕 every 0.66 tiles bursting 0.8 s after it was left for 130 % / 160 % within 1.1, air too; cast every 12th attack', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.spType, sk.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.atk_scale_2, sk.bb.dist], ['INCREASE_WHEN_ATTACK', elite ? 10 : 11, 0, elite ? 3.2 : 2.6, elite ? 1.6 : 1.3, 0.66], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1, pin: true });
assert.deepEqual([u.skill.kind, u.skill.rule], ['instant', 'DEFAULT'], `T${tier}`);
const A = h.spawn('enemy_dummy', { pos: [10, 8] }); // the end point; 灼痕 5 (7.30) and 6 (7.96) within 1.1
const B = h.spawn('enemy_dummy', { pos: [11, 8] }); // 1.0 from the end; 灼痕 6 within 1.1
const M = h.spawn('enemy_dummy', { pos: [11, 6] }); // only 灼痕 3 (5.98) within 1.1
const F = h.spawn('enemy_fly', { pos: [9, 6] }); // the same, an air unit
const C = h.spawn('enemy_dummy', { pos: [9, 9.4] }); // 1.72 from the end, 1.75 from 灼痕 6
u.skill.gainSp(999);
const a0 = u.stats.attacks;
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `T${tier}: cast`);
const t0 = h.b.time;
assert.equal(u.stats.attacks, a0, `T${tier}: that attack was the cast`);
h.run(1.6);
const sk2 = (c) => c.dmg.tags?.includes('phenxi:s2') ? 'end' : c.dmg.tags?.includes('phenxi:mark') ? 'mark' : 'attack';
const scales = (e) => on(h, e).filter((c) => c.source === u && sk2(c) !== 'attack' && c.t <= t0 + 1.6).map((c) => [sk2(c), Math.round(c.amount / u.s.atk * 1000) / 1000]).sort();
const end = Math.round(sk.bb.atk_scale * 1000) / 1000, mark = Math.round(sk.bb.atk_scale_2 * 1000) / 1000;
assert.deepEqual(scales(A), [['end', end], ['mark', mark], ['mark', mark]], `T${tier}: A`);
assert.deepEqual(scales(B), [['end', end], ['mark', mark]], `T${tier}: B`);
assert.deepEqual(scales(M), [['mark', mark]], `T${tier}: M`);
assert.deepEqual(scales(F), [['mark', mark]], `T${tier}: the flyer`);
assert.deepEqual(scales(C), [], `T${tier}: C`);
const endHit = on(h, A).find((c) => sk2(c) === 'end');
assert.ok(Math.abs(endHit.t - t0 - 0.5) < 0.07, `T${tier}: the shell bursts ≈ 0.5 s later (${endHit.t - t0})`);
const mHit = on(h, M).find((c) => sk2(c) === 'mark');
assert.ok(Math.abs(mHit.t - t0 - (3 * 0.66 / 8 + 0.8)) < 0.07, `T${tier}: 灼痕 3 bursts 0.8 s after it was left (${mHit.t - t0})`);
assert.ok(on(h, A).every((c) => c.dmg.type === 'phys'), `T${tier}: physical`);
// attack SP: the next cast after spCost more attacks
const n1 = u.stats.attacks;
assert.ok(h.runUntil(() => u.skill.activations === 2, 60), `T${tier}: cast again`);
assert.equal(u.stats.attacks - n1, sk.spCost, `T${tier}: after ${sk.spCost} attacks`);
done(h);
}
});
test('S3 “你须偿还” (MANUAL, attack SP 22 / 19, data DEFAULT, a toggle): 持续攻击 — every attack interval a shell on her 4th tile ahead, enemy or not: 110 % / 115 % within 2.0, 170 % / 185 % within 1.1, air too; it never ends; a stun holds it', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.spCost, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@atk_scale_2'], sk.bb['attack@dist'], sk.bb.base_attack_time],
[elite ? 19 : 22, -1, elite ? 1.85 : 1.7, elite ? 1.15 : 1.1, 1.1, 0], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2, pin: true });
assert.deepEqual([u.skill.kind, u.skill.rule], ['toggle', 'DEFAULT'], `T${tier}`);
const core = h.spawn('enemy_dummy', { pos: [10, 8] });
const ring = h.spawn('enemy_dummy', { pos: [11, 8] }); // 1.0: the centre circle
const outer = h.spawn('enemy_dummy', { pos: [10, 9.5] }); // 1.5
const fly = h.spawn('enemy_fly', { pos: [9, 8.5] }); // 1.118: outside the centre circle
const far = h.spawn('enemy_dummy', { pos: [11, 10] }); // 2.24
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const a0 = u.stats.attacks;
h.run(20);
assert.ok(u.skill.active, `T${tier}: 持续时间无限`);
const shots = u.stats.attacks - a0;
assert.equal(shots, Math.floor(20 / u.s.interval) + 1, `T${tier}: one shell at once, then one per attack interval (${u.s.interval} s)`);
const s3 = (e) => on(h, e).filter((c) => c.dmg.tags?.includes('phenxi:s3'));
for (const [e, sc] of [[core, sk.bb['attack@atk_scale']], [ring, sk.bb['attack@atk_scale']], [outer, sk.bb['attack@atk_scale_2']], [fly, sk.bb['attack@atk_scale_2']]]) {
assert.ok(s3(e).length >= shots - 1, `T${tier}: ${e.defId} hit by every shell`);
for (const c of s3(e)) {
approx(c.amount, u.s.atk * sc, `T${tier}: ×${sc}`);
assert.deepEqual([c.type, c.dmg.isAttack, c.dmg.isSkill], ['phys', true, true], `T${tier}: her attack`);
}
}
assert.equal(s3(far).length, 0, `T${tier}: 2.24 away untouched`);
assert.equal(from(h, u).filter((c) => !c.dmg.tags?.includes('phenxi:s3') && c.t > h.b.time - 19).length, 0, `T${tier}: no normal attack meanwhile`);
for (const e of [core, ring, outer, fly, far]) h.b.kill(e, null);
const a1 = u.stats.attacks;
h.run(6);
assert.ok(u.stats.attacks - a1 >= 2, `T${tier}: no enemy, still firing`);
h.b.applyStatus(u, 'stun', { duration: 6, force: true });
const a2 = u.stats.attacks;
h.run(5.5);
assert.equal(u.stats.attacks, a2, `T${tier}: a stun holds it`);
h.run(3);
assert.ok(u.stats.attacks > a2, `T${tier}: and it goes on`);
done(h);
}
});
test('ART-X trait: ×1.1 on her damage to a blocked enemy (attack, splash, S2 blasts), stages 1 and 3; ART-Y trait: ignores 100 DEF; ISW-A: attributes only', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x = elite && mod === X, y = elite && mod === Y;
const { h, u } = field({ tier, elite, mod, skill: 2, pin: true, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 8, dir: 'LEFT' }] });
const blockedE = h.spawn('enemy_dummy', { pos: [10, 8] });
const free = h.spawn('enemy_dummy', { pos: [11, 8] });
h.step();
assert.ok(blockedE.blockedBy, `${label(f)}: blocked`);
assert.ok(h.runUntil(() => from(h, u).some((c) => c.target === free), 6), `${label(f)}: an attack`);
const id = from(h, u).find((c) => c.target === free).dmg.attackId;
const amt = (e) => from(h, u).find((c) => c.dmg.attackId === id && c.target === e)?.amount;
approx(amt(free), u.s.atk, `${label(f)}: not blocked`);
approx(amt(blockedE), u.s.atk * (x ? 1.1 : 1), `${label(f)}: blocked`);
assert.deepEqual(u.def.raw.trait.bb, x ? { atk_scale: 1.1 } : y ? { def_penetrate_fixed: 100 } : {}, `${label(f)}: trait bb`);
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
const d = h.spawn('enemy_def', { pos: [9, 12] });
approx(h.b.dealDamage(u, d, { amount: 1000, type: 'phys', canDodge: false }), y ? 800 : 700, `${label(f)}: DEF 300`);
done(h);
}
// S2's blasts on a blocked enemy (ART-X stage 3)
const { h, u } = field({ tier: 6, elite: true, mod: X, skill: 1, pin: true, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 8, dir: 'LEFT' }] });
const blockedE = h.spawn('enemy_dummy', { pos: [10, 8] });
const free = h.spawn('enemy_dummy', { pos: [11, 8] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 4));
h.run(1);
const endOf = (e) => on(h, e).find((c) => c.dmg.tags?.includes('phenxi:s2'))?.amount;
approx(endOf(blockedE), endOf(free) * 1.1, 'the shell ×1.1 on the blocked one');
done(h);
});
+319
View File
@@ -0,0 +1,319 @@
// test/content/op_wisdel.test.js — the 自选 operator kit of 维什戴尔 (char_1035_wisdel, 6★ 投掷手; kit
// server/sim/content/kits/ops/op-wisdel.js) and of her summon “魂灵之影” (token_10035_wisdel_wward), fielded the production
// way (a DIY slot + its `diy` pick, simdata getDiy) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module)
// and elite (E2 Lv60, rank 7) with no module or BOM-X “‘祖宗发射器’” at stage 1 (tier 5) / 3 (tier 6). Every number is read
// back from data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_wisdel.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 WD = 'char_1035_wisdel';
const SHADOW = 'token_10035_wisdel_wward';
const FORMS = BACKUPS.units[WD].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const BOM = 'uniequip_002_wisdel';
const S1 = 'skchr_wisdel_1', S2 = 'skchr_wisdel_2', S3 = 'skchr_wisdel_3';
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusOf(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 shadowOf = (tier, elite) => BACKUPS.tokens[SHADOW].variants[`${WD}@${statusOf(tier, elite)}`];
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_atk: dummy('enemy_atk', { atk: 100, range: 3 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BOM].map((m) => [t, true, m]))];
/** 维什戴尔 as uid 1 at (10, 4) facing RIGHT (3-9: rows 8–12; (10, 4..8), (9 / 11, 4..7), (8 / 12, 4..6)). */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 1200, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'heal'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: WD, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
const on = (h, e) => h.hooksOf('damaged').filter((c) => c.target === e);
const shadowsOf = (h, u) => h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === SHADOW && t.ownerUnit === u);
const tag = (c, t) => !!c.dmg.tags?.includes(t);
const markKey = (u) => `wisdel:mark:${u.id}`;
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const t0Of = (tier, elite, mod) => (elite && mod === BOM && tier === 6 ? modOf(6, BOM).talentChanges[0].bb : formOf(tier, elite).talents[0].bb);
test('维什戴尔 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-9, 投掷手 (ground-only 0.9 splash, never air), blocks 1, the data triggers, a 魂灵之影 beside her at once; offered as a pick', () => {
assert.equal(OPERATOR_KITS[WD], KITS[WD]);
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], [WD, 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.aspd], [form.stats.maxHp, form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res, 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.groundOnly, u.profile.dmgType, u.base.bat, u.profile.splashRadius, u.profile.sub],
[1, 'ranged', false, true, 'phys', 2.1, 0.9, 'bombarder'], `${label(f)}: 投掷手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-9`);
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
const sh = shadowsOf(h, u);
assert.deepEqual(sh.map((t) => [t.tileR, t.tileC]), [[9, 4]], `${label(f)}: a shadow on the nearest free tile of her range (lower row first)`);
assert.ok(u.s.flags.camou && !u.s.flags.liftoff && !u.s.flags.stealth, `${label(f)}: 迷彩 beside it, ground-targetable otherwise`);
done(h);
}
}
assert.deepEqual(formOf(6, true).skills.map((s) => [s.skillType, s.trigger.rule, s.trigger.rawRule]), [['AUTO', 'DEFAULT', 'DEFAULT'], ['MANUAL', 'DEFAULT', 'DEFAULT'], ['MANUAL', 'SP_FULL', 'ALWAYS']]);
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], [1434, 583, 1735, 652]);
assert.deepEqual([modOf(5, BOM).attr, modOf(6, BOM).attr], [{ atk: 45, aspd: 5 }, { atk: 65, aspd: 7 }]);
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(WD) && [5, 6].every((t) => diyPool(t, { data, kitted: KITTED_CHARS }).includes(WD)));
assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: WD, skillIndex: 2, uniEquipId: BOM } }, { data, kitted: KITTED_CHARS }).ok, true);
});
test('trait 投掷手 + T1 好礼: the main target ×1.15 (BOM-X stage 3 ×1.25), the 0.9 splash in full, then 0.3 s later an aftershock at 50 % ATK on the spot (×1.15 on the main one); BOM-X: two; flyers never hit; her 残影 on the main target', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const t0 = t0Of(tier, elite, mod);
const shocks = elite && mod ? 2 : 1;
assert.deepEqual([t0['attack@main_atk_scale'], t0['attack@bomb_atk_scale']], elite && mod === BOM && tier === 6 ? [1.25, 1.75] : [1.15, 1.5], label(f));
assert.equal(formOf(tier, elite).trait.bb['attack@append_atk_scale'], 0.5, label(f));
if (elite && mod) assert.equal(modOf(tier, mod).traitOverride.bb['attack@enable_third_attack'], 1, label(f));
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 21 });
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
const sp = h.spawn('enemy_dummy', { pos: [10, 8.85] }); // 0.85: her splash, outside her range
const out = h.spawn('enemy_dummy', { pos: [11, 8.75] }); // √(1 + 0.5625) = 1.25
const fly = h.spawn('enemy_fly', { pos: [9, 8] }); // in her range, an air unit
assert.ok(h.runUntil(() => on(h, main).length > 0, 4), `${label(f)}: an attack`);
const t = h.b.time;
h.run(0.8);
const atk = u.s.atk;
const ms = t0['attack@main_atk_scale'];
const plain = (c) => !tag(c, 'wisdel:bomb');
const first = from(h, u).filter((c) => plain(c) && c.t <= t + 1e-9);
assert.deepEqual(new Set(first.map((c) => c.target)), new Set([main, sp]), `${label(f)}: the main target and the splash`);
approx(first.find((c) => c.target === main).amount, atk * ms, `${label(f)}: main ×${ms}`);
approx(first.find((c) => c.target === sp).amount, atk, `${label(f)}: splash in full`);
const shockHits = from(h, u).filter((c) => plain(c) && tag(c, 'aftershock') && c.t <= t + 0.75);
assert.equal(shockHits.filter((c) => c.target === main).length, shocks, `${label(f)}: ${shocks} aftershock(s)`);
for (const c of shockHits) approx(c.amount, atk * 0.5 * (c.target === main ? ms : 1), `${label(f)}: 50 %`);
assert.ok(Math.abs(shockHits[0].t - t - 0.3) < 0.04, `${label(f)}: 0.3 s later`);
assert.deepEqual([on(h, out).length, on(h, fly).length], [0, 0], `${label(f)}: 1.25 away and the flyer untouched`);
assert.ok(main.findBuff(markKey(u)) || on(h, main).some((c) => tag(c, 'wisdel:bomb')), `${label(f)}: marked (or the mark went off)`);
done(h);
}
});
test('T1 好礼: each aftershock on a marked enemy goes off 15 % of the time — 150 % (175 %) ATK physical to every enemy within 1.1, air too, stun 1 s, the mark spent; the 残影 leave with her', () => {
for (const f of [[5, false, null], [6, true, BOM]]) {
const [tier, elite, mod] = f;
const t0 = t0Of(tier, elite, mod);
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 33 });
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
const fly = h.spawn('enemy_fly', { pos: [10, 9] }); // 1.0 from the main target: only the explosion reaches it
h.run(400);
const bombs = from(h, u).filter((c) => tag(c, 'wisdel:bomb') && c.target === main);
const rolls = from(h, u).filter((c) => tag(c, 'aftershock') && c.target === main).length;
assert.ok(rolls > 150, `${label(f)}: ${rolls} rolls`);
const r = bombs.length / rolls;
assert.ok(r > 0.09 && r < 0.21, `${label(f)}: ${bombs.length} / ${rolls} ≈ 15 %`);
for (const c of bombs) assert.equal(c.type, 'phys', label(f));
const flyBombs = on(h, fly).filter((c) => tag(c, 'wisdel:bomb'));
assert.equal(flyBombs.length, bombs.length, `${label(f)}: the flyer 1.0 away takes every explosion`);
assert.equal(on(h, fly).length, flyBombs.length, `${label(f)}: and nothing else`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === fly);
assert.equal(stuns.length, bombs.length, `${label(f)}: each explosion stuns`);
for (const c of stuns) approx(c.duration, t0['attack@stun'], `${label(f)}: 1 s`);
// the amounts: her ATK × bomb_atk_scale (the explosion is no attack: no ×main)
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
h.run(2);
main.buffs = main.buffs.filter((b) => b.key !== markKey(u));
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
h.b.addBuff(e, { key: markKey(u), source: u });
h.b.removeBuff(u, 'test:disarm');
assert.ok(h.runUntil(() => on(h, e).some((c) => tag(c, 'wisdel:bomb')), 200), `${label(f)}: it goes off on a marked one`);
const b = on(h, e).find((c) => tag(c, 'wisdel:bomb'));
approx(b.amount, u.s.atk * t0['attack@bomb_atk_scale'], `${label(f)}: ×${t0['attack@bomb_atk_scale']}`);
assert.ok(!e.findBuff(markKey(u)) || on(h, e).filter((c) => c.t > b.t && !tag(c, 'wisdel:bomb')).length > 0, `${label(f)}: spent (or re-marked by a later attack)`);
h.b.addBuff(e, { key: markKey(u), source: u });
h.b.retreat(u);
assert.ok(!e.findBuff(markKey(u)) && !main.findBuff(markKey(u)), `${label(f)}: her 残影 leave with her`);
done(h);
}
});
test('T2 死魂灵的余息: a 魂灵之影 (the form\'s stats, 禁疗, no attack, attackable) at each deployment on the nearest free tile of her range, at most 3; 迷彩 while one that came in on her x-5 stands; the shadows stay when she leaves', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const v = shadowOf(tier, elite);
assert.deepEqual([v.stats.maxHp, v.stats.atk, v.stats.def, v.stats.res, v.stats.respawnTime, v.stats.deployLimit], [3500, elite ? 738 : 660, elite ? 628 : 585, 50, 5, 3], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
const [t] = shadowsOf(h, u);
assert.deepEqual([t.base.maxHp, t.base.atk, t.base.def, t.base.res, t.s.blockCnt, !!t.s.flags.noHeal, !!t.s.flags.untargetable, t.profile.noAttack],
[3500, v.stats.atk, v.stats.def, 50, 0, true, false, true], `T${tier}: stats, 禁疗, attackable, no attack`);
assert.ok(u.findBuff(`wisdel:camou:${t.id}`) && u.s.flags.camou, `T${tier}: 迷彩`);
// a ranged enemy in reach: she is not its target while camouflaged and not blocking
const shooter = h.spawn('enemy_atk', { pos: [10, 6] });
h.run(4);
assert.ok(!h.hooksOf('damaged').some((c) => c.source === shooter && c.target === u), `T${tier}: 迷彩: the shooter does not pick her`);
h.b.kill(shooter, null);
h.b.kill(t, null);
assert.ok(!u.s.flags.camou, `T${tier}: gone with its shadow`);
for (let i = 0; i < 4; i++) {
h.b.retreat(u);
h.run(0.2);
assert.ok(h.b.redeploy(u, { free: true }), `T${tier}: redeploy ${i}`);
h.run(0.2);
}
assert.equal(shadowsOf(h, u).filter((s) => s.alive).length, 3, `T${tier}: at most 3 (they stay when she leaves)`);
done(h);
}
// no free tile next to her: the shadow lands on the next nearest (lower row first) and gives no 迷彩
const others = [[9, 4], [11, 4], [10, 5]].map(([r, c], i) => ({ uid: 10 + i, chessId: 'chess_char_1_02_a', row: r, col: c }));
const { h, u } = field({ skill: 1, others });
assert.deepEqual(shadowsOf(h, u).map((t) => [t.tileR, t.tileC]), [[9, 5]], 'the nearest free one: √2 away, lower row first');
assert.ok(!u.s.flags.camou, 'not on its x-5: no 迷彩');
done(h);
});
test('魂灵之影\'s skill: every 5 SP (time; +0–2 after each cast) a ground enemy of 维什戴尔\'s range — unmarked first — takes its ATK as arts damage, 停顿 1 s and her 残影; never an air unit', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const v = shadowOf(tier, elite);
assert.deepEqual([v.skill.skillType, v.skill.spType, v.skill.spCost, v.skill.bb], ['AUTO', 'INCREASE_WITH_TIME', 5, { sluggish: 1, sp_min: 0, sp_max: 3 }], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } });
const [t] = shadowsOf(h, u);
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
h.run(8);
assert.deepEqual([t.skill.activations, on(h, fly).length], [0, 0], `T${tier}: an air unit only: nothing, SP kept`);
assert.equal(t.skill.charges, 1, `T${tier}`);
const a = h.spawn('enemy_dummy', { pos: [10, 7] });
const b = h.spawn('enemy_dummy', { pos: [11, 7] });
const outside = h.spawn('enemy_dummy', { pos: [10, 9] }); // outside her 3-9
h.b.addBuff(a, { key: markKey(u), source: u }); // already marked
h.step();
assert.equal(t.skill.activations, 1, `T${tier}: cast at once`);
const hit = from(h, t);
assert.deepEqual(hit.map((c) => c.target), [b], `T${tier}: the unmarked one`);
approx(hit[0].amount, t.s.atk, `T${tier}: its ATK`);
assert.deepEqual([hit[0].type, hit[0].dmg.isSkill], ['arts', true], `T${tier}: arts`);
assert.ok(b.findBuff(markKey(u)), `T${tier}: her 残影`);
const slow = h.hooksOf('statusApplied').find((c) => c.source === t && c.target === b);
assert.deepEqual([slow?.status, slow?.duration], ['sluggish', 1], `T${tier}: 停顿 1 s`);
h.run(60);
const casts = t.skill.activations;
assert.ok(casts >= 60 / 5 + 1 && casts <= 60 / 3 + 2, `T${tier}: 5 SP a cast, 0–2 back each time (${casts} in 60 s)`);
assert.equal(on(h, outside).length, 0, `T${tier}: outside her range: never`);
done(h);
}
});
test('S1 定点清算 (AUTO, attack SP 4 / 3, data DEFAULT): the next attack splashes 1.1, makes 2 more aftershocks at 75 % / 90 % ATK and stuns every enemy it hits 0.5 / 1 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S1), t0 = t0Of(tier, elite, mod);
assert.deepEqual([sk.spType, sk.spCost, sk.bb.append_atk_scale, sk.bb.stun_duration], ['INCREASE_WHEN_ATTACK', elite ? 3 : 4, elite ? 0.9 : 0.75, elite ? 1 : 0.5], label(f));
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 2 });
assert.deepEqual([u.skill.kind, u.skill.rule], ['instant', 'DEFAULT'], label(f));
h.b.addBuff(shadowsOf(h, u)[0], { key: 'test:silence', flags: { silence: true } });
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
const sp = h.spawn('enemy_dummy', { pos: [10, 9.05] }); // 1.05: only the 1.1 splash
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `${label(f)}: cast on her next attack`);
h.run(2);
const atk = u.s.atk;
const hits = from(h, u).filter((c) => !tag(c, 'wisdel:bomb') && c.dmg.isSkill);
const n = (elite && mod ? 2 : 1) + 2;
assert.equal(hits.filter((c) => c.target === main && tag(c, 'aftershock')).length, n, `${label(f)}: ${n} aftershocks`);
for (const c of hits.filter((x) => tag(x, 'aftershock'))) approx(c.amount, atk * sk.bb.append_atk_scale * (c.target === main ? t0['attack@main_atk_scale'] : 1), `${label(f)}: ${sk.bb.append_atk_scale * 100} %`);
assert.ok(hits.some((c) => c.target === sp && !tag(c, 'aftershock')), `${label(f)}: the 1.1 splash`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === sp);
assert.ok(stuns.length >= 1 && stuns.every((c) => Math.abs(c.duration - sk.bb.stun_duration) < 1e-9), `${label(f)}: stunned ${sk.bb.stun_duration} s`);
done(h);
}
});
test('S2 饱和复仇 (MANUAL, 32 / 29 SP from 15, 25 s, data DEFAULT): ATK +16 % / +25 %, interval −0.5 / −0.7 s, 3 targets; 过载 the second half: every attack 4 rounds of 65 % / 70 % on random enemies of her range', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb['attack@atk_scale_ol']], elite ? [29, 15, 25, 0.25, -0.7, 0.7] : [32, 15, 25, 0.16, -0.5, 0.65], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1, seed: 8 });
h.b.addBuff(shadowsOf(h, u)[0], { key: 'test:silence', flags: { silence: true } });
assert.deepEqual([u.skill.kind, u.skill.rule], ['duration', 'DEFAULT'], `T${tier}`);
for (const p of [[10, 6], [11, 6], [9, 6], [12, 5]]) h.spawn('enemy_dummy', { pos: p });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const ts = h.b.time;
approx(u.s.interval, 2.1 + sk.bb.base_attack_time, `T${tier}: interval`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.run(26);
const mains = from(h, u).filter((c) => c.dmg.isAttack && !c.dmg.isSplash && c.dmg.isSkill);
const early = mains.filter((c) => c.t < ts + 12.4), late = mains.filter((c) => c.t > ts + 12.6 && c.t < ts + 25);
const byAttack = (list) => { const m = new Map(); for (const c of list) m.set(c.dmg.attackId, [...(m.get(c.dmg.attackId) ?? []), c]); return [...m.values()]; };
for (const g of byAttack(early)) {
assert.equal(new Set(g.map((c) => c.target)).size, 3, `T${tier}: 3 targets at once`);
for (const c of g) approx(c.amount, u.base.atk * (1 + sk.bb.atk) * 1.15, `T${tier}: ×1.15 on each main target`);
}
const lg = byAttack(late);
assert.ok(lg.length >= 5, `T${tier}: overload attacks`);
for (const g of lg) {
assert.equal(g.length, 4, `T${tier}: 4 rounds`);
for (const c of g) approx(c.amount, u.base.atk * (1 + sk.bb.atk) * sk.bb['attack@atk_scale_ol'] * 1.15, `T${tier}: ${sk.bb['attack@atk_scale_ol'] * 100} %`);
}
assert.ok(lg.some((g) => new Set(g.map((c) => c.target)).size < 4), `T${tier}: random picks (repeats happen)`);
assert.ok(new Set(lg.flatMap((g) => g.map((c) => c.target))).size === 4, `T${tier}: every enemy of her range drawn some time`);
assert.ok(!u.skill.active, `T${tier}: ends after 25 s`);
done(h);
}
});
test('S3 爆裂黎明 (MANUAL, data SP_FULL: at once, no enemy needed): 1 / 2 shadows (the first with 3 SP), ATK +120 % / +150 %, interval +2.9 s, 6 attacks at 175 % / 190 % with a 2.5 splash on air units too, 好礼 at 100 %', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, BOM]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.spCost, sk.initSp, sk.bb.max_cnt, sk.bb.atk, sk.bb.base_attack_time, sk.bb['attack@atk_scale_3'], sk.bb['attack@trigger_time'], sk.bb['attack@prob'], sk.bb.sp],
elite ? [58, 35, 2, 1.5, 2.9, 1.9, 6, 1, 3] : [64, 30, 1, 1.2, 2.9, 1.75, 6, 1, 3], `T${tier}`);
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 4 });
assert.deepEqual([u.skill.kind, u.skill.rule], ['ammo', 'SP_FULL'], `T${tier}`);
for (const t of shadowsOf(h, u)) h.b.addBuff(t, { key: 'test:silence', flags: { silence: true } });
u.skill.gainSp(999);
h.step();
assert.ok(u.skill.active, `T${tier}: cast at once, no enemy on the field`);
const sh = shadowsOf(h, u);
assert.equal(sh.length, 1 + sk.bb.max_cnt, `T${tier}: the talent's + ${sk.bb.max_cnt}`);
assert.ok(Math.floor(sh[1].skill.sp) === 3 && (sh.length < 3 || Math.floor(sh[2].skill.sp) === 0), `T${tier}: the first S3 shadow with 3 SP`);
approx(u.s.interval, (2.1 + 2.9) * 100 / u.s.aspd, `T${tier}: interval +2.9 s`);
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
assert.ok(h.runUntil(() => on(h, fly).length > 0, 2), `T${tier}: an air unit alone in her range is her target now`);
const g = h.spawn('enemy_dummy', { pos: [11, 8] }); // √2 from the flyer: her 2.5 splash, her aftershocks
const ring = h.spawn('enemy_fly', { pos: [11.5, 8.9] }); // 2.42 from the flyer, outside her range
h.b.addBuff(g, { key: markKey(u), source: u });
assert.ok(h.runUntil(() => !u.skill.active, 60), `T${tier}: 6 attacks and done`);
h.run(1);
const mains = from(h, u).filter((c) => c.dmg.isAttack && !c.dmg.isSplash);
assert.equal(new Set(mains.map((c) => c.dmg.attackId)).size, 6, `T${tier}: 6 attacks`);
// ATK +atk while it runs (the last shell lands after its 6th bullet ended it: the engine's ammo rule, skills.js)
const tEnd = h.hooksOf('skillEnd').find((c) => c.unit === u).t;
const atkAt = (t) => u.base.atk * (t <= tEnd + 1e-9 ? 1 + sk.bb.atk : 1);
const ms = t0Of(tier, elite, mod)['attack@main_atk_scale'];
for (const c of mains) approx(c.amount, atkAt(c.t) * sk.bb['attack@atk_scale_3'] * ms, `T${tier}: ×${sk.bb['attack@atk_scale_3']} × main`);
assert.ok(mains.filter((c) => c.t <= tEnd).length >= 5, `T${tier}: five land while it runs`);
assert.ok(mains.every((c) => c.target === fly), `T${tier}: the flyer was her target throughout`);
const splash = from(h, u).filter((c) => c.dmg.isSplash && !tag(c, 'aftershock') && !tag(c, 'wisdel:bomb'));
assert.ok(splash.some((c) => c.target === ring) && splash.some((c) => c.target === g), `T${tier}: the 2.5 splash reaches the air unit 2.42 away and the ground one`);
for (const c of splash) approx(c.amount, atkAt(c.t) * sk.bb['attack@atk_scale_3'], `T${tier}: splash in full`);
assert.ok(on(h, fly).every((c) => !tag(c, 'aftershock')) && on(h, ring).every((c) => !tag(c, 'aftershock')), `T${tier}: aftershocks only on ground enemies`);
const shocks = from(h, u).filter((c) => tag(c, 'aftershock') && c.target === g);
assert.equal(shocks.length, 6 * (elite && mod ? 2 : 1), `T${tier}: every aftershock reaches the ground one (it came before the first one)`);
for (const c of shocks) approx(c.amount, atkAt(c.t) * sk.bb['attack@atk_scale_3'] * 0.5, `T${tier}: 50 % of the boosted ATK`);
assert.ok(on(h, g).some((c) => tag(c, 'wisdel:bomb') && Math.abs(c.t - shocks[0].t) < 1e-9), `T${tier}: 好礼 at 100 %: the marked one went off on the first aftershock`);
done(h);
}
});
+524 -125
View File
@@ -2632,174 +2632,321 @@
"snaps": "d7a4c4b8 d1b3d92e 8cf50abd f866abf9 96ede988 8cec9f3d f62404fe 6dc40d5b 799f0eb4 1efd07dc 63b0fb0a 13f97919 2848c1c0 939ec9a5 8033448c 27fbb548 e861cf3a a7167207 6073a250 b79fb995 7957b19c"
},
"diy-065": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_b=demetr/S1 6_diy2_b=demetr/S2 6_diy1_b=demetr/S3 6_diy2_b=demetr/S1/002_demetr 6_diy1_b=demetr/S2/002_demetr 6_diy2_b=demetr/S3/002_demetr 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe",
"fast": true,
"about": "6_diy1_b=demetr/S1 6_diy2_b=demetr/S2 6_diy1_b=demetr/S3 6_diy2_b=demetr/S1/002_demetr 6_diy1_b=demetr/S2/002_demetr 6_diy2_b=demetr/S3/002_demetr 5_diy1_a=cqbw/S1 5_diy2_a=cqbw/S2 5_diy1_a=cqbw/S3 5_diy2_b=cqbw/S1 5_diy1_b=cqbw/S2 5_diy2_b=cqbw/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m03",
"modeId": "mode_multi_funny",
"round": 13,
"seed": 1168351812,
"waveId": "act1autochess_h06",
"time": 218.63,
"ticks": 6559,
"time": 201.93,
"ticks": 6058,
"reason": "cleared",
"killed": 62,
"killed": 98,
"total": 105,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 386,
"rngDraws": 315,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":62,"total":105,"perfect":false,"leaks":43,"leaksUncounted":0,"coins":0,"dmg":939462,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
"players": {"p1":{"killed":98,"total":105,"perfect":false,"leaks":7,"leaksUncounted":0,"coins":0,"dmg":1337096,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,159,1,7435,4256,0,51,1],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,144,0,8582,5816,0,37,1],
["p1",3,"op","chess_char_6_diy1_b",0,"none",0,0,126,2,30063,4932,0,76,15],
["p1",4,"op","chess_char_6_diy2_b",1,"none",0,0,119,7,150377,4932,0,150,3],
["p1",5,"op","chess_char_6_diy1_b",2,"none",2274,1,161,10,144561,2658,0,192,3],
["p1",6,"op","chess_char_6_diy2_b",0,"uniequip_002_demetr",728,1,157,4,53769,4664,0,80,16],
["p1",7,"op","chess_char_6_diy1_b",1,"uniequip_002_demetr",1520,1,147,9,169639,3872,0,132,4],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_demetr",158.56,1,154,22,297728,2537,0,302,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,130,0,8567,5128,0,28,1],
["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,120,1,15950,6668,0,38,2],
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,8,4,17766,0,0,196,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,10,2,35026,0,0,205,3],
["p1",1,"op","chess_char_6_diy1_b",0,"none",194,1,5,7,42152,2272,0,105,21],
["p1",2,"op","chess_char_6_diy2_b",1,"none",2466,1,176,6,75310,2466,0,100,3],
["p1",3,"op","chess_char_6_diy1_b",2,"none",0,0,126,12,141029,2466,0,184,2],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_demetr",1064,1,160,10,77372,4328,0,126,25],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_demetr",279.28,1,119,24,296343,5113,0,204,3],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_demetr",624,1,194,14,191392,2072,0,185,3],
["p1",7,"op","chess_char_5_diy1_a",0,null,1284,1,7,0,20643,0,0,42,6],
["p1",8,"op","chess_char_5_diy2_a",1,null,1284,1,10,2,33071,0,0,53,17],
["p1",9,"op","chess_char_5_diy1_a",2,null,1284,1,12,7,88856,0,0,65,5],
["p1",10,"op","chess_char_5_diy2_b",0,"none",1497,1,14,1,142895,0,0,70,10],
["p1",11,"op","chess_char_5_diy1_b",1,"none",1497,1,4,5,25784,0,0,39,15],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1497,1,8,5,137246,0,0,66,5],
["-","~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],
["p1","~1","token","token_10065_demetr_dmtpos",null,null,1000,0,27,0,0,0,0,0,null],
["p1","~2","token","token_10065_demetr_dmtpos",null,null,1000,0,43,0,0,0,0,0,null],
["p1","~3","token","token_10065_demetr_dmtpos",null,null,1000,0,96,0,0,0,0,0,null]
["p1","~1","token","token_10008_cqbw_box",null,null,1000,0,24,0,2368,0,0,0,0],
["p1","~1","token","token_10065_demetr_dmtpos",null,null,1000,0,28,0,0,0,0,0,null],
["p1","~2","token","token_10008_cqbw_box",null,null,1000,0,34,0,2050,0,0,0,0],
["p1","~2","token","token_10065_demetr_dmtpos",null,null,1000,0,38,0,0,0,0,0,null],
["p1","~3","token","token_10008_cqbw_box",null,null,1000,0,39,0,913,0,0,0,0],
["p1","~4","token","token_10008_cqbw_box",null,null,1000,0,46,0,1044,0,0,0,0],
["p1","~5","token","token_10008_cqbw_box",null,null,1000,0,50,0,2769,0,0,0,0],
["p1","~6","token","token_10008_cqbw_box",null,null,1000,0,52,0,1044,0,0,0,0],
["p1","~7","token","token_10008_cqbw_box",null,null,1000,0,58,0,5150,0,0,0,0],
["p1","~8","token","token_10008_cqbw_box",null,null,1000,0,61,0,0,0,0,0,0],
["p1","~9","token","token_10008_cqbw_box",null,null,1000,0,67,0,1610,0,0,0,0],
["p1","~10","token","token_10008_cqbw_box",null,null,1000,0,71,0,2368,0,0,0,0],
["p1","~11","token","token_10008_cqbw_box",null,null,1000,0,80,1,813,0,0,0,0],
["p1","~12","token","token_10008_cqbw_box",null,null,1000,0,86,0,1728,0,0,0,0],
["p1","~13","token","token_10008_cqbw_box",null,null,1000,0,89,0,1508,0,0,0,0],
["p1","~3","token","token_10065_demetr_dmtpos",null,null,1000,0,91,0,0,0,0,0,null],
["p1","~4","token","token_10065_demetr_dmtpos",null,null,1000,0,98,0,0,0,0,0,null],
["p1","~14","token","token_10008_cqbw_box",null,null,1000,0,100,0,764,0,0,0,0],
["p1","~15","token","token_10008_cqbw_box",null,null,1000,0,106,0,8972,0,0,0,0],
["p1","~16","token","token_10008_cqbw_box",null,null,1000,0,109,0,764,0,0,0,0],
["p1","~5","token","token_10065_demetr_dmtpos",null,null,1000,0,111,0,0,0,0,0,null],
["p1","~17","token","token_10008_cqbw_box",null,null,1000,0,116,0,1310,0,0,0,0],
["p1","~18","token","token_10008_cqbw_box",null,null,1000,0,121,0,864,0,0,0,0],
["p1","~6","token","token_10065_demetr_dmtpos",null,null,1000,0,124,0,0,0,0,0,null],
["p1","~19","token","token_10008_cqbw_box",null,null,1000,0,129,0,1876,0,0,0,0],
["p1","~20","token","token_10008_cqbw_box",null,null,1000,0,136,0,2368,0,0,0,0],
["p1","~21","token","token_10008_cqbw_box",null,null,1000,0,145,2,5714,0,0,0,0],
["p1","~22","token","token_10008_cqbw_box",null,null,1000,0,151,0,1728,0,0,0,0],
["p1","~23","token","token_10008_cqbw_box",null,null,1000,0,155,1,2651,0,0,0,0],
["p1","~24","token","token_10008_cqbw_box",null,null,1000,0,158,0,764,0,0,0,0],
["p1","~25","token","token_10008_cqbw_box",null,null,1000,0,163,1,4089,0,0,0,0],
["p1","~26","token","token_10008_cqbw_box",null,null,1000,0,166,0,902,0,0,0,0],
["p1","~7","token","token_10065_demetr_dmtpos",null,null,1000,0,168,0,0,0,0,0,null],
["p1","~8","token","token_10065_demetr_dmtpos",null,null,1000,0,171,0,0,0,0,0,null],
["p1","~27","token","token_10008_cqbw_box",null,null,1000,0,175,0,6136,0,0,0,0],
["p1","~28","token","token_10008_cqbw_box",null,null,1000,0,179,0,0,0,0,0,0],
["p1","~29","token","token_10008_cqbw_box",null,null,1000,0,182,0,2732,0,0,0,0],
["p1","~9","token","token_10065_demetr_dmtpos",null,null,1000,0,185,0,0,0,0,0,null],
["p1","~30","token","token_10008_cqbw_box",null,null,1000,1,189,0,0,0,0,0,0],
["p1","~31","token","token_10008_cqbw_box",null,null,1000,1,193,0,0,0,0,0,0],
["p1","~32","token","token_10008_cqbw_box",null,null,1000,1,196,0,0,0,0,0,0]
],
"enemies": {"enemy_1116_liprr_2":[27,15,12,1609,125049],"enemy_1119_vofsd":[48,30,18,14476,348278],"enemy_1121_lifbos_2":[30,17,13,29379,466135]},
"hooks": [1,161,6559,1729,1729,1988,1911,3731,77,123,55,54,0,25971,0,96,105,43,1,78,0,0,77],
"events": {"atk":1963,"deploy":53,"die":80,"dmg":1911,"fx":131,"leak":43,"skill":109,"spawn":123,"status":209},
"fx": {"dodge":77,"liberate":28,"teleport":25,"undying":1},
"snaps": "d5320ae4 4c9a6086 3ef6da3f 4cecf299 2d9aa2b7 2f24f2b5 2cf4ab07 9299fe0c 7d601db9 48c4c881 c9bcc356 6eb8d1f9 9cfb08bd c28c9da9 794c275f 784648d8 88699e9a 3e2ae987 9e9c9e23 f3a3b1b6 44936fe0 8ed918ed"
"enemies": {"enemy_1116_liprr_2":[27,27,0,33,194400],"enemy_1119_vofsd":[48,44,4,4602,483663],"enemy_1121_lifbos_2":[30,27,3,14081,659033]},
"hooks": [1,196,6058,1390,1348,2328,2268,863,102,147,115,144,0,43125,166,80,105,7,1,102,0,0,60],
"events": {"atk":1598,"deploy":88,"die":140,"dmg":2268,"fx":251,"leak":7,"skill":294,"spawn":161,"status":410},
"fx": {"aoe":56,"buff":6,"dodge":60,"liberate":10,"lock":27,"summon":32,"telegraph":29,"teleport":31},
"snaps": "26812200 a2fa19cc b694ee05 7cc27c9c ffc5e78b 87d97e05 024fcece 26c15317 892ddec2 ee17b669 8eb5b83c 0b3b2ac3 c0bb05ce 15443ccb 9312eb29 d5a1826c 724fffdf de1245c4 054a1bc8 8b27ecc2 fa6ce84f"
},
"diy-066": {
"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=cqbw/S1/002_cqbw 5_diy2_b=cqbw/S2/002_cqbw 5_diy1_b=cqbw/S3/002_cqbw 5_diy2_b=cqbw/S1/003_cqbw 5_diy1_b=cqbw/S2/003_cqbw 5_diy2_b=cqbw/S3/003_cqbw 6_diy1_a=cqbw/S1 6_diy2_a=cqbw/S2 6_diy1_a=cqbw/S3 6_diy2_b=cqbw/S1 6_diy1_b=cqbw/S2 6_diy2_b=cqbw/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m04",
"modeId": "mode_single_hard",
"round": 12,
"round": 13,
"seed": 3822982705,
"waveId": "act1autochess_h05",
"time": 222.47,
"ticks": 6674,
"reason": "cleared",
"killed": 14,
"total": 111,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 37,
"total": 105,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 35,
"rngDraws": 27,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":14,"total":111,"perfect":false,"leaks":97,"leaksUncounted":0,"coins":0,"dmg":301762,"bossDmg":0,"heal":0,"deaths":12,"layerGains":{}}},
"players": {"p1":{"killed":37,"total":105,"perfect":false,"leaks":68,"leaksUncounted":0,"coins":0,"dmg":1439959,"bossDmg":0,"heal":0,"deaths":6,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,107,0,8089,5208,0,76,1],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",2758.74,1,131,1,24872,3983,0,95,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,132,2,26971,4587,0,20,2],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",1436.24,1,133,2,33193,4276,0,28,3],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,106,4,30020,5276,0,46,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,101,1,43129,6822,0,52,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,3,2,23155,0,0,92,3],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,4,1,49524,0,0,172,4],
["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,25478,0,0,129,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,11,1,37332,0,0,150,4],
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_cqbw",1627,1,3,2,162731,0,0,78,11],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_cqbw",1627,1,4,2,47289,0,0,59,20],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_cqbw",1627,1,11,0,51740,0,0,47,4],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_cqbw",1657,1,13,0,41276,0,0,42,8],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_cqbw",1657,1,5,3,48986,0,0,60,20],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_cqbw",1657,1,8,5,224079,0,0,72,6],
["p1",7,"op","chess_char_6_diy1_a",0,null,1284,1,12,0,110432,0,0,75,9],
["p1",8,"op","chess_char_6_diy2_a",1,null,1284,1,14,0,30571,0,0,54,17],
["p1",9,"op","chess_char_6_diy1_a",2,null,1284,1,9,1,135240,0,0,70,5],
["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,155,1,61887,2994,0,41,5],
["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,152,0,14306,2994,0,32,10],
["p1",12,"op","chess_char_6_diy2_b",2,"none",1497,1,190,2,33171,2994,0,28,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]
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["p1","~1","token","token_10008_cqbw_box",null,null,1000,0,23,0,3585,0,0,0,0],
["p1","~2","token","token_10008_cqbw_box",null,null,1000,0,25,1,1424,0,0,0,0],
["p1","~3","token","token_10008_cqbw_box",null,null,1000,0,26,0,2820,0,0,0,0],
["p1","~4","token","token_10008_cqbw_box",null,null,1000,0,27,0,6141,0,0,0,0],
["p1","~5","token","token_10008_cqbw_box",null,null,1000,0,34,1,9187,0,0,0,0],
["p1","~6","token","token_10008_cqbw_box",null,null,1000,0,35,0,4283,0,0,0,0],
["p1","~7","token","token_10008_cqbw_box",null,null,1000,0,36,0,4130,0,0,0,0],
["p1","~8","token","token_10008_cqbw_box",null,null,1000,0,37,0,1628,0,0,0,0],
["p1","~9","token","token_10008_cqbw_box",null,null,1000,0,43,0,10425,0,0,0,0],
["p1","~10","token","token_10008_cqbw_box",null,null,1000,0,44,0,4920,0,0,0,0],
["p1","~11","token","token_10008_cqbw_box",null,null,1000,0,45,0,1310,0,0,0,0],
["p1","~12","token","token_10008_cqbw_box",null,null,1000,0,46,0,3257,0,0,0,0],
["p1","~13","token","token_10008_cqbw_box",null,null,1000,0,51,0,4642,0,0,0,0],
["p1","~14","token","token_10008_cqbw_box",null,null,1000,0,52,0,5233,0,0,0,0],
["p1","~15","token","token_10008_cqbw_box",null,null,1000,0,53,2,13990,0,0,0,0],
["p1","~16","token","token_10008_cqbw_box",null,null,1000,0,56,0,3133,0,0,0,0],
["p1","~17","token","token_10008_cqbw_box",null,null,1000,0,59,1,10251,0,0,0,0],
["p1","~18","token","token_10008_cqbw_box",null,null,1000,0,60,1,14573,0,0,0,0],
["p1","~19","token","token_10008_cqbw_box",null,null,1000,0,61,0,15997,0,0,0,0],
["p1","~20","token","token_10008_cqbw_box",null,null,1000,0,62,0,1728,0,0,0,0],
["p1","~21","token","token_10008_cqbw_box",null,null,1000,0,71,1,10982,0,0,0,0],
["p1","~22","token","token_10008_cqbw_box",null,null,1000,0,72,0,8029,0,0,0,0],
["p1","~23","token","token_10008_cqbw_box",null,null,1000,0,78,0,4430,0,0,0,0],
["p1","~24","token","token_10008_cqbw_box",null,null,1000,0,80,0,13370,0,0,0,0],
["p1","~25","token","token_10008_cqbw_box",null,null,1000,0,88,0,7148,0,0,0,0],
["p1","~26","token","token_10008_cqbw_box",null,null,1000,0,89,0,5705,0,0,0,0],
["p1","~27","token","token_10008_cqbw_box",null,null,1000,0,91,0,0,0,0,0,0],
["p1","~28","token","token_10008_cqbw_box",null,null,1000,0,92,0,3357,0,0,0,0],
["p1","~29","token","token_10008_cqbw_box",null,null,1000,0,97,1,7793,0,0,0,0],
["p1","~30","token","token_10008_cqbw_box",null,null,1000,0,98,1,13055,0,0,0,0],
["p1","~31","token","token_10008_cqbw_box",null,null,1000,0,100,0,3157,0,0,0,0],
["p1","~32","token","token_10008_cqbw_box",null,null,1000,0,104,0,7513,0,0,0,0],
["p1","~33","token","token_10008_cqbw_box",null,null,1000,0,105,0,15606,0,0,0,0],
["p1","~34","token","token_10008_cqbw_box",null,null,1000,0,107,0,864,0,0,0,0],
["p1","~35","token","token_10008_cqbw_box",null,null,1000,0,111,0,8935,0,0,0,0],
["p1","~36","token","token_10008_cqbw_box",null,null,1000,0,112,0,13045,0,0,0,0],
["p1","~37","token","token_10008_cqbw_box",null,null,1000,0,114,0,1728,0,0,0,0],
["p1","~38","token","token_10008_cqbw_box",null,null,1000,0,122,1,8956,0,0,0,0],
["p1","~39","token","token_10008_cqbw_box",null,null,1000,0,123,1,5870,0,0,0,0],
["p1","~40","token","token_10008_cqbw_box",null,null,1000,0,125,0,3233,0,0,0,0],
["p1","~41","token","token_10008_cqbw_box",null,null,1000,0,138,0,4468,0,0,0,0],
["p1","~42","token","token_10008_cqbw_box",null,null,1000,0,139,0,11298,0,0,0,0],
["p1","~43","token","token_10008_cqbw_box",null,null,1000,0,141,0,4761,0,0,0,0],
["p1","~44","token","token_10008_cqbw_box",null,null,1000,0,147,0,16163,0,0,0,0],
["p1","~45","token","token_10008_cqbw_box",null,null,1000,0,148,3,17717,0,0,0,0],
["p1","~46","token","token_10008_cqbw_box",null,null,1000,0,150,0,3157,0,0,0,0],
["p1","~47","token","token_10008_cqbw_box",null,null,1000,0,157,1,9190,0,0,0,0],
["p1","~48","token","token_10008_cqbw_box",null,null,1000,0,158,0,7174,0,0,0,0],
["p1","~49","token","token_10008_cqbw_box",null,null,1000,0,159,0,3270,0,0,0,0],
["p1","~50","token","token_10008_cqbw_box",null,null,1000,0,161,0,9570,0,0,0,0],
["p1","~51","token","token_10008_cqbw_box",null,null,1000,0,165,0,5990,0,0,0,0],
["p1","~52","token","token_10008_cqbw_box",null,null,1000,0,166,0,8619,0,0,0,0],
["p1","~53","token","token_10008_cqbw_box",null,null,1000,0,167,0,1728,0,0,0,0],
["p1","~54","token","token_10008_cqbw_box",null,null,1000,0,169,0,17623,0,0,0,0],
["p1","~55","token","token_10008_cqbw_box",null,null,1000,0,173,0,10898,0,0,0,0],
["p1","~56","token","token_10008_cqbw_box",null,null,1000,0,174,1,9758,0,0,0,0],
["p1","~57","token","token_10008_cqbw_box",null,null,1000,0,175,0,3233,0,0,0,0],
["p1","~58","token","token_10008_cqbw_box",null,null,1000,0,178,0,11647,0,0,0,0],
["p1","~59","token","token_10008_cqbw_box",null,null,1000,0,180,0,7413,0,0,0,0],
["p1","~60","token","token_10008_cqbw_box",null,null,1000,0,181,1,8773,0,0,0,0],
["p1","~61","token","token_10008_cqbw_box",null,null,1000,0,183,0,864,0,0,0,0],
["p1","~62","token","token_10008_cqbw_box",null,null,1000,0,184,1,10950,0,0,0,0],
["p1","~63","token","token_10008_cqbw_box",null,null,1000,0,185,1,13358,0,0,0,0],
["p1","~64","token","token_10008_cqbw_box",null,null,1000,0,186,0,4568,0,0,0,0],
["p1","~65","token","token_10008_cqbw_box",null,null,1000,0,187,0,7014,0,0,0,0],
["p1","~66","token","token_10008_cqbw_box",null,null,1000,0,188,0,1797,0,0,0,0],
["p1","~67","token","token_10008_cqbw_box",null,null,1000,0,189,0,1823,0,0,0,0]
],
"enemies": {"enemy_1010_demon":[42,4,38,12104,111213],"enemy_1422_lrsldr_2":[45,10,35,3684,141826],"enemy_1425_lrcmra_2":[24,0,24,14364,81163]},
"hooks": [1,133,6674,1177,1177,2246,2235,0,26,123,25,23,0,29082,269,41,111,97,1,26,0,0,11],
"events": {"atk":1177,"deploy":20,"die":26,"dmg":2235,"fx":105,"leak":97,"skill":48,"spawn":125,"status":480},
"fx": {"aoe":45,"dodge":11,"dp":9,"slash":40},
"snaps": "6cc88f86 7cacf7c8 93464d70 85641265 c4d1bd60 4e973db2 5fdca821 ff0dcc37 dd3d2832 929511af 9b1f7ecc a2b7a226 3ef1fb1d 8b9fec74 4670ebe6 692461f5 50bbb267 0832f2f1 892dca09 7eca58af 2ac78dcc 2eb3bcc0 d24fc730"
"enemies": {"enemy_1116_liprr_2":[27,26,1,0,334858],"enemy_1119_vofsd":[48,11,36,2651,629001],"enemy_1121_lifbos_2":[30,0,30,6331,513934]},
"hooks": [1,190,6900,773,689,3504,3486,4143,43,177,117,184,0,65115,582,10,105,67,1,43,0,0,18],
"events": {"atk":689,"deploy":83,"die":110,"dmg":3486,"fx":339,"leak":67,"skill":368,"spawn":186,"status":872},
"fx": {"aoe":109,"buff":15,"dodge":18,"liberate":20,"lock":43,"summon":67,"telegraph":67},
"snaps": "c0921652 69589ea4 acea48b5 4aeac179 ca726377 46604709 c25eb30e 01b7e6dc e12be22b 1a384a2f f83c72b2 557921fd 479c3f08 5b13ef38 3fcffada d0ca6c43 616cf220 8e438dd7 349294bb e2d87b5c 98e4077f 1941c917 d2b616a0"
},
"diy-067": {
"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=cqbw/S1/002_cqbw 6_diy2_b=cqbw/S2/002_cqbw 6_diy1_b=cqbw/S3/002_cqbw 6_diy2_b=cqbw/S1/003_cqbw 6_diy1_b=cqbw/S2/003_cqbw 6_diy2_b=cqbw/S3/003_cqbw 5_diy1_a=phenxi/S1 5_diy2_a=phenxi/S2 5_diy1_a=phenxi/S3 5_diy2_b=phenxi/S1 5_diy1_b=phenxi/S2 5_diy2_b=phenxi/S3",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m01",
"modeId": "mode_multi_normal",
"round": 12,
"round": 13,
"seed": 2182386222,
"waveId": "act1autochess_h05",
"time": 217.2,
"ticks": 6516,
"waveId": "act1autochess_h06",
"time": 224.17,
"ticks": 6725,
"reason": "cleared",
"killed": 27,
"total": 138,
"killed": 41,
"total": 147,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1241,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":27,"total":138,"perfect":false,"leaks":111,"leaksUncounted":0,"coins":0,"dmg":817964,"bossDmg":0,"heal":14211,"deaths":22,"layerGains":{}}},
"players": {"p1":{"killed":41,"total":147,"perfect":false,"leaks":106,"leaksUncounted":0,"coins":0,"dmg":1546308,"bossDmg":0,"heal":0,"deaths":13,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,123,4,31761,4138,0,40,1],
["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,117,5,75873,4748,0,45,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,173,1,19950,9232,0,59,0],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,171,0,33594,10136,0,53,0],
["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,4,4,53886,0,0,193,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,5,7,82918,0,0,214,3],
["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,11,2,234458,0,0,74,2],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,13,3,266847,0,0,77,2],
["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,6,0,0,0,14211,29,2],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,14,0,0,0,0,0,0],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,172,0,1370,2320,0,11,0],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",631.9,1,154,1,17308,16031,0,74,2],
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_cqbw",1707,1,4,8,170766,0,0,73,10],
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_cqbw",1707,1,5,6,53907,0,0,55,18],
["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_cqbw",1707,1,11,4,227352,0,0,69,5],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_cqbw",1697,1,13,2,278457,0,0,73,10],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_cqbw",1697,1,6,5,61284,0,0,52,18],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_cqbw",1697,1,14,2,332467,0,0,67,5],
["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,200,0,3213,4620,0,5,0],
["p1",8,"op","chess_char_5_diy2_a",1,null,1375,1,206,0,6368,3245,0,9,0],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,181,1,8250,4620,0,11,0],
["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,99,0,9520,3592,0,8,0],
["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,97,0,9787,3592,0,6,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,8,0,9096,1796,0,9,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
["p1","~1","token","token_10008_cqbw_box",null,null,1000,0,26,1,7826,0,0,0,0],
["p1","~2","token","token_10008_cqbw_box",null,null,1000,0,41,0,7392,0,0,0,0],
["p1","~3","token","token_10008_cqbw_box",null,null,1000,0,45,0,5720,0,0,0,0],
["p1","~4","token","token_10008_cqbw_box",null,null,1000,0,51,1,8382,0,0,0,0],
["p1","~5","token","token_10008_cqbw_box",null,null,1000,0,53,0,1205,0,0,0,0],
["p1","~6","token","token_10008_cqbw_box",null,null,1000,0,58,0,11122,0,0,0,0],
["p1","~7","token","token_10008_cqbw_box",null,null,1000,0,61,0,13350,0,0,0,0],
["p1","~8","token","token_10008_cqbw_box",null,null,1000,0,68,1,14709,0,0,0,0],
["p1","~9","token","token_10008_cqbw_box",null,null,1000,0,75,0,13970,0,0,0,0],
["p1","~10","token","token_10008_cqbw_box",null,null,1000,0,86,1,12003,0,0,0,0],
["p1","~11","token","token_10008_cqbw_box",null,null,1000,0,92,0,2510,0,0,0,0],
["p1","~12","token","token_10008_cqbw_box",null,null,1000,0,100,1,11978,0,0,0,0],
["p1","~13","token","token_10008_cqbw_box",null,null,1000,0,104,1,17079,0,0,0,0],
["p1","~14","token","token_10008_cqbw_box",null,null,1000,0,111,1,19947,0,0,0,0],
["p1","~15","token","token_10008_cqbw_box",null,null,1000,0,115,0,15100,0,0,0,0],
["p1","~16","token","token_10008_cqbw_box",null,null,1000,0,121,0,13009,0,0,0,0],
["p1","~17","token","token_10008_cqbw_box",null,null,1000,0,123,0,11845,0,0,0,0],
["p1","~18","token","token_10008_cqbw_box",null,null,1000,0,128,0,9135,0,0,0,0],
["p1","~19","token","token_10008_cqbw_box",null,null,1000,0,132,0,3615,0,0,0,0],
["p1","~20","token","token_10008_cqbw_box",null,null,1000,0,148,0,13729,0,0,0,0],
["p1","~21","token","token_10008_cqbw_box",null,null,1000,0,152,0,12450,0,0,0,0],
["p1","~22","token","token_10008_cqbw_box",null,null,1000,0,161,0,12342,0,0,0,0],
["p1","~23","token","token_10008_cqbw_box",null,null,1000,0,165,1,15645,0,0,0,0],
["p1","~24","token","token_10008_cqbw_box",null,null,1000,0,171,2,12007,0,0,0,0],
["p1","~25","token","token_10008_cqbw_box",null,null,1000,0,175,0,5520,0,0,0,0],
["p1","~26","token","token_10008_cqbw_box",null,null,1000,0,180,0,12009,0,0,0,0],
["p1","~27","token","token_10008_cqbw_box",null,null,1000,0,183,0,11845,0,0,0,0],
["p1","~28","token","token_10008_cqbw_box",null,null,1000,0,187,0,11122,0,0,0,0],
["p1","~29","token","token_10008_cqbw_box",null,null,1000,0,191,0,22028,0,0,0,0],
["p1","~30","token","token_10008_cqbw_box",null,null,1000,0,195,0,15116,0,0,0,0],
["p1","~31","token","token_10008_cqbw_box",null,null,1000,0,198,0,4535,0,0,0,0],
["p1","~32","token","token_10008_cqbw_box",null,null,1000,0,201,0,4374,0,0,0,0],
["p1","~33","token","token_10008_cqbw_box",null,null,1000,0,202,1,2864,0,0,0,0],
["p1","~34","token","token_10008_cqbw_box",null,null,1000,0,203,2,11951,0,0,0,0],
["p1","~35","token","token_10008_cqbw_box",null,null,1000,0,204,0,6020,0,0,0,0],
["p1","~36","token","token_10008_cqbw_box",null,null,1000,0,205,0,2387,0,0,0,0]
],
"enemies": {"enemy_1166_dusbr_2":[45,19,26,7595,304167],"enemy_1169_duphlx_2":[51,7,44,17547,274083],"enemy_1170_dushld_2":[42,1,41,21463,239714]},
"hooks": [1,173,6516,1011,1011,2931,2995,49,49,160,15,29,0,26241,6389,42,138,111,1,49,0,0,0],
"events": {"atk":1387,"deploy":32,"die":49,"dmg":2916,"fx":77,"heal":38,"leak":111,"skill":62,"spawn":153,"status":695},
"fx": {"aoe":33,"displace":28,"dodge":15,"spGain":1},
"snaps": "efb477c6 8151f389 ea30f9f9 2cdfdb02 f2b50565 be21f6eb 54d9544a caf91403 4ca57e04 6100a790 236a3081 090aa38e 83445fa6 35c0712a b18e29ad 504a7170 f0c6b6f8 dfb90d92 829e3424 7a555ecf 736d5ddf aeb357b7"
"enemies": {"enemy_1166_dusbr_2":[39,34,5,2255,480947],"enemy_1169_duphlx_2":[57,7,50,3181,589353],"enemy_1170_dushld_2":[51,0,51,9202,476009]},
"hooks": [1,206,6725,510,464,2342,3805,0,54,196,66,102,0,36503,485,15,147,106,1,54,0,0,0],
"events": {"atk":464,"deploy":56,"die":90,"dmg":2342,"fx":174,"leak":106,"skill":204,"spawn":198,"status":903},
"fx": {"aoe":66,"buff":6,"lock":30,"summon":36,"telegraph":36},
"snaps": "99cae01b 88a3d7c2 eabad3c3 e4f64106 08a3675a 38e5c33e 0a935e4d 83773bd8 8c791877 903dfb2d d4151e04 dbc3b187 4a1b5f59 79ed95c7 2ca926d1 65bdac69 7879445d 9837e2fb c8233653 5dfab371 0b8f3dec 9471fb29 bbce7a0e"
},
"diy-068": {
"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=phenxi/S1/002_phenxi 5_diy2_b=phenxi/S2/002_phenxi 5_diy1_b=phenxi/S3/002_phenxi 5_diy2_b=phenxi/S1/003_phenxi 5_diy1_b=phenxi/S2/003_phenxi 5_diy2_b=phenxi/S3/003_phenxi 5_diy1_b=phenxi/S1/004_phenxi 5_diy2_b=phenxi/S2/004_phenxi 5_diy1_b=phenxi/S3/004_phenxi 6_diy1_a=phenxi/S1 6_diy2_a=phenxi/S2 6_diy1_a=phenxi/S3",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m02",
"modeId": "mode_multi_hard",
"round": 13,
"round": 12,
"seed": 541523556,
"waveId": "act1autochess_h06",
"time": 216.63,
"ticks": 6499,
"waveId": "act1autochess_h05",
"time": 205.03,
"ticks": 6151,
"reason": "cleared",
"killed": 5,
"total": 147,
"killed": 36,
"total": 111,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 818,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":5,"total":147,"perfect":false,"leaks":142,"leaksUncounted":0,"coins":0,"dmg":740914,"bossDmg":0,"heal":44350,"deaths":19,"layerGains":{}}},
"players": {"p1":{"killed":36,"total":111,"perfect":false,"leaks":75,"leaksUncounted":0,"coins":0,"dmg":743334,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,163,0,1058,7812,0,12,0],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,159,0,6558,8040,0,41,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,169,0,11407,4587,0,27,2],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1904,1,171,2,84672,15092,0,54,4],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,144,1,21244,18132,0,47,2],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,151,1,85946,19220,0,73,3],
["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,5,0,22123,0,0,168,2],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,8,1,95449,0,0,192,3],
["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,6,0,175652,0,0,77,2],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,9,0,236806,0,0,82,3],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,149,0,0,5352,1680,4,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,145,0,0,8121,42670,31,2]
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_phenxi",1,1,4,7,111229,1795,0,73,3],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_phenxi",1,1,7,5,242217,1795,0,75,7],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_phenxi",1,1,5,3,217929,1795,0,73,1],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_phenxi",1,1,8,7,119399,1795,0,78,3],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_phenxi",942,1,130,1,6890,4446,0,10,0],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_phenxi",0,0,105,6,7912,3592,0,12,0],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_004_phenxi",0,0,123,3,1788,3762,0,3,0],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_004_phenxi",0,0,9,0,9716,1881,0,10,0],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_004_phenxi",0,0,77,0,12034,3762,0,12,0],
["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,66,0,4757,3080,0,6,0],
["p1",11,"op","chess_char_6_diy2_a",1,null,0,0,2,3,3296,1540,0,9,0],
["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,74,1,6167,3080,0,8,0]
],
"enemies": {"enemy_1166_dusbr_2":[39,1,38,5433,224317],"enemy_1169_duphlx_2":[57,4,53,27713,257242],"enemy_1170_dushld_2":[51,0,51,53211,259356]},
"hooks": [1,171,6499,976,976,2593,2581,68,24,166,24,22,0,29191,0,40,147,142,1,30,0,0,12],
"events": {"atk":1211,"deploy":24,"die":24,"dmg":2581,"fx":134,"heal":64,"leak":142,"skill":46,"spawn":159,"status":159},
"fx": {"aoe":61,"dodge":12,"dp":8,"slash":48,"spGain":5},
"snaps": "781b1dba 9953d89c d62f5539 e2966f30 012de962 b2da7aff a9017dce 20209563 9745406a acd54467 14b08371 eeb3f082 0656caee b76ca44b b907e3c5 1ff44173 549045c2 4a92e0a2 3d45b025 b5c6c9e3 6c636a65 5c0049aa"
"enemies": {
"enemy_1197_sfshu": [18,7,11,435,106555],
"enemy_1197_sfshu_2": [27,5,22,5061,168938],
"enemy_1198_msfshu": [21,18,3,0,55],
"enemy_1198_msfshu_2": [15,6,9,0,36],
"enemy_9010_acpupp": [30,0,30,7915,467750]
},
"hooks": [1,130,6151,341,334,913,5458,0,50,125,14,13,0,255,0,16,111,75,1,51,0,0,0],
"events": {"atk":334,"deploy":19,"die":50,"dmg":906,"fx":161,"leak":75,"skill":27,"spawn":123,"status":39},
"fx": {"aoe":103,"ember":1,"revive":1,"shieldBreak":1,"summon":12,"telegraph":1,"zone":42},
"snaps": "e906bf06 dffa6b85 fec1877b 24b394c1 01fc048e 06dafd96 06fdb1c8 e8d643cc 08719af9 8541b8df 0c1d5725 a155e29e 0fec21ae 4d9fb3fb 7dd80deb d6f25f5a fd9469ba 515f7f29 d74a3559 c3e7ac22 c9596a35"
},
"diy-069": {
"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": "6_diy1_b=phenxi/S1 6_diy2_b=phenxi/S2 6_diy1_b=phenxi/S3 6_diy2_b=phenxi/S1/002_phenxi 6_diy1_b=phenxi/S2/002_phenxi 6_diy2_b=phenxi/S3/002_phenxi 6_diy1_b=phenxi/S1/003_phenxi 6_diy2_b=phenxi/S2/003_phenxi 6_diy1_b=phenxi/S3/003_phenxi 6_diy2_b=phenxi/S1/004_phenxi 6_diy1_b=phenxi/S2/004_phenxi 6_diy2_b=phenxi/S3/004_phenxi",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m03",
@@ -2807,32 +2954,284 @@
"round": 13,
"seed": 3435515545,
"waveId": "act1autochess_h06",
"time": 218.87,
"ticks": 6566,
"time": 204,
"ticks": 6120,
"reason": "cleared",
"killed": 4,
"killed": 5,
"total": 102,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 158,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":4,"total":102,"perfect":false,"leaks":98,"leaksUncounted":0,"coins":0,"dmg":279006,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}},
"players": {"p1":{"killed":5,"total":102,"perfect":false,"leaks":97,"leaksUncounted":0,"coins":0,"dmg":549480,"bossDmg":0,"heal":0,"deaths":13,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,130,0,19608,5595,0,24,1],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,127,1,49502,7482,0,36,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,131,0,26763,10386,0,48,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,128,3,69526,11872,0,81,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,6,0,40716,0,0,86,2],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,9,0,72891,0,0,118,2],
["p1",1,"op","chess_char_6_diy1_b",0,"none",164,1,115,2,23151,3428,0,30,1],
["p1",2,"op","chess_char_6_diy2_b",1,"none",992,1,124,0,16949,2600,0,24,2],
["p1",3,"op","chess_char_6_diy1_b",2,"none",1,1,14,0,11239,1795,0,67,1],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_phenxi",1,1,100,1,131370,3591,0,52,2],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_phenxi",0,0,5,1,60861,1796,0,24,2],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_phenxi",0,0,79,0,16842,3592,0,12,0],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_003_phenxi",402,1,12,1,51978,1394,0,58,3],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_003_phenxi",902,1,15,0,204841,894,0,79,7],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_003_phenxi",0,0,68,0,11897,3592,0,13,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_004_phenxi",0,0,66,0,8109,3822,0,10,0],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_004_phenxi",0,0,62,0,4431,3822,0,6,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_004_phenxi",0,0,9,0,7814,1911,0,8,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1267_nhpbr_2":[27,4,23,7795,94117],"enemy_1270_nhstlk_2":[36,0,36,9450,76170],"enemy_9006_actoxi":[39,0,39,18089,108718]},
"hooks": [1,131,6566,509,509,689,728,0,27,125,6,23,0,11252,4464,32,102,98,1,27,0,0,4],
"events": {"atk":523,"deploy":26,"die":27,"dmg":670,"fx":37,"leak":98,"skill":48,"spawn":111,"status":658},
"fx": {"displace":28,"dodge":5,"zone":4},
"snaps": "d87bacee 4a5b5184 8bd75e7e 15a97a2d 1f5b6cda f88d6647 d127aecc 47aa891f a326d3f1 76566f37 9213718e aadabba7 6e040097 4a6230ed d1f7804a 4f567e2f bda2e258 e2c828a9 a802edca 5544e52a 45da48c6 d8cd6592"
"enemies": {"enemy_1267_nhpbr_2":[27,4,23,2285,149262],"enemy_1270_nhstlk_2":[36,1,35,3930,135479],"enemy_9006_actoxi":[39,0,39,4737,264739]},
"hooks": [1,124,6120,367,356,689,5494,0,18,115,18,17,0,282,0,9,102,97,1,18,0,0,0],
"events": {"atk":356,"deploy":19,"die":18,"dmg":689,"fx":195,"leak":97,"skill":35,"spawn":117,"status":44},
"fx": {"aoe":125,"zone":70},
"snaps": "51c4bda9 21f0cc46 59465d49 3881ca3f a3a635a8 abd07299 43bec432 b7203fd6 3290d0a9 571acc56 f4465684 33bd419c 6c79b79b 3bb52f0b 58d23ded a0e16f44 fbf36653 c984f07c 970867f6 b8fe4355 09040082"
},
"diy-070": {
"about": "5_diy1_a=wisdel/S1 5_diy2_a=wisdel/S2 5_diy1_a=wisdel/S3 5_diy2_b=wisdel/S1 5_diy1_b=wisdel/S2 5_diy2_b=wisdel/S3 5_diy1_b=wisdel/S1/002_wisdel 5_diy2_b=wisdel/S2/002_wisdel 5_diy1_b=wisdel/S3/002_wisdel 6_diy1_a=wisdel/S1 6_diy2_a=wisdel/S2 6_diy1_a=wisdel/S3",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m04",
"modeId": "mode_multi_abyss",
"round": 12,
"seed": 1794668012,
"waveId": "act1autochess_h05",
"time": 198.57,
"ticks": 5957,
"reason": "cleared",
"killed": 27,
"total": 111,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 973,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":27,"total":111,"perfect":false,"leaks":84,"leaksUncounted":0,"coins":0,"dmg":1947120,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,4,3,249007,1434,0,62,12],
["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,135,4,183893,2868,0,42,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,1434,1,19,6,439723,0,0,73,2],
["p1",4,"op","chess_char_5_diy2_b",0,"none",1735,1,143,1,79945,1735,0,28,6],
["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,121,6,130940,3470,0,36,1],
["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,88,1,66113,3470,0,12,1],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_wisdel",0,0,100,0,94710,3470,0,28,6],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_wisdel",0,0,87,2,147960,3470,0,19,1],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_wisdel",0,0,9,2,413261,1735,0,29,1],
["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,12,0,59767,1434,0,28,5],
["p1",11,"op","chess_char_6_diy2_a",1,null,0,0,82,2,41961,2868,0,14,1],
["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,93,0,22135,2868,0,15,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
["p1","~1","token","token_10035_wisdel_wward",null,null,0,0,5,0,4620,3500,0,0,14],
["p1","~2","token","token_10035_wisdel_wward",null,null,0,0,7,0,3960,3500,0,0,12],
["p1","~3","token","token_10035_wisdel_wward",null,null,3500,1,10,0,7011,0,0,0,19],
["p1","~4","token","token_10035_wisdel_wward",null,null,3500,1,15,0,2112,0,0,0,6],
["p1","~5","token","token_10035_wisdel_wward",null,null,1750,1,136,0,0,1750,0,0,0],
["p1","~6","token","token_10035_wisdel_wward",null,null,3500,1,144,0,0,0,0,0,0]
],
"enemies": {"enemy_1010_demon":[42,11,31,6635,791759],"enemy_1422_lrsldr_2":[45,16,29,2525,873020],"enemy_1425_lrcmra_2":[24,0,24,25033,386922]},
"hooks": [1,140,5957,411,411,8620,8620,0,47,131,88,88,19,21188,2107,18,111,84,1,47,0,0,0],
"events": {"atk":515,"deploy":26,"die":47,"dmg":8620,"fx":245,"leak":84,"skill":176,"spawn":132,"status":1109},
"fx": {"aoe":161,"camouflage":1,"overload":2,"strike":51,"summon":6,"telegraph":24},
"snaps": "2d9f8f73 d2731731 7ad6484b de506ad9 744afbb0 c80fca2f 66d47606 70a8ba35 d4329acf 5cb1fdbb 0f020029 88de71e9 8762bfc7 f80cf797 62fea1f7 63c7f32f 93829e16 17ab53e2 24bc8145 571b2e52"
},
"diy-071": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=wisdel/S1 6_diy2_b=wisdel/S2 6_diy1_b=wisdel/S3 6_diy2_b=wisdel/S1/002_wisdel 6_diy1_b=wisdel/S2/002_wisdel 6_diy2_b=wisdel/S3/002_wisdel 5_diy1_a=csnipe 5_diy2_b=csnipe",
"fast": true,
"kind": "normal",
"stageId": "act1autochess_m05",
"modeId": "mode_multi_funny",
"round": 13,
"seed": 1743045998,
"waveId": "act1autochess_h06",
"time": 200.23,
"ticks": 6007,
"reason": "cleared",
"killed": 129,
"total": 129,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 2278,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":129,"total":129,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1092882,"bossDmg":0,"heal":0,"deaths":1,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,2128,1,3,0,0,0,0,0,0],
["p1",2,"op","chess_char_5_diy2_b",2,"none",2908,1,6,0,0,0,0,0,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,9,0,0,0,0,0,0],
["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,12,1,5167,3334,0,84,3],
["p1",5,"op","chess_char_6_diy1_b",0,"none",1735,1,14,5,32370,0,0,51,12],
["p1",6,"op","chess_char_6_diy2_b",1,"none",1735,1,15,32,250298,0,0,118,4],
["p1",7,"op","chess_char_6_diy1_b",2,"none",1735,1,1,0,0,0,0,0,1],
["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_002_wisdel",1735,1,4,6,57148,0,0,67,16],
["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_002_wisdel",1735,1,7,40,280613,0,0,121,4],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_wisdel",1735,1,10,41,411715,0,0,83,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,13,0,4500,0,0,192,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,16,0,5660,0,0,192,3],
["p1","~1","token","token_10035_wisdel_wward",null,null,3500,1,2,0,0,0,0,0,0],
["p1","~2","token","token_10035_wisdel_wward",null,null,3500,1,5,3,11577,0,0,0,36],
["p1","~3","token","token_10035_wisdel_wward",null,null,3500,1,8,1,16565,0,0,0,48],
["p1","~4","token","token_10035_wisdel_wward",null,null,3500,1,11,0,17269,0,0,0,51],
["p1","~5","token","token_10035_wisdel_wward",null,null,3500,1,41,0,0,0,0,0,0]
],
"enemies": {"enemy_10124_uashld_2":[18,18,0,2568,432000],"enemy_1043_zomsbr":[66,66,0,0,167717],"enemy_1061_zomshd":[45,45,0,766,525605]},
"hooks": [1,146,6007,914,914,10408,10408,17595,130,130,184,180,15,39811,2202,6,129,0,1,130,0,0,0],
"events": {"atk":1255,"deploy":17,"die":130,"dmg":10408,"fx":463,"skill":364,"spawn":146,"status":1118},
"fx": {"aoe":314,"camouflage":1,"overload":8,"strike":135,"summon":5},
"snaps": "26a95a14 72d3b786 58cfa207 888fb447 256e095a a5cf08bf 079bec13 5ccf0eec 51fdcabe bcc4a5ff d1d1a0dd 45764c99 a1ed7dbd 71926661 3b3af91d fa2cd3b9 c0f99870 8c20c23f fbba0542 b59e13f6"
},
"diy-072": {
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m06",
"modeId": "mode_single_hard",
"round": 12,
"seed": 3384200484,
"waveId": "act1autochess_h05",
"time": 204.6,
"ticks": 6138,
"reason": "cleared",
"killed": 19,
"total": 117,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 56,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":19,"total":117,"perfect":false,"leaks":98,"leaksUncounted":0,"coins":0,"dmg":159729,"bossDmg":0,"heal":16045,"deaths":23,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,93,0,7137,11030,0,39,0],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3371,1,6,0,0,0,0,0,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,9,8,31289,0,0,0,7],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,128,4,20803,5712,0,33,1],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,113,3,20962,7381,0,44,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,120,2,39208,8511,0,56,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,99,0,4203,2266,0,9,2],
["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,92,1,23030,2720,0,52,2],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,88,0,0,5014,7904,23,2],
["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,87,0,0,4315,8141,17,2],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,134,0,4227,2796,0,14,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,142,1,8871,5676,0,25,1]
],
"enemies": {"enemy_1006_shield_3":[18,0,18,24196,19254],"enemy_1043_zomsbr":[72,19,53,8536,91885],"enemy_1402_tgshd_2":[27,0,27,22691,48590]},
"hooks": [1,142,6138,532,532,643,627,7310,42,140,21,21,0,20309,38,41,117,98,1,45,0,0,16],
"events": {"atk":532,"deploy":25,"die":42,"dmg":627,"fx":240,"heal":40,"leak":98,"skill":42,"spawn":129,"status":119},
"fx": {"dodge":16,"dp":20,"expose":143,"slash":61},
"snaps": "edadf2fb 7583fb4c cfa55f1f 6c4b397c 562ed971 5a325195 87a1cfde 57e48e44 6e31121b c85b4fa4 ddd106d8 87048e9a bfc4e6fe 8ce79edc 6c260053 c3c5a3a1 b83e6c72 be4087c8 ae11705c 3a471c74 89ab6038"
},
"diy-073": {
"about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m07",
"modeId": "mode_multi_normal",
"round": 12,
"seed": 2756955908,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 7,
"total": 90,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 860,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":7,"total":90,"perfect":false,"leaks":83,"leaksUncounted":0,"coins":0,"dmg":565936,"bossDmg":0,"heal":37862,"deaths":15,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,116,1,23376,5595,0,42,1],
["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,115,3,87314,10246,0,44,2],
["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,117,2,37753,19555,0,152,0],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,114,0,16429,26745,0,182,0],
["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,4,0,16123,0,0,113,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,7,1,39656,0,0,130,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,10,0,77688,0,0,51,2],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,13,0,89279,0,0,48,2],
["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,14,0,0,0,0,0,0],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,5,0,0,0,37862,58,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,8,0,73430,0,0,157,3],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,11,0,104888,0,0,161,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1116_liprr_2":[27,6,21,7860,157842],"enemy_1119_vofsd":[39,1,37,25542,243517],"enemy_1121_lifbos_2":[24,0,24,28739,164577]},
"hooks": [1,117,6900,1235,1235,1585,1650,4412,22,104,20,26,0,32957,7577,27,90,82,1,22,0,0,0],
"events": {"atk":1361,"deploy":25,"die":22,"dmg":1619,"fx":49,"heal":68,"leak":82,"skill":59,"spawn":104,"status":941},
"fx": {"aoe":6,"displace":2,"dodge":9,"liberate":20,"spGain":12},
"snaps": "47ef258d f9e9aba1 190ac938 372c5487 ed5a908a 815460f1 55cea32d f76bcba9 6667fc01 24413ad2 2fd382d9 19e2cfc3 759441ca acbe5115 f94bb0ba e8ea30b1 ce1bcacd b79a43bf 180f16ed 40dda8e9 ae9ed60a a9074d2a 9e3ce0d0"
},
"diy-074": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m01",
"modeId": "mode_multi_hard",
"round": 13,
"seed": 102712569,
"waveId": "act1autochess_h06",
"time": 221.87,
"ticks": 6656,
"reason": "cleared",
"killed": 8,
"total": 105,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 205,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":8,"total":105,"perfect":false,"leaks":97,"leaksUncounted":0,"coins":0,"dmg":305544,"bossDmg":0,"heal":25311,"deaths":24,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,73,0,203,5208,0,4,0],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,72,0,168,6862,0,4,0],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,117,0,11702,4587,0,5,1],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,123,1,87482,6798,0,8,2],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,80,1,16290,5276,0,15,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,8,1,27795,17209,0,14,1],
["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,130,0,19769,2544,0,30,1],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,91,1,66539,3286,0,45,1],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,4,4,49537,1372,0,11,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,66,0,26060,6199,0,6,1],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,112,0,0,4229,9625,8,1],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,129,0,0,9133,15687,16,1]
],
"enemies": {"enemy_1229_darmy":[27,5,22,10928,81346],"enemy_1229_darmy_2":[57,3,54,20729,130893],"enemy_1275_dwlock_2":[21,0,21,41047,93304]},
"hooks": [1,130,6656,287,287,718,780,47,32,129,11,11,0,10225,0,35,105,97,1,102,0,6,0],
"events": {"atk":354,"deploy":25,"die":32,"dmg":780,"fx":84,"heal":47,"leak":97,"skill":22,"spawn":117,"status":18},
"fx": {"aoe":10,"beam":36,"burst":6,"dp":4,"explode":2,"slash":24,"spGain":2},
"snaps": "0d9dfdaa 32539bfa 43860568 067d2c23 bef0b9e3 3d406805 b49ce9e9 b869cbb4 25e59436 d8053893 f537a887 b66f5aa1 af47af96 de57bc15 7c78eae1 e7a644ec 1a687f01 b8c42e5f 6b4fb3ce 2ec2ec96 20f31e31 d63d7191 fb610efc"
},
"diy-075": {
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m02",
"modeId": "mode_single_normal",
"round": 13,
"seed": 3770869562,
"waveId": "act1autochess_h06",
"time": 212,
"ticks": 6360,
"reason": "cleared",
"killed": 7,
"total": 102,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 178,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":7,"total":102,"perfect":false,"leaks":95,"leaksUncounted":0,"coins":0,"dmg":328833,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,126,1,26885,5595,0,29,1],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",2344,1,129,2,65444,5138,0,27,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,128,0,34841,10386,0,49,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",120.14,1,127,3,67065,11752,0,80,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,6,0,52776,0,0,114,2],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,7,1,81822,0,0,136,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1267_nhpbr_2":[27,6,21,7965,101509],"enemy_1270_nhstlk_2":[36,1,35,5072,104987],"enemy_9006_actoxi":[39,0,39,19834,122338]},
"hooks": [1,129,6360,551,551,755,785,0,28,123,7,22,0,10737,5368,32,102,95,1,28,0,0,8],
"events": {"atk":572,"deploy":25,"die":28,"dmg":720,"fx":50,"leak":95,"skill":47,"spawn":110,"status":709},
"fx": {"displace":32,"dodge":12,"zone":6},
"snaps": "219bb3e6 15948f1c 968d0d57 4e777f00 76716f4d d8dae95c a1a991dc 5c7c06a7 3ad889af 10e3e009 8f5f1c4b a6a6d4ea 4d2f29e9 1b0021ec c8db6150 d2e30a7c c0e91e16 4106ca50 3ccfeb13 e5e829bb 56a2c338 72359c7e"
}
}
}