feedback5: merge O21 — 自选 kits for 麦哲伦 (drones), 令 (souls, 弦惊) and 电弧 (towers) with a shared summon-deck helper (kits/shared/summoner.js); golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 14:14:44 +08:00
commit 3c9ae005de
10 files changed
+2192 -100

No files matched your search

+4 -1
View File
@@ -15,6 +15,7 @@ operators, the self-select 6★ operators, contributions from GitHub issue #136
| `ops/op-<codename>.js` | one 自选 operator kit per file (below: "How to add an operator (自选)") |
| `shared/tier1.js` | the general kit helpers (blackboard readers, unit predicates, hit hooks, area queries, buffs, zones, free tiles, skill records) and the notes of the tier-1 kits |
| `shared/tier2.js` … `tier6.js` | helpers two or more kits of that tier use, and that tier's notes (conventions, simplifications, fx kinds) |
| `shared/summoner.js` | the 召唤师 summon deck the 自选 kits of 麦哲伦 / 令 / 电弧 share (`summonDeck`: the holding, a placed piece's return, recalls, the summons leaving with their owner; `holdBuff`, `tokenStat`) |
| `tier1.js` … `tier6.js` | re-export shims for the old import paths; removed after the 0.2.0 refactor |
`content/index.js` takes `KITS` from `index.js`: a unit's kit is `KITS[def.baseId]` (also the exact or the suffix-less
@@ -192,7 +193,9 @@ so a kit finds its pieces before the battle starts as `battle.allyUnits` with `k
`ownerUnit === unit`, still undeployed (e.g. `op-bgsnow.js` gives its 打字机 pieces their kit in a talent's `install`
with `battle._setupUnit(t, kit)`; `op-cgbird.js` counts its 幻影). A kit test fields a piece the same way:
`units: [op, { uid, kind: 'token', tokenId, ownerUid, row, col }]` (`test/content/op_bgsnow.test.js`); the match side is
covered by `test/match/diy-shop.test.js` (a DIY 鸿雪's 打字机 from prep to battle). Potential is 0 [ASSUMED: no account]. A new kit makes the operator a legal pick at once:
covered by `test/match/diy-shop.test.js` (a DIY 鸿雪's 打字机 from prep to battle). A 召唤师 whose talent holds a deck of
summons ("可以使用5个召唤物(最多同时部署3个)") takes `shared/summoner.js summonDeck` — the holding, a placed piece's return
on its tile, recalls, its pieces' kits (`op-mgllan.js`, `op-ling.js`, `op-radian.js`). Potential is 0 [ASSUMED: no account]. A new kit makes the operator a legal pick at once:
the server's `welcome.diyKitted` lists `KITTED_CHARS`, so the 自选编队 picker offers it and `room.diy` keeps it.
**The fidelity rule and the checklist** above apply item by item: every skill at rank 4 and 7, every talent, every
+3
View File
@@ -139,6 +139,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-doroth.js',
'op-aglna2.js',
'op-ebnhlz.js',
'op-mgllan.js',
'op-ling.js',
'op-radian.js',
]);
/**
+249
View File
@@ -0,0 +1,249 @@
// server/sim/content/kits/ops/op-ling.js — 令 (char_2023_ling) 自选 operator kit: 6★ 召唤师 (辅助), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (SUM-Y 诗短梦长) at every form, and the
// kits of her summons “清平” / “逍遥” / “弦惊” (token_10020_ling_soul1 / 2 / 3). Kit contract and the 自选 rules: ../README.md
// ("How to add an operator (自选)"); the summon deck: ../shared/summoner.js.
//
// Forms (data/backups.json units.char_2023_ling): 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 / the tokens' character_table rows (zh_CN, as built into
// backups.json); PRTS 令 (S2 备注 "令与其召唤物的攻击范围内没有可选目标时,该技能也可开启…但可以进行召唤物的回收", "召唤物的同步
// 触发技能与回收不受缴械或沉默等效果制约"; S3 备注 "召唤物对周围敌人造成的持续法术伤害不可对空,且视为来自令造成的伤害"; SUM-Y
// 特性 "※“清平”“逍遥”:部署费用-3;“弦惊”:部署费用-5", stage 3 "属性更强的召唤物"); PRTS “清平” / “逍遥” / “弦惊” (备注 "持有
// 禁疗", "可以且优先攻击自身阻挡的单位"; 弦惊's merge rules and "高级形态下:生命上限+100%,攻击力+80%,防御力+80%,法术抗性+100%,
// 攻击间隔+0.8s,阻挡数+2…直接加算或直接乘算", "升级时立刻重设召唤物生命值至最大值"); PRTS 分支特性信息 召唤师; the client's
// battle data (charpack char_2023_ling: Talents/1 charge_token[born], Talents/2 ling_t on her summons, CommonAbilities
// die_to_kill_token; buff_template_data ling_t (ON_OWNER_KILLED: modify_sp[trigger] `sp`, ling_atk_up ATK MULTIPLIER STACK
// to max_stack_cnt), ling_s3_aoe[token] (AOEDamage MAGICAL SPLASH WALK_ONLY, source 令, × atk_scale, range x-5 every
// `interval`), ling_soul3_evolution[self] / [another] / _die (the merge); skill prefabs skchr_ling_1 (aura: the summons' mode
// + ATK / ASPD, hers + trigger_charge_token), skchr_ling_2 (Attack: 2 targets, ling_s2_unmovable UNMOVABLE; TokenAttack:
// InterruptCharacterAttack + the summon's ability "2", `_checkCanUseAblityFlag` false; Recycle: 1.2 s later, Withdraw
// `_switchToDeadState` + RechargeToken cnt for every summon below hp_ratio), skchr_ling_3 (aura + ling_s3[charge_token]);
// equips ling_equip_1_* (trigger_charge_token: the trait's cnt)).
// - Trait (召唤师): the profession default (ranged arts on her 3-1, hits air units — PRTS "可对空"), blocks 1, ground enemies
// target her; her summons leave the field with her (shared/summoner.js).
// - T1 挑灯问梦 "可以使用5个召唤物(最多同时部署3个),功能随技能选择而改变": the summon of her pick's skill (“清平” / “逍遥” /
// “弦惊” — the skills' overrideTokenKey) is the hand piece the player places; the deck of ../shared/summoner.js with charge =
// cnt (5), cap = the summon deck (max_deck_stack_cnt 5, +3 with SUM-Y), at most 1 + max_deploy_count (2 ⇒ 3; SUM-Y stage 3:
// 4) standing. Every summon holds 禁疗 (PRTS; [ASSUMED: given here — the data's `abnormal` lacks it]) and fights with its
// own data: “清平” blocks 1 and strikes in melee (physical) on its 1-1, “逍遥” shoots arts on its 3-1 (air units too), “弦惊”
// blocks 2 and strikes every enemy it blocks (physical; its trait "攻击阻挡的所有敌人").
// - T2 随付笺咏醉屠苏 "召唤物被击倒/吸收/回收时令额外获得3点技力、攻击力+3%(攻击力加成最多叠加5层)": a summon knocked out,
// absorbed by a 弦惊 merge or recalled by S2 ⇒ +sp SP (no SP while a timed skill of hers runs; 阻回 stops it) and a stack of
// ATK +atk (直接乘算) up to max_stack_cnt, kept until she leaves the field — not when her summons leave with her.
// - S1 重进酒 (MANUAL, data DEFAULT, 25 s): +cnt held at the cast; she and her summons ATK +atk, ASPD +attack_speed, and her
// summons' attacks deal arts damage meanwhile ("召唤物伤害类型变为法术"). Passive "召唤物可部署在近战位": placement only.
// - S2 笑鸣瑟 (MANUAL, data DEFAULT, 2 charges — the cast replaces the attack she is about to make): her attack strikes up to
// `value` (2) enemies of her range for atk_scale × ATK arts and 束缚 ling_s2_unmovable.duration s each; every summon of hers
// on the field breaks off its attack (its cooldown restarts) and strikes up to 2 enemies of its own range for its skill's
// 2.atk_scale × its ATK arts + 束缚 2.duration s — stunned / silenced / disarmed ones too (PRTS; `_checkCanUseAblityFlag`
// false) — instant for a melee summon, a bolt for “逍遥”; RECYCLE_DELAY (1.2) s after the cast every summon of hers below
// hp_ratio of its max HP is recalled (+1 held each, T2). Passive "召唤物可部署在远程位,攻击造成法术伤害": “逍遥”'s data.
// The 备注's cast with no target cannot happen here: the data's DEFAULT casts it on an attack.
// - S3 宁作吾 (MANUAL, data DEFAULT, 30 s): she and her summons ATK +atk, DEF +def; every `interval` (0.5) s from the moment
// it covers a summon, every ground enemy on that summon's x-5 takes atk_scale × 令's ATK arts (溅射: not dodgeable, her
// damage — PRTS); +cnt held when it ends (not when she leaves). Passive: the 弦惊 merge — at a 弦惊's deployment, another
// basic 弦惊 of hers on its range ⇒ that one turns 高级形态 and the new one is absorbed (withdrawn); else, the new one
// standing on the range of one or more basic ones ⇒ it turns 高级形态 and the latest deployed of them is absorbed. 高级形态
// (until it leaves the field): its skill's 2.max_hp / 2.atk / 2.def (直接乘算), RES ×(1 + 2.magic_resistance), attack
// interval +2.base_attack_time s, block +2.block_cnt, arts attacks, HP reset to its new maximum. Its "占据2个部署位" (and
// SUM-X-like 部署位 rules) concern the prep's deploy cap, which summon pieces never use in this mode: N/A.
// - Module SUM-Y 诗短梦长: trait "召唤物持有上限+3,召唤物部署费用减少" — +3 held on the card at the battle start, cap +3, the
// summons' lower costs (the module variant); stage 2+ T1 "最多同时部署4个", stage 3 "属性更强的召唤物" (the module variant's
// stats). Its attributes are in the stats.
import { num, talentBb, traitBb, moduleOn, skillRec, batMod, giveSp, up } from '../shared/tier1.js';
import { summonDeck, holdBuff, tokenStat } from '../shared/summoner.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { acquireTargets } from '../../../ai.js';
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
const S1 = 'skchr_ling_1';
const S2 = 'skchr_ling_2';
const S3 = 'skchr_ling_3';
export const SOUL1 = 'token_10020_ling_soul1';
export const SOUL2 = 'token_10020_ling_soul2';
export const SOUL3 = 'token_10020_ling_soul3';
const SOULS = Object.freeze([SOUL1, SOUL2, SOUL3]);
const SUM_Y = 'uniequip_002_ling';
/** skchr_ling_2 Recycle `_preDelay` 1.2 s: the recall of the summons below hp_ratio after the cast. */
export const RECYCLE_DELAY = 1.2;
/** S3's area around each summon (ling_s3[aoe] range_id x-5: its tile and the four next to it). */
const X5 = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
const TAG_S2 = 'ling:s2';
const TAG_S3 = 'ling:s3';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const skillOn = (u, id) => !!(u && u.skill && u.skill.active && u.skill.id === id);
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
const tileOf = (u) => u.tileR * COLS + u.tileC;
/** Her S1 / S3 acting on a summon (held from each skill start / end and each summon deployment). */
function syncSoul(battle, owner, t, { b1, b3 }) {
if (!t.alive) return;
holdBuff(battle, t, 'ling:s1:soul', skillOn(owner, S1), { atkPct: num(b1.atk), aspd: num(b1.attack_speed) });
holdBuff(battle, t, 'ling:s3:soul', skillOn(owner, S3), { atkPct: num(b3.atk), defPct: num(b3.def) });
}
/** 弦惊 高级形态 (its skill blackboard "2.*"): stats, block, arts attacks, HP reset to the new maximum. */
function evolve(battle, t) {
const tb = t.def.skill?.bb ?? {};
battle.addBuff(t, {
key: 'ling:soul3:advanced', tags: ['token'],
mods: {
hpPct: num(tb['2.max_hp']), atkPct: num(tb['2.atk']), defPct: num(tb['2.def']), resMul: 1 + num(tb['2.magic_resistance']),
batPct: batMod(tb['2.base_attack_time'], { stats: t.def.stats }), blockCnt: num(tb['2.block_cnt']),
},
});
t.mem.lingAdvanced = true;
t.hp = t.s.maxHp;
battle.fx('soul', { x: t.x, y: t.y, id: t.id });
}
/** The summons' kit: 禁疗, her skills' effects on them, S3's pulses, the 弦惊 merge. `owner` = 令. */
function soulKit(t, owner, opts) {
return {
skill: null,
talents: [],
trait: t.defId === SOUL3 ? { hitAllBlocked: true } : null,
install(battle, s) {
battle.addBuff(s, { key: 'ling:soul:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
battle.on('deploy', (c) => {
if (c.unit !== s) return;
s.mem.lingAdvanced = false;
s.mem.lingAoeAt = null;
syncSoul(battle, owner, s, opts);
if (s.defId === SOUL3) merge(battle, owner, s);
}, { owner: s });
// S1: arts attacks; 高级形态: arts attacks
battle.on('hit', (c) => {
if (c.source !== s || !c.dmg || !c.dmg.isAttack || c.dmg.type === 'element') return;
if (s.mem.lingAdvanced || skillOn(owner, S1)) c.dmg.type = 'arts';
}, { owner: s, priority: 50 });
// S3: every interval s from the moment it covers this summon, atk_scale × 令's ATK arts on the ground enemies of its x-5
const iv = num(opts.b3.interval, 0.5);
battle.on('tick', () => {
if (!up(s) || !skillOn(owner, S3) || !(iv > 0)) { s.mem.lingAoeAt = null; return; }
if (s.mem.lingAoeAt == null) { s.mem.lingAoeAt = battle.time + iv; return; }
if (battle.time + 1e-9 < s.mem.lingAoeAt) return;
s.mem.lingAoeAt += iv;
const keys = absoluteRangeKeys(X5, s.tileR, s.tileC, s.dir, 0);
for (const e of battle.enemiesInKeys(keys, s, { canHitFly: false })) {
if (!e.alive) continue;
battle.dealDamage(owner, e, { amount: owner.s.atk * num(opts.b3.atk_scale), type: 'arts', isSkill: true, isSplash: true, canDodge: false, tags: ['skill', TAG_S3] });
}
}, { owner: s });
},
};
}
/** The 弦惊 merge at the deployment of 弦惊 `t` (see the header). */
function merge(battle, owner, t) {
const deck = owner.mem.summonDeck;
if (!deck || !t.rangeKeySet) return;
const mates = deck.standing().filter((o) => o !== t && o.defId === SOUL3 && !o.mem.lingAdvanced);
const target = mates.find((o) => t.rangeKeySet.has(tileOf(o)));
if (target) { evolve(battle, target); deck.withdraw(t, 'absorb'); return; }
const holders = mates.filter((o) => o.rangeKeySet && o.rangeKeySet.has(tileOf(t)));
if (!holders.length) return;
const material = holders.reduce((a, b) => (b.deploySeq > a.deploySeq ? b : a));
evolve(battle, t);
deck.withdraw(material, 'absorb');
}
/** S2: a summon's synced strike — up to `n` enemies of its range, its skill's 2.atk_scale × ATK arts + 束缚 2.duration s. */
function syncedStrike(battle, t, { b2 }) {
const tb = t.def.skill?.bb ?? {};
const scale = num(tb['2.atk_scale'], num(b2.atk_scale, 1));
const dur = num(tb['2.duration'], num(b2['ling_s2_unmovable.duration']));
const n = Math.max(1, Math.floor(num(b2.value, 2)));
const targets = acquireTargets(battle, t, { ...t.profile, maxTargets: n, allInRange: false });
t.atkCd = t.s.interval; // InterruptCharacterAttack (resetCD): its attack starts over
const strike = (e) => {
if (!e || !e.alive) return;
battle.dealDamage(t, e, { amount: t.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', TAG_S2] });
if (e.alive && dur > 0) battle.applyStatus(e, 'bind', { duration: dur, source: t });
};
for (const e of targets.slice(0, n)) {
if (t.profile.attack === 'ranged') {
battle.addProjectile({ from: t, target: e, speed: PROJECTILE_SPEEDS.bolt, visual: 'bolt', source: t, onHit: (c) => strike(c.target) });
} else strike(e);
}
}
export default {
char_2023_ling: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const t0 = talentBb({ talents: chess?.talentsBase ?? chess?.talents }, 0); // 挑灯问梦: cnt
const t1 = talentBb(chess, 1); // 随付笺咏醉屠苏: sp, atk, max_stack_cnt
const sumY = moduleOn(chess) && chess.module.id === SUM_Y ? Math.max(0, Math.floor(num(traitBb(chess).cnt))) : 0;
const picked = chess?.skill?.skillId ?? null;
const tokenId = skillRec(chess, picked)?.overrideTokenKey ?? null;
const opts = { b1, b2, b3 };
const gain = (unit, n) => unit.mem.summonDeck?.gain(n);
return {
skills: {
[S1]: {
kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) },
onStart({ unit }) { gain(unit, num(b1.cnt, 1)); }, // 技能开启时获得1个召唤物
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
targeting: { maxTargets: Math.max(1, Math.floor(num(b2.value, 2))) },
attack: {
atkScale: num(b2.atk_scale, 1),
onEachHit({ battle, unit, target, kind }) {
const d = num(b2['ling_s2_unmovable.duration']);
if (kind === 'main' && target && target.alive && d > 0) battle.applyStatus(target, 'bind', { duration: d, source: unit });
},
},
onStart({ battle, unit }) {
const deck = unit.mem.summonDeck;
if (!deck) return;
for (const t of deck.standing()) syncedStrike(battle, t, opts);
// 技能结束时回收生命值低于一半的召唤物 (Recycle, 1.2 s after the cast)
const hp = num(b2.hp_ratio, 0.5);
battle.after(RECYCLE_DELAY, () => {
if (!up(unit)) return;
for (const t of deck.standing()) if (t.hpRatio < hp - 1e-9) deck.recall(t);
}, { owner: unit });
},
},
[S3]: {
kind: 'duration', mods: { atkPct: num(b3.atk), defPct: num(b3.def) },
onEnd({ unit, reason }) { if (reason !== 'death') gain(unit, num(b3.cnt, 1)); }, // 技能结束时获得1个召唤物
},
},
talents: [
{ install(battle, unit) { // 挑灯问梦: the deck of her summon pieces and their kit
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
const deckBase = num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, cnt);
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
const sp = num(t1.sp), atk = num(t1.atk), stacks = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1)));
summonDeck(battle, unit, {
tokenIds: SOULS,
charge: cnt,
start: sumY,
cap: deckBase + num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : 0, 0),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
kit: (t) => soulKit(t, unit, opts),
// 随付笺咏醉屠苏: knocked out / absorbed / recalled ⇒ +sp SP and a stack of ATK +atk (not when they leave with her)
onLeave(t, kind) {
if (!(kind === 'killed' || kind === 'absorb' || kind === 'recall') || !up(unit)) return;
if (sp > 0 && giveSp(unit, sp) > 0) battle.fx('spGain', { x: unit.x, y: unit.y, id: unit.id, n: sp });
if (atk) battle.addBuff(unit, { key: 'ling:t2', mods: { atkPct: atk }, refresh: 'stack', maxStacks: stacks, tags: ['talent'] });
},
});
const sync = (c) => {
if (c.unit !== unit) return;
for (const t of unit.mem.summonDeck.standing()) syncSoul(battle, unit, t, opts);
};
battle.on('skillStart', sync, { owner: unit });
battle.on('skillEnd', sync, { owner: unit });
} },
// 随付笺咏醉屠苏 lives in the deck's onLeave above
],
};
},
};
+202
View File
@@ -0,0 +1,202 @@
// server/sim/content/kits/ops/op-mgllan.js — 麦哲伦 (char_248_mgllan) 自选 operator kit: 6★ 召唤师 (辅助), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (SUM-X 专业无人机操作模块, SUM-Y
// 教学用龙腾无人机) at every form, and the kits of her drones 龙腾.F / 龙腾.L / 龙腾.A (token_10005_mgllan_drone1 / 2 / 3).
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"); the summon deck: ../shared/summoner.js.
//
// Forms (data/backups.json units.char_248_mgllan): 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 / the tokens' character_table rows (zh_CN, as built
// into backups.json); PRTS 麦哲伦 (光学折射配件 备注 "无人机实际获得隐匿(异常效果)而非隐匿"; S1 / S2 / S3 备注 "技能结束时的回收
// 仅是触发召唤物的技能,技能无法触发的场合不会产生效果"; S3 备注 "主动开启时,无人机攻击的爆炸半径从0.75增加至1.25"; SUM-Y 特性
// "※龙腾.F:部署费用-1;龙腾.L、龙腾.A:部署费用-3", stage 3 "※龙腾.F:生命上限+100,防御+50 / 龙腾.L:攻击+40,攻击速度+3 /
// 龙腾.A:攻击+50,攻击速度+3"); PRTS 龙腾.F / 龙腾.L / 龙腾.A (备注 "持有禁疗"; 龙腾.A "普通攻击的爆炸范围半径0.75,可对空");
// PRTS 分支特性信息 召唤师 ("可对空", "干员离场后,附属的召唤物随之消失"); the client's battle data (charpack char_248_mgllan:
// Talents/1 charge_token[born], CommonAbilities die_to_kill_token, Modes S1_Default / S1 with the S1[Passive] / S1[Active]
// buffs; buff_template_data mgllan_s_1[passive] / [active] and mgllan_s_1_token[passive] / [active] (CreateBuffInRange on her
// attack range and, through CreateBuffToToken, each drone's: the DB `sluggish` / mgllan_frozen UNMOVABLE), mgllan_drone_t
// (INVISIBLE for hidden_duration at the drone's deployment), mgllan_drone_s[finish] (RechargeToken + Withdraw
// `_switchToDeadState`); skill prefabs skchr_mgllan_1 / 2 / 3 (`_buffs` = the drones' mode + bonus, `_passiveBuffs` = hers,
// mgllan_s[after] trigger_token_skill); equips mgllan_equip_1_* (free_summon: 部署位 only), mgllan_equip_1_2/3_p2
// (mgllan_drone_e_hidden INVISIBLE for e_hidden_duration on her inside a drone's x-5, `_removeBuffWhenTargetLeave`),
// mgllan_equip_2_* (trigger_charge_token: the trait's cnt)).
// - Trait (召唤师) "攻击造成法术伤害 / 可以使用召唤物协助作战": the profession default (ranged arts on her 3-1, hits air units —
// PRTS "可对空"), blocks 1, ground enemies target her. Her drones leave the field with her (shared/summoner.js).
// - T1 支援无人机·龙腾 "可以使用5个不阻挡敌人的无人机(最多同时部署3个),功能随技能选择而改变": the drone of her pick's skill
// (龙腾.F / .L / .A — the skills' overrideTokenKey) is the hand piece the player places; the deck of ../shared/summoner.js
// with charge = cnt (5) at each of her deployments, cap = the drone deck (its hidden talent's max_deck_stack_cnt 5, +the
// SUM-Y deckStack 3), at most 1 + its max_deploy_count (2 ⇒ 3; SUM-Y stage 3: 3 ⇒ 4) standing. Every drone holds 禁疗
// (PRTS; the data's `abnormal` lacks it [ASSUMED: given here]), blocks nothing, has no skill of its own here (its 回收 is
// her skills' end, below). 龙腾.F (ATK 0) makes no attack (the engine's rule for a 0-ATK summon; a commented-out PRTS
// note "缴械,普通攻击不造成伤害" agrees [ASSUMED]); 龙腾.L strikes one ground enemy on its own tile for arts damage; 龙腾.A
// fires physical shells that blow up within BLAST (0.75) of their target, air units too.
// - T2 光学折射配件 "无人机在部署后的20秒内处于隐匿状态": each drone holds 隐匿 (the anomaly — flag `stealth`: only an enemy it
// blocks could target it, enemy areas skip it) for its token talent's hidden_duration from each deployment. SUM-X stage
// 2+ "26秒…若麦哲伦在其周围四格则享受相同效果": the drones' 26 s (the module variant), and she holds 隐匿 while she stands on
// the x-5 of a drone still inside its e_hidden_duration window [ASSUMED: the window of that drone's deployment — the equip
// aura's buff lasts e_hidden_duration and drops when she leaves the x-5].
// - S1 高效制冷模块 (MANUAL, data DEFAULT, 15 s): passive (while S1 is picked and not running) every attack@interval (3) s from
// her deployment / the skill's end, every enemy (air units too) on her attack range and on each of her standing drones'
// ranges (龙腾.F: x-4) takes 停顿 attack@sluggish s; while it runs, every 3 s from the cast, 束缚 attack@frozen_duration s
// instead. It acts while she is stunned or silenced (the buff is neither — isStunnable / isSilenceable 0).
// - S2 激光开采模块 (MANUAL, data DEFAULT, 15 s): she and her drones ASPD +attack_speed; a 龙腾.L strikes every enemy on its tile
// (群体) while it runs. S3 武装打击模块 (MANUAL, data DEFAULT, 15 s): she and her drones ATK +atk; 龙腾.A's blast radius
// BLAST_S3 (1.25). Passive parts ("无人机可以部署在近战位 / 远程位"): placement — the piece's position, nothing in battle.
// - Every skill's end (the normal end, not her leaving: the drones go with her then) recalls every standing drone that can
// still trigger its own skill (not stunned / frozen / asleep, not silenced — PRTS "技能无法触发的场合不会产生效果"
// [ASSUMED: those]): +1 held each, the drone leaves and comes back on its tile its redeploy time (10 s) later as any piece.
// - Module SUM-X 专业无人机操作模块: trait "首个召唤物部署时不消耗部署位" — 部署位 are the prep's deploy cap, which summon pieces
// never use in this mode (server/match deployCount counts chess only): N/A; stage 2+ is T2's change above; stats.
// SUM-Y 教学用龙腾无人机: trait "召唤物持有上限+3,召唤物部署费用减少" — +3 held on the card at the battle start, cap +3, the
// drones' lower costs (the module variant); stage 2+ T1 "最多同时部署4个", stage 3 "属性更强的" (the module variant's stats).
import { num, talentBb, traitBb, moduleOn, skillRec, up } from '../shared/tier1.js';
import { summonDeck, holdBuff, tokenStat } from '../shared/summoner.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_mgllan_1';
const S2 = 'skchr_mgllan_2';
const S3 = 'skchr_mgllan_3';
export const DRONE_F = 'token_10005_mgllan_drone1';
export const DRONE_L = 'token_10005_mgllan_drone2';
export const DRONE_A = 'token_10005_mgllan_drone3';
const DRONES = Object.freeze([DRONE_F, DRONE_L, DRONE_A]);
const SUM_Y = 'uniequip_003_mgllan';
/** PRTS 龙腾.A 备注 "普通攻击的爆炸范围半径0.75"; S3 备注 "主动开启时,无人机攻击的爆炸半径从0.75增加至1.25". */
export const BLAST = 0.75;
export const BLAST_S3 = 1.25;
/** 光学折射配件 at E2 when the drone def lacks its hidden_duration (PRTS: 20 s). */
const HIDDEN_FALLBACK = 20;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const skillOn = (u, id) => !!(u && u.skill && u.skill.active && u.skill.id === id);
/** 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;
/** Her skills acting on a drone (held from each skill start / end and each drone deployment). */
function syncDrone(battle, owner, t, { b2, b3 }) {
if (!t.alive) return;
const s2 = skillOn(owner, S2), s3 = skillOn(owner, S3);
holdBuff(battle, t, 'mgllan:s2:drone', s2, { aspd: num(b2.attack_speed) });
holdBuff(battle, t, 'mgllan:s3:drone', s3, { atkPct: num(b3.atk) });
if (t.defId === DRONE_A) t.profile.splashRadius = s3 ? BLAST_S3 : BLAST;
}
/** The drones' kit: 禁疗, 光学折射配件's 隐匿 window, her skills' modes (龙腾.L 群体, 龙腾.A blast). `owner` = 麦哲伦. */
function droneKit(t, owner, opts) {
return {
skill: null,
talents: [],
trait: t.defId === DRONE_A ? { splashRadius: BLAST, splashScale: 1 } : null,
install(battle, d) {
battle.addBuff(d, { key: 'mgllan:drone:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
const hidden = num(tokTalent(d.def, 'hidden_duration')?.bb?.hidden_duration, HIDDEN_FALLBACK);
battle.on('deploy', (c) => {
if (c.unit !== d) return;
if (hidden > 0) {
battle.addBuff(d, { key: 'mgllan:drone:hidden', flags: { stealth: true }, duration: hidden, status: 'stealth', tags: ['talent'] });
battle.fx('stealth', { x: d.x, y: d.y, id: d.id });
}
syncDrone(battle, owner, d, opts);
}, { owner: d });
// S2: a 龙腾.L strikes every enemy on its tile
if (d.defId === DRONE_L) {
battle.on('beforeAttack', (c) => {
if (c.attacker !== d || !skillOn(owner, S2)) return;
const all = battle.enemiesInKeys(d.rangeKeys, d, c.profile);
if (all.length) c.targets = all;
}, { owner: d });
}
},
};
}
export default {
char_248_mgllan: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const t0 = talentBb({ talents: chess?.talentsBase ?? chess?.talents }, 0); // 支援无人机·龙腾: cnt
const sumY = moduleOn(chess) && chess.module.id === SUM_Y ? Math.max(0, Math.floor(num(traitBb(chess).cnt))) : 0;
const picked = chess?.skill?.skillId ?? null;
const tokenId = skillRec(chess, picked)?.overrideTokenKey ?? null;
const opts = { b2, b3 };
/** 技能结束时回收所有无人机 (her normal end; the drones that cannot trigger their skill stay). */
const recall = ({ unit, reason }) => {
if (reason === 'death') return;
const deck = unit.mem.summonDeck;
if (!deck) return;
for (const d of deck.standing()) if (d.canAct && !d.s.flags.silence) deck.recall(d);
};
return {
skills: {
[S1]: { kind: 'duration', onEnd: recall },
[S2]: { kind: 'duration', mods: { aspd: num(b2.attack_speed) }, onEnd: recall },
[S3]: { kind: 'duration', mods: { atkPct: num(b3.atk) }, onEnd: recall },
},
talents: [
{ install(battle, unit) { // 支援无人机·龙腾: the deck of her drone pieces and their kit
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
const deckBase = num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, cnt);
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
summonDeck(battle, unit, {
tokenIds: DRONES,
charge: cnt,
start: sumY,
cap: deckBase + num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : 0, 0),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
kit: (t) => droneKit(t, unit, opts),
});
// her skills on the standing drones: at each start / end (a drone deployed meanwhile syncs itself)
const sync = (c) => {
if (c.unit !== unit) return;
for (const d of unit.mem.summonDeck.standing()) syncDrone(battle, unit, d, opts);
};
battle.on('skillStart', sync, { owner: unit });
battle.on('skillEnd', sync, { owner: unit });
} },
{ install(battle, unit) { // 光学折射配件 (the drones' 隐匿: their kit); SUM-X stage 2+: hers beside a hidden drone
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
const et = tokTalent(tdef, 'e_hidden_duration');
const dur = num(et?.bb?.e_hidden_duration);
if (!(dur > 0)) return;
const grid = et.rangeGrid?.length ? et.rangeGrid : [[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]];
battle.on('tick', () => {
const deck = unit.mem.summonDeck;
const want = up(unit) && !!deck && deck.standing().some((d) => battle.time < d.deployedAt + dur - 1e-9
&& absoluteRangeKeys(grid, d.tileR, d.tileC, d.dir, 0).includes(unit.tileR * COLS + unit.tileC));
const has = unit.findBuff('mgllan:eHidden');
if (want && !has) battle.addBuff(unit, { key: 'mgllan:eHidden', flags: { stealth: true }, status: 'stealth', tags: ['module'] });
else if (!want && has) battle.removeBuff(unit, 'mgllan:eHidden');
}, { owner: unit });
} },
],
install(battle, unit) {
// S1 高效制冷模块: every attack@interval s, 停顿 (passive) / 束缚 (running) on her range and her drones' ranges
if (picked !== S1) return;
const iv = num(b1['attack@interval'], 3);
if (!(iv > 0)) return;
const restart = (c) => { if (c.unit === unit) unit.mem.mgllanPulseAt = battle.time + iv; };
battle.on('deploy', restart, { owner: unit });
battle.on('skillStart', restart, { owner: unit });
battle.on('skillEnd', restart, { owner: unit });
battle.on('tick', () => {
if (!up(unit) || !(battle.time + 1e-9 >= (unit.mem.mgllanPulseAt ?? Infinity))) return;
unit.mem.mgllanPulseAt += iv;
const active = skillOn(unit, S1);
const key = active ? 'bind' : 'sluggish';
const duration = num(active ? b1['attack@frozen_duration'] : b1['attack@sluggish']);
if (!(duration > 0)) return;
const done = new Set();
for (const src of [unit, ...(unit.mem.summonDeck?.standing() ?? [])]) {
for (const e of battle.enemiesInKeys(src.rangeKeys, src, { canHitFly: true })) {
if (done.has(e)) continue;
done.add(e);
battle.applyStatus(e, key, { duration, source: src });
}
}
battle.fx('coldWind', { x: unit.x, y: unit.y, id: unit.id, n: done.size });
}, { owner: unit });
},
};
},
};
+286
View File
@@ -0,0 +1,286 @@
// server/sim/content/kits/ops/op-radian.js — 电弧 (char_4195_radian) 自选 operator kit: 6★ 召唤师 (辅助), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots — not the 补位 stand-in Raidian (char_614_acsupo, standin-acsupo.js); every skill, both
// talents, the trait and both modules (SO-A 电弧特勤证章, SO-B 新起点) at every form, and the kits of her summons 戴乌 / 赛柯 /
// 桑特拉 (token_10051_radian_tower1, token_10052_radian_tower2, token_10053_radian_tower3). Kit contract and the 自选 rules:
// ../README.md ("How to add an operator (自选)"); the summon deck: ../shared/summoner.js.
//
// Forms (data/backups.json units.char_4195_radian): 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 / the tokens' character_table rows (zh_CN, as built
// into backups.json); PRTS 电弧 (加油~ 修正 "电弧自身" for the text's "其自身", 备注 "加成效果的数值每秒更新一次"; S1 备注 "技能期间
// 部署的戴乌在部署时也会获得屏障,此时计算屏障量可享受电弧第二天赋的生命值加成"; S2 备注 on 赛柯's bullets; S3 备注 "召唤物可对空",
// "停顿与法术脆弱效果为伤害附加效果,于本次伤害前生效", the 协同攻击); PRTS 戴乌 / 赛柯 / 桑特拉 (备注 "持有禁疗"; 赛柯 "不进行索敌,
// 会持续向前攻击…飞行速度5.0,最大飞行时间0.6秒,碰撞半径0.5,不可对空,无视迷彩…首个敌方单位…100%的物理普通伤害", the S2 bullet
// origins; 桑特拉's 协同请求 / 协同攻击 rules); PRTS 分支特性信息 召唤师; the client's battle data (charpack char_4195_radian:
// Talents/1 charge_token[born], Talents/2 hp_to_hp / atk_to_atk / def_to_def[final_addition] on her TOKEN-class allies every
// 1 s (鼓舞: FINAL_SCALER of the source's attribute), CommonAbilities die_to_kill_token, Talents/master_1 and LogKillBoss
// (`rogue_yan` only); buff_template_data radian_s_1[shield] / [shield_token] (BlockDamage ANY_ATTACK_EXCEPT_ELEMENT of
// hp_ratio × MAX_HP, the token's computed 0.03 s after it starts), radian_s_2[token] (the 赛柯 mode),
// radian_tower2_s_2[emit_projectile] (two more bullets), radian_s_3[token] (ON_OUTPUT_DAMAGE: DB sluggish +
// weak[magic][limit] on the target), token_radian_tower3_t[link_buff] / _s_3[trigger_link] / [prism_aoe] (the 协同攻击);
// skill prefabs skchr_radian_1 / 2 / 3 (`_buffs` = the summons' part, `_passiveBuffs` = hers + trigger_charge_token);
// battle_equip_table uniequip_002 / 003_radian (the talent parts gated to 集成战略: `rogue_yan`, validInGameTag roguelike)).
// - Trait (召唤师): the profession default (ranged arts on her 3-1, hits air units — PRTS "可对空"), blocks 1, ground enemies
// target her; her summons leave the field with her (shared/summoner.js).
// - T1 卡带里的灵感 "可以使用5个召唤物(最多同时部署3个),功能随技能选择而改变": the summon of her pick's skill (戴乌 / 赛柯 /
// 桑特拉 — the skills' overrideTokenKey) is the hand piece the player places; the deck of ../shared/summoner.js with charge =
// cnt (5), cap = the token deck (deckStack 5), at most its deployLimit (3) standing. Every summon holds 禁疗 (PRTS; the
// data's `abnormal` lacks it [ASSUMED: given here]). 戴乌 blocks 3 and strikes in melee on its own tile (physical); 赛柯
// blocks 2 and never picks a target: every attack interval it fires a bullet straight ahead (BULLET_SPEED tiles/s for
// BULLET_LIFE s, collision radius BULLET_RADIUS) that deals 100 % of its ATK as physical 普通伤害 to the first selectable
// ground enemy it touches (stunned / frozen: no shot; disarmed: none [ASSUMED]); 桑特拉 shoots arts on its 3-1, air units
// too, its splash data capped at 0 extra targets (prism_max_target / normal_aoe max_target 0). 协同攻击 (桑特拉's own rule,
// PRTS): when a 桑特拉 attacks, every other 桑特拉 of the field that can attack, is between attacks (its attack ready —
// `atkCd` 0 here) and has no valid target of its own, and has the asking 桑特拉 inside its attack range ("其自身范围内其他
// 电弧的召唤物"), shoots a link at it — the link's arrival deals prism_atk_scale × the responder's ATK as arts 普通伤害 to the
// first asker's target — and asks the others in turn; its attack restarts [ASSUMED: the link is its attack].
// - T2 加油~ "电弧的召唤物获得相当于电弧自身12%攻击力、防御力、生命值的鼓舞效果": every INSPIRE_IV (1) s and at each of their
// deployments, her summons on the field get 鼓舞 = ratio × her current ATK / DEF / max HP, added after their own
// multipliers, the strongest source of each kind kept (the keys 魔王 / 浊心斯卡蒂 use: inspire, inspire:def, inspire:hp).
// - S1 律动线 (MANUAL, data DEFAULT, 25 s): +cnt held at the cast; she and her summons DEF +def and a 屏障 of hp_ratio × their
// own max HP until the skill ends (every damage type but 元素伤害 — shieldType phys / arts / true); a summon deployed while it
// runs takes it at its deployment, after its 鼓舞 (PRTS). Passive "召唤物可部署在近战位": placement only.
// - S2 环形鳞地 (MANUAL, data DEFAULT, 25 s): +cnt held; she and her summons ATK +atk (base_attack_time 0: no change); 赛柯's
// attacks fire BULLET_OFFSETS (three bullets: its centre, 0.2 tile left-up and right-down — fixed, PRTS) that fly its token
// skill's attack@projectile_life_time s (0.6 at rank 4, 0.8 at rank 7: "子弹飞行距离+1"); its 3-2 range is unchanged at ranks
// 4 / 7 (PRTS). Passive "会持续向前方发射子弹": 赛柯's data.
// - S3 手牵手 (MANUAL, data DEFAULT, 30 s): +cnt held; she and her summons ATK +atk; before each damage of her attacks and of
// any damage of her summons on an enemy: 停顿 `sluggish` s and 法术脆弱 (artsFragile damage_scale − 1, "同名效果取最高") for
// weak[magic][limit] s (a dodged hit still carries them [ASSUMED]). Passive "召唤物可部署在远程位…协同攻击": 桑特拉 (above).
// - Modules SO-A 电弧特勤证章 / SO-B 新起点: their trait and talent parts act "在【岁的界园志异】中" only (the client gates them to
// the 集成战略 theme rogue_yan / validInGameTag roguelike): N/A here — the module talents the composed record carries
// (SO-A's hidden respawn_time / prob / sp, SO-B's attack@max_target and x-5 recall) are ignored; their attributes are in the
// stats. (DIY_EXCLUDED_MODULE_TYPE refuses ISW-* only, so a roster may carry them — see the report.)
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
import { summonDeck, holdBuff, tokenStat } from '../shared/summoner.js';
import { acquireTargets } from '../../../ai.js';
import { aggregateMods } from '../../../buffs.js';
import { dirVec } from '../../../dir.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_radian_1';
const S2 = 'skchr_radian_2';
const S3 = 'skchr_radian_3';
export const TOWER1 = 'token_10051_radian_tower1';
export const TOWER2 = 'token_10052_radian_tower2';
export const TOWER3 = 'token_10053_radian_tower3';
const TOWERS = Object.freeze([TOWER1, TOWER2, TOWER3]);
/** 加油~ "加成效果的数值每秒更新一次" (PRTS; Talents/2 triggerInterval 1). */
export const INSPIRE_IV = 1;
/** PRTS 赛柯 备注: "飞行速度5.0,最大飞行时间0.6秒,碰撞半径0.5". */
export const BULLET_SPEED = 5;
export const BULLET_LIFE = 0.6;
export const BULLET_RADIUS = 0.5;
/** PRTS S2 备注: the skill's bullets start at the tile centre, 0.2 left + 0.2 up and 0.2 right + 0.2 down (any facing). */
const BULLET_OFFSETS = Object.freeze([[0, 0], [-0.2, 0.2], [0.2, -0.2]]);
/** PRTS 桑特拉 备注 "不论是普通攻击还是协同攻击,弹道飞行速度均为50". */
const LINK_SPEED = 50;
const SHIELD_TYPES = Object.freeze(['phys', 'arts', 'true']);
const KEY_BARRIER = 'radian:s1:barrier';
const TAG_BULLET = 'radian:bullet';
const TAG_LINK = 'radian:link';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const skillOn = (u, id) => !!(u && u.skill && u.skill.active && u.skill.id === id);
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
/**
* 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` of `stat` after the target's own multipliers (the flat
* mod compensated), the strongest source kept — the keys and rule of 魔王's / 浊心斯卡蒂's 鼓舞.
*/
function inspire(battle, target, val, src, stat) {
if (!(val > 0) || !up(target) || target.mem?.noInspire) return;
const key = stat === 'atk' ? 'inspire' : `inspire:${stat}`;
const cur = target.findBuff(key);
if (cur && cur.data && cur.data.src !== src.id && cur.data.val > val && cur.timeLeft > 0.1) return;
const { add, mul } = aggregateMods(target.buffs.filter((b) => b.key !== key));
const f = stat === 'def' ? Math.max(0, 1 + (add.defPct ?? 0)) * (mul.defMul ?? 1)
: stat === 'hp' ? Math.max(0, 1 + (add.hpPct ?? 0)) * (mul.hpMul ?? 1)
: Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1);
const flat = f > 1e-6 ? val / f : val;
const mods = stat === 'def' ? { defFlat: flat } : stat === 'hp' ? { hpFlat: flat } : { atkFlat: flat };
battle.addBuff(target, { key, mods, duration: INSPIRE_IV + 0.25, refresh: 'replace', source: src, status: 'inspire', data: { src: src.id, val } });
}
/** 律动线's 屏障: hp_ratio × the holder's max HP, until the skill ends. */
function barrier(battle, u, owner, ratio) {
if (!(ratio > 0) || !up(u)) return;
const left = owner.skill?.active ? owner.skill.timeLeft : 0;
if (!(left > 0)) return;
battle.addBuff(u, { key: KEY_BARRIER, shield: u.s.maxHp * ratio, shieldType: SHIELD_TYPES, duration: left, tags: ['skill'] });
battle.fx('shield', { x: u.x, y: u.y, id: u.id });
}
/** 协同攻击: every 桑特拉 of the field that can answer `asker` shoots its link and asks the others in turn (see the header). */
function relayLink(battle, asker, target, seen) {
for (const r of battle.allyUnits) {
if (r.kind !== 'token' || r.defId !== TOWER3 || seen.has(r) || !up(r) || !r.canAct || r.s.flags.disarm || r.atkCd > 1e-9) continue;
if (!r.rangeKeySet || !r.rangeKeySet.has(asker.tileR * COLS + asker.tileC)) continue;
if (acquireTargets(battle, r, r.profile).length) continue;
seen.add(r);
r.atkCd = r.s.interval;
const amount = r.s.atk * num(tokTalent(r.def, 'prism_atk_scale')?.bb?.prism_atk_scale, 1);
battle.addProjectile({
from: r, target: asker, speed: LINK_SPEED, visual: 'bolt', source: r, hitDead: true,
onHit: () => { if (target.alive) battle.dealDamage(r, target, { amount, type: 'arts', isAttack: true, tags: [TAG_LINK] }); },
});
battle.fx('link', { x: r.x, y: r.y, id: r.id, to: asker.id });
relayLink(battle, r, target, seen);
}
}
/** 赛柯: one volley straight ahead (one bullet; three while S2 runs). */
function fireVolley(battle, t, owner) {
const s2 = skillOn(owner, S2);
const life = s2 ? num(t.def.skill?.bb?.['attack@projectile_life_time'], BULLET_LIFE) : BULLET_LIFE;
const [fr, fc] = dirVec(t.dir);
const list = (t.mem.radianBullets ??= []);
for (const [dx, dy] of s2 ? BULLET_OFFSETS : [[0, 0]]) {
list.push({ x: t.x + dx, y: t.y + dy, vx: fc * BULLET_SPEED, vy: fr * BULLET_SPEED, life, atk: t.s.atk });
}
battle.fx(s2 ? 'volley' : 'strike', { x: t.x, y: t.y, id: t.id, n: s2 ? BULLET_OFFSETS.length : 1 });
}
/** Move 赛柯's bullets one tick: the first selectable ground enemy within the radius takes the hit; expired ones vanish. */
function flyBullets(battle, t, dt) {
const list = t.mem.radianBullets;
if (!list || !list.length) return;
const keep = [];
for (const b of list) {
const hit = () => {
const near = battle.foesInRadius(b.x, b.y, BULLET_RADIUS).filter((e) => e.alive && !e.isFlying);
if (!near.length) return false;
near.sort((p, q) => Math.hypot(p.x - b.x, p.y - b.y) - Math.hypot(q.x - b.x, q.y - b.y) || p.spawnSeq - q.spawnSeq);
battle.dealDamage(t, near[0], { amount: b.atk, type: 'phys', isAttack: true, tags: [TAG_BULLET] });
return true;
};
if (hit()) continue;
const step = Math.min(dt, b.life);
b.x += b.vx * step;
b.y += b.vy * step;
b.life -= step;
if (hit()) continue;
if (b.life > 1e-9) keep.push(b);
}
t.mem.radianBullets = keep;
}
/** Her summons' kit: 禁疗, her skills on them (stats, 屏障), 鼓舞 at each deployment, 赛柯's bullets, 桑特拉's 协同攻击. */
function towerKit(t, owner, opts) {
return {
skill: null,
talents: [],
trait: t.defId === TOWER2 ? { noAttack: true } : null,
install(battle, s) {
battle.addBuff(s, { key: 'radian:tower:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
battle.on('deploy', (c) => {
if (c.unit !== s) return;
s.mem.radianFireCd = 0;
opts.sync(s); // her running skill first: the 鼓舞's flat part is set against its multipliers
opts.inspireOne(s);
if (skillOn(owner, S1)) barrier(battle, s, owner, opts.hpRatio); // 技能期间部署的戴乌在部署时也会获得屏障 (with its 鼓舞 HP)
}, { owner: s });
if (s.defId === TOWER2) {
battle.on('tick', (c) => {
const dt = c.dt ?? battle.dt;
flyBullets(battle, s, dt);
if (!up(s) || !s.canAct) return;
s.mem.radianFireCd = Math.max(0, (s.mem.radianFireCd ?? 0) - dt);
if (s.s.flags.disarm || s.mem.radianFireCd > 1e-9) return;
fireVolley(battle, s, owner);
s.mem.radianFireCd = s.s.interval;
}, { owner: s });
}
if (s.defId === TOWER3) {
battle.on('attack', (c) => {
if (c.attacker !== s) return;
const target = (c.targets || []).find((e) => e && e.side === 'enemy' && e.alive);
if (target) relayLink(battle, s, target, new Set([s]));
}, { owner: s });
}
},
};
}
export default {
char_4195_radian: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const t0 = talentBb({ talents: chess?.talentsBase ?? chess?.talents }, 0); // 卡带里的灵感: cnt (the module copies' IS keys ignored)
const t1 = talentBb({ talents: chess?.talentsBase ?? chess?.talents }, 1); // 加油~: atk / def / max_hp
const picked = chess?.skill?.skillId ?? null;
const tokenId = skillRec(chess, picked)?.overrideTokenKey ?? null;
const hpRatio = num(b1.hp_ratio);
const gain = (unit, n) => unit.mem.summonDeck?.gain(n);
return {
skills: {
[S1]: {
kind: 'duration', mods: { defPct: num(b1.def) },
onStart({ battle, unit }) {
gain(unit, num(b1.cnt, 1)); // 技能开启时获得1个召唤物
barrier(battle, unit, unit, hpRatio);
for (const t of unit.mem.summonDeck?.standing() ?? []) barrier(battle, t, unit, hpRatio);
},
onEnd({ battle, unit }) {
for (const u of [unit, ...(unit.mem.summonDeck?.pieces() ?? [])]) if (u.findBuff(KEY_BARRIER)) battle.removeBuff(u, KEY_BARRIER);
},
},
[S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, onStart({ unit }) { gain(unit, num(b2.cnt, 1)); } },
[S3]: { kind: 'duration', mods: { atkPct: num(b3.atk) }, onStart({ unit }) { gain(unit, num(b3.cnt, 1)); } },
},
talents: [
{ install(battle, unit) { // 卡带里的灵感: the deck of her summon pieces and their kit
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
const ratios = { atk: num(t1.atk), def: num(t1.def), hp: num(t1.max_hp) };
const inspireOne = (s) => {
if (!up(unit)) return;
inspire(battle, s, unit.s.atk * ratios.atk, unit, 'atk');
inspire(battle, s, unit.s.def * ratios.def, unit, 'def');
inspire(battle, s, unit.s.maxHp * ratios.hp, unit, 'hp');
};
const sync = (s) => {
if (!s.alive) return;
holdBuff(battle, s, 'radian:s1:tower', skillOn(unit, S1), { defPct: num(b1.def) });
holdBuff(battle, s, 'radian:s2:tower', skillOn(unit, S2), { atkPct: num(b2.atk) });
holdBuff(battle, s, 'radian:s3:tower', skillOn(unit, S3), { atkPct: num(b3.atk) });
};
const opts = { inspireOne, sync, hpRatio };
summonDeck(battle, unit, {
tokenIds: TOWERS,
charge: cnt,
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt) + num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, 0),
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
kit: (t) => towerKit(t, unit, opts),
});
// her skills on the standing summons at each start / end (a summon deployed meanwhile syncs itself); 鼓舞 again
const onSkill = (c) => {
if (c.unit !== unit) return;
for (const s of unit.mem.summonDeck.standing()) { sync(s); inspireOne(s); }
};
battle.on('skillStart', onSkill, { owner: unit });
battle.on('skillEnd', onSkill, { owner: unit });
// 加油~: every second
if (ratios.atk > 0 || ratios.def > 0 || ratios.hp > 0) {
battle.every(INSPIRE_IV, () => { for (const s of unit.mem.summonDeck.standing()) inspireOne(s); }, { owner: unit });
}
} },
// 加油~ lives in the deck's install above (its 鼓舞 at each deployment and every second)
],
install(battle, unit) {
// S3 手牵手: before each damage of her attacks / of her summons' damage on an enemy, 停顿 + 法术脆弱
if (picked !== S3) return;
const slug = num(b3.sluggish), dur = num(b3['weak[magic][limit]']), frag = num(b3.damage_scale, 1) - 1;
battle.on('hit', (c) => {
const src = c.source, e = c.target;
if (!src || !e || e.side !== 'enemy' || !e.alive || !c.dmg || c.dmg.type === 'element' || !skillOn(unit, S3)) return;
const mine = src === unit ? !!c.dmg.isAttack : (src.kind === 'token' && src.ownerUnit === unit && TOWERS.includes(src.defId));
if (!mine) return;
if (slug > 0) battle.applyStatus(e, 'sluggish', { duration: slug, source: src });
if (frag > 0 && dur > 0) battle.applyStatus(e, 'artsFragile', { duration: dur, value: frag, source: src });
}, { owner: unit, priority: 100 });
},
};
},
};
+139
View File
@@ -0,0 +1,139 @@
// server/sim/content/kits/shared/summoner.js — the 召唤师 (summoner) branch in 卫戍: the summon deck the 自选 kits of 麦哲伦
// (ops/op-mgllan.js), 令 (ops/op-ling.js) and 电弧 (ops/op-radian.js) share — the holding (持有), the return of a placed piece
// that left the field, recalls (回收) and the summons vanishing with their owner. Kit contract and rules: ../README.md.
//
// Sources: the client's battle data (charpack char_248_mgllan / char_2023_ling / char_4195_radian: Talents/1
// charge_token[born] — RechargeToken `cnt` at every deployment of the owner; CommonAbilities die_to_kill_token — KillTokens
// when the owner leaves; the SUM-Y equips mgllan_equip_2_* / ling_equip_1_*: trigger_charge_token — RechargeToken the
// trait's `cnt` once, on the undeployed card (`_attachPassiveBuffsOnDummy`); the token decks: max_deck_stack_cnt /
// max_deploy_count of the tokens' hidden talent, deckStack / deployLimit of the token records); PRTS 分支特性信息 召唤师
// ("干员离场后,附属的召唤物随之消失"); PRTS 卫戍协议/帮助 §作战阶段 (placed summons: "所有手动部署的召唤物,无视所属干员的持有
// 状态,不消耗持有数量,作战开始时立即部署一次", "若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个").
//
// The deck (`summonDeck`):
// - holding: `start` on the undeployed card (SUM-Y's +3), then +`charge` at each deployment of the owner, at most `cap`
// (the token deck: 5, +3 with SUM-Y); a skill's "获得1个召唤物" and a recall add 1 (deck.gain / deck.recall);
// - the battle start deploys every placed piece for free without touching the holding (the engine's initial deployment);
// - a piece that leaves the field — knocked out, recalled, absorbed, withdrawn with its owner — comes back on its own tile
// once "满足条件": its owner on the field [ASSUMED: as in the base game, a summon is deployed only while its owner
// stands], its redeploy time (the token's respawnTime) passed since it left, one held (used up), fewer than
// `maxDeployed` of the owner's pieces standing ("最多同时部署3个") and its DP cost paid [ASSUMED: "满足条件" = what a manual
// deployment needs; the pieces of 夜莺 / 鸿雪 / 凯尔希 pay too];
// - the owner leaving (knocked out or withdrawn) takes its standing pieces off the field with it (KillTokens — withdrawn
// here, no knock-down: 'retreat' [ASSUMED]); they wait for its next deployment like any piece that left.
// `onLeave(piece, kind, ctx)` tells the kit why a piece left: 'killed', 'recall', 'absorb' (令's 弦惊 merge), 'owner' (with
// its owner), else the engine's removal reason.
import { num, up } from './tier1.js';
/** Seconds between two tries of a piece waiting to come back (its owner off the field, timer, holding, DP, tile). */
export const DECK_RETRY = 0.25;
/**
* Keep buff `key` with `mods` on `u` while `want` (replaced when the mods change, removed when not wanted) — the owner's
* timed skills acting on its summons, held every tick so a summon deployed during the skill takes them too.
*/
export function holdBuff(battle, u, key, want, mods, extra = null) {
const has = u.findBuff(key);
if (!want) { if (has) battle.removeBuff(u, key); return; }
const cur = has?.mods ?? {};
if (has && Object.keys(mods).length === Object.keys(cur).length && Object.keys(mods).every((k) => cur[k] === mods[k])) return;
battle.addBuff(u, { key, mods, tags: ['skill'], ...extra });
}
/**
* A field of a summon's token record at its owner's form and module (data/backups.json `tokens[id].variants[<charId>@
* <status>]`, `byModule[uniEquipId]`): the stats the normalised def drops (`deployLimit`, `deckStack`).
* @param {object} battle
* @param {object} owner the summoner unit
* @param {string} tokenId
* @param {string} key a `stats` field
*/
export function tokenStat(battle, owner, tokenId, key) {
const raw = battle.data?.rawToken?.(tokenId) ?? null;
const v = raw?.variants?.[owner?.def?.tokenOwner] ?? null;
const mod = owner?.def?.loadout?.diy?.uniEquipId ?? owner?.def?.loadout?.moduleId ?? null;
const st = (mod && v?.byModule?.[mod]?.stats) || v?.stats || raw?.stats || null;
return st && Number.isFinite(st[key]) ? st[key] : (Number.isFinite(raw?.[key]) ? raw[key] : null);
}
/**
* Install the summon deck of `owner` (see the header) and hand its placed pieces their kit.
* @param {object} battle
* @param {object} owner the summoner unit
* @param {{ tokenIds: Iterable<string>, charge: number, start?: number, cap: number, maxDeployed?: number,
* kit?: ((piece: object) => object) | null, onLeave?: ((piece: object, kind: string, ctx: object) => void) | null }} opts
* @returns {object} the deck: { held, cap, charge, maxDeployed, pieces(), standing(), gain(n), recall(t), withdraw(t, kind) }
*/
export function summonDeck(battle, owner, { tokenIds, charge, start = 0, cap, maxDeployed = Infinity, kit = null, onLeave = null }) {
const ids = new Set(tokenIds);
const c = Math.max(0, Math.floor(num(cap, 0)));
const deck = {
held: Math.min(c, Math.max(0, Math.floor(num(start, 0)))),
cap: c,
charge: Math.max(0, Math.floor(num(charge, 0))),
maxDeployed: num(maxDeployed, Infinity) > 0 ? num(maxDeployed, Infinity) : Infinity,
/** Every placed piece of the owner (on the field or waiting). */
pieces: () => battle.allyUnits.filter((t) => t.kind === 'token' && t.ownerUnit === owner && ids.has(t.defId)),
/** The pieces on the field. */
standing: () => deck.pieces().filter(up),
/** "获得N个召唤物": +n held, at most the cap. */
gain(n = 1) { deck.held = Math.min(deck.cap, deck.held + Math.max(0, Math.floor(num(n, 0)))); },
/** Take a standing piece off the field for `kind` (it comes back like any piece that left). */
withdraw(t, kind) {
if (!up(t)) return false;
t.mem.deckLeave = kind;
battle.retreat(t, { reason: 'retreat' });
return true;
},
/** 回收: the piece goes back to the holding (+1) and leaves the field (its redeploy timer runs: `_switchToDeadState`). */
recall(t) {
if (!up(t)) return false;
deck.gain(1);
battle.fx('disappear', { x: t.x, y: t.y, id: t.id });
return deck.withdraw(t, 'recall');
},
};
owner.mem.summonDeck = deck;
/** The piece comes back on its tile once the conditions are met (see the header); one waiting loop per leave. */
const waitAndReturn = (t) => {
const seq = (t.mem.deckSeq = (t.mem.deckSeq ?? 0) + 1);
battle.every(DECK_RETRY, (b, sched) => {
if (t.mem.deckSeq !== seq || t.alive || t.removed || b.finished) { sched.cancel(); return; }
if (!up(owner) || b.time + 1e-9 < t.mem.deckReadyAt || deck.held < 1) return;
if (deck.standing().length >= deck.maxDeployed) return;
if (b.redeploy(t, { free: false })) {
deck.held = Math.max(0, deck.held - 1);
sched.cancel();
}
}, { owner: t });
};
for (const t of deck.pieces()) {
if (t.alive || t.deployed) continue;
if (kit) {
if (t.kit) battle.offOwner(t); // a piece set up before its owner: drop its generic kit's hooks
battle._setupUnit(t, kit(t));
}
// a piece that left stays its tile's piece (`removed` false: Battle.redeploy accepts it, its hooks are kept)
battle.on('death', (ctx) => {
if (ctx.unit !== t || battle.finished) return;
const kind = t.mem.deckLeave ?? (ctx.reason === 'killed' ? 'killed' : ctx.reason);
t.mem.deckLeave = null;
t.removed = false;
t.mem.deckReadyAt = battle.time + Math.max(0, num(t.base.respawnTime));
if (onLeave) battle._safe(() => onLeave(t, kind, ctx), 'summonDeck.onLeave', t);
waitAndReturn(t);
}, { owner: t, priority: -10 });
}
// charge_token[born]: +charge at every deployment of the owner (the battle start's included)
battle.on('deploy', (ctx) => { if (ctx.unit === owner) deck.gain(deck.charge); }, { owner, priority: 100 });
// die_to_kill_token: "干员离场后,附属的召唤物随之消失"
battle.on('death', (ctx) => {
if (ctx.unit !== owner) return;
for (const t of deck.standing()) deck.withdraw(t, 'owner');
}, { owner, priority: 50 });
return deck;
}
+314
View File
@@ -0,0 +1,314 @@
// test/content/op_ling.test.js — the 自选 operator kit of 令 (char_2023_ling, 6★ 召唤师; kit
// server/sim/content/kits/ops/op-ling.js, the summon deck of kits/shared/summoner.js) and of her summons “清平” / “逍遥” /
// “弦惊”, fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; the summons as the placed hand pieces of her
// player) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or
// SUM-Y 诗短梦长 at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_ling.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 LING = 'char_2023_ling';
const FORMS = BACKUPS.units[LING].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SUMY = 'uniequip_002_ling';
const S1 = 'skchr_ling_1', S2 = 'skchr_ling_2', S3 = 'skchr_ling_3';
const Q = 'token_10020_ling_soul1', X = 'token_10020_ling_soul2', J = 'token_10020_ling_soul3';
const SOUL_OF = [Q, X, J];
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 tokOf = (tok, tier, elite, skill, mod) => {
let v = BACKUPS.tokens[tok].variants[`${LING}@${statusOf(tier, elite)}`];
if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] };
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_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SUMY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** 令 as uid 1 at (row, col) facing RIGHT; `souls` = [[tokenId, row, col, dir?]] placed pieces (uid 10 +). */
function field({ tier = 5, elite = false, mod = null, skill = 0, souls = [], row = 10, col = 3, seed = 5, dp = 99 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, captureNoisy: true,
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack', 'dodge'],
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LING, skillIndex: skill, uniEquipId: mod }, elite, row, col },
...souls.map(([tokenId, r, c, dir], i) => ({ uid: 10 + i, kind: 'token', tokenId, ownerUid: 1, row: r, col: c, dir: dir ?? 'RIGHT' }))],
});
h.step();
const u = h.unit(1);
return { h, u, ss: h.b.allyUnits.filter((a) => a.kind === 'token'), deck: u.mem.summonDeck };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('令 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-1, ranged arts, hits air, blocks 1, ground-targetable, the data triggers (DEFAULT), 炎, no 特质', () => {
assert.equal(OPERATOR_KITS[LING], KITS[LING]);
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], [LING, 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.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 召唤师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.skill.rule, form.skills[skill].trigger.rule], ['DEFAULT', 'DEFAULT'], `${label(f)}: the data's trigger`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 852 / 406 / 114, E2 Lv60 1002 / 450 / 130; SUM-Y +100 / +30 → +150 / +50 HP / ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [852, 406, 1002, 450]);
assert.deepEqual([modOf(5, SUMY).attr, modOf(6, SUMY).attr], [{ maxHp: 100, atk: 30 }, { maxHp: 150, atk: 50 }]);
});
test('令\'s skills in every form: the data\'s SP (time SP, spCost, initSp, charges) and her 3-1 range while each runs (none changes it)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const sk = formOf(tier, elite).skills[skill];
const { h, u } = field({ tier, elite, mod, skill });
assert.deepEqual([u.skill.spType, u.skill.spCost, u.skill.maxCharges], ['time', sk.spCost, sk.maxChargeTime], `${label(f)} S${skill + 1}: SP`);
assert.ok(Math.abs(u.skill.sp - sk.initSp) < 0.1, `${label(f)} S${skill + 1}: initSp ${sk.initSp} (${u.skill.sp})`);
h.spawn('enemy_dummy', { pos: [10, 4] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations > 0, 3), `${label(f)} S${skill + 1}: cast with an enemy in her range`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)} S${skill + 1}: 3-1 while it runs`);
done(h);
}
}
});
test('a 自选 pick: 令 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(LING));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LING), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: LING, skillIndex: 1, uniEquipId: SUMY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: LING, skillIndex: 1, uniEquipId: SUMY } } });
});
test('T1 挑灯问梦: her skill\'s summon is her piece; the deck — 5 held, cap 5 (8 with SUM-Y: +3 on the card), at most 3 standing (4 at SUM-Y stage 3); every summon holds 禁疗 and fights with its data (清平 1-1 melee physical blocks 1, 逍遥 3-1 arts hits air, 弦惊 1-1 blocks 2 strikes all it blocks)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const tok = SOUL_OF[skill];
const { h, u, ss, deck } = field({ tier, elite, mod, skill, souls: [[tok, 10, 6]] });
const s = ss[0], v = tokOf(tok, tier, elite, skill, mod);
const y = elite && mod === SUMY;
assert.ok(h.b.producesToken(u, tok) && SOUL_OF.filter((x) => x !== tok).every((x) => !h.b.producesToken(u, x)), `${label(f)} S${skill + 1}: only ${tok}`);
assert.deepEqual([deck.charge, deck.cap, deck.held, deck.maxDeployed], [5, y ? 8 : 5, y ? 8 : 5, y && tier === 6 ? 4 : 3], `${label(f)} S${skill + 1}: deck`);
assert.deepEqual([s.base.maxHp, s.base.atk, s.base.def, s.base.res, s.base.cost, s.base.respawnTime, s.base.bat], [v.stats.maxHp, v.stats.atk, v.stats.def, v.stats.res, v.stats.cost, v.stats.respawnTime, v.stats.bat], `${label(f)}: summon stats`);
assert.deepEqual([s.alive, !!s.s.flags.noHeal, s.kit.skill], [true, true, null], `${label(f)}: deployed, 禁疗, no skill`);
assert.deepEqual(s.liveRangeGrid, v.rangeGrid, `${label(f)}: its range`);
const want = { [Q]: ['melee', 'phys', false, 1, false], [X]: ['ranged', 'arts', true, 1, false], [J]: ['melee', 'phys', false, 2, true] }[tok];
assert.deepEqual([s.profile.attack, s.profile.dmgType, s.profile.canHitFly, s.s.blockCnt, !!s.profile.hitAllBlocked], want, `${label(f)}: ${tok}`);
done(h);
}
}
// SUM-Y: 清平 / 逍遥 −3 DP, 弦惊 −5 ("部署费用-3 / -5"); stage 3 清平 +150 HP +45 ATK, 逍遥 +100 / +35, 弦惊 +250 / +60
assert.deepEqual(SOUL_OF.map((t, i) => tokOf(t, 6, false, i, null).stats.cost - tokOf(t, 6, true, i, SUMY).stats.cost), [3, 3, 5]);
assert.deepEqual(SOUL_OF.map((t, i) => [tokOf(t, 6, true, i, SUMY).stats.maxHp - tokOf(t, 6, true, i, null).stats.maxHp, tokOf(t, 6, true, i, SUMY).stats.atk - tokOf(t, 6, true, i, null).stats.atk]), [[150, 45], [100, 35], [250, 60]]);
});
test('T2 随付笺咏醉屠苏: a summon knocked out, recalled or absorbed ⇒ +3 SP and a stack of ATK +3 % (at most 5); not when her summons leave with her; no SP while a timed skill of hers runs', () => {
const { h, u, ss } = field({ tier: 6, elite: true, skill: 0, souls: [[Q, 10, 6], [Q, 11, 6], [Q, 9, 6]] });
const t1 = formOf(6, true).talents.find((t) => t.index === 1).bb;
assert.deepEqual(t1, { max_stack_cnt: 5, sp: 3, atk: 0.03 });
const atk0 = u.s.atk;
const sp0 = u.skill.sp;
h.b.kill(ss[0], null);
approx(u.skill.sp - sp0, 3, '+3 SP', 1e-3);
assert.equal(u.findBuff('ling:t2').stacks, 1, 'one stack');
approx(u.s.atk, atk0 * 1.03, 'ATK +3 %');
for (let i = 0; i < 6; i++) { h.b.kill(ss[1 + (i % 2)], null); h.runUntil(() => ss.every((s) => s.alive), 15); }
assert.equal(u.findBuff('ling:t2').stacks, 5, 'at most 5 stacks');
approx(u.s.atk, atk0 * 1.15, 'ATK +15 %');
// no SP while S1 runs
h.spawn('enemy_dummy', { pos: [10, 4] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'S1 cast');
const spOn = u.skill.sp;
h.b.kill(ss[0], null);
assert.equal(u.skill.sp, spOn, 'no SP during a timed skill');
// her leaving: the summons go with her, nothing more
const st = u.findBuff('ling:t2').stacks;
h.b.retreat(u);
assert.equal(u.findBuff('ling:t2'), null, 'the stacks leave with her');
assert.equal(st, 5);
done(h);
});
test('S1 重进酒: +1 held at the cast; she and her summons ATK +29 % / +38 % and ASPD +29 / +38 for 25 s; her summons\' attacks deal arts damage meanwhile, physical before and after', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, ss, deck } = field({ tier, elite, mod, skill: 0, souls: [[Q, 10, 6]] });
const s = ss[0];
assert.deepEqual([sk.bb.atk, sk.bb.attack_speed, sk.bb.cnt, sk.duration], [elite ? 0.38 : 0.29, elite ? 38 : 29, 1, 25]);
h.spawn('enemy_dummy', { pos: [10, 7] }); // its 1-1 (outside her 3-1)
h.spawn('enemy_dummy', { pos: [11, 4] }); // hers
h.run(3);
assert.ok(from(h, s).length && from(h, s).every((c) => c.type === 'phys'), 'physical before');
deck.held = 2;
const atk0 = u.s.atk, satk0 = s.s.atk;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
assert.equal(deck.held, 3, '技能开启时获得1个召唤物');
approx(u.s.atk, atk0 * (1 + sk.bb.atk), 'her ATK');
approx(s.s.atk, satk0 * (1 + sk.bb.atk), 'its ATK');
assert.deepEqual([u.s.aspd, s.s.aspd], [100 + sk.bb.attack_speed, 100 + sk.bb.attack_speed], 'ASPD');
const n0 = from(h, s).length;
h.run(4);
assert.ok(from(h, s).slice(n0).length && from(h, s).slice(n0).every((c) => c.type === 'arts'), 'arts during S1');
// knocked out during it: back 10 s later with the skill's bonus and arts
h.b.kill(s, null);
assert.ok(h.runUntil(() => s.alive, 10.5) && u.skill.active, 'back while it runs');
assert.deepEqual(s.findBuff('ling:s1:soul')?.mods, { atkPct: sk.bb.atk, aspd: sk.bb.attack_speed }, 'a summon deployed during it takes the bonus');
h.runUntil(() => !u.skill.active, 30);
approx(h.b.time, h.hooksOf('skillStart').at(-1).skill.lastStart + 25, '25 s', 0.01);
const n1 = from(h, s).length;
h.run(3);
assert.ok(from(h, s).slice(n1).every((c) => c.type === 'phys') && s.s.aspd === 100, 'physical and its ASPD back');
done(h);
}
});
test('S2 笑鸣瑟 (2 charges): her cast-attack strikes 2 enemies for 310 % / 370 % ATK arts and 束缚 2 / 2.5 s each; every summon of hers breaks off and strikes 2 enemies of its range for its 2.atk_scale × its ATK + 束缚 2.duration s (silenced / disarmed ones too); 1.2 s later the summons below half HP are recalled (+1 held, T2)', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S2);
const { h, u, ss, deck } = field({ tier, elite, mod, skill: 1, souls: [[X, 10, 5], [X, 11, 5], [X, 9, 5]] });
const [hurt, muted, fine] = ss;
const tv = tokOf(X, tier, elite, 1, mod);
assert.deepEqual([u.skill.maxCharges, sk.bb.atk_scale, sk.bb['ling_s2_unmovable.duration'], sk.bb.value, sk.bb.hp_ratio, tv.skill.bb['2.atk_scale'], tv.skill.bb['2.duration']],
[2, elite ? 3.7 : 3.1, elite ? 2.5 : 2, 2, 0.5, elite ? 3.7 : 3.1, elite ? 2.5 : 2]);
const a = h.spawn('enemy_dummy', { pos: [10, 5] }), b = h.spawn('enemy_dummy', { pos: [10, 6] }), c = h.spawn('enemy_dummy', { pos: [11, 6] });
hurt.hp = hurt.s.maxHp * 0.45;
fine.hp = fine.s.maxHp * 0.55;
h.b.applyStatus(muted, 'silence', { duration: 30, force: true });
h.b.applyStatus(muted, 'disarm', { duration: 30, force: true });
deck.held = 1;
u.skill.gainSp(999);
const sp0 = u.skill.sp;
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'cast on her attack');
const castAt = h.b.time;
h.run(0.6);
const mine = from(h, u).filter((x) => x.dmg.isSkill);
assert.equal(mine.length, 2, 'her two targets');
for (const x of mine) approx(x.amount, u.s.atk * sk.bb.atk_scale, 'her scale');
const binds = h.hooksOf('statusApplied').filter((x) => x.status === 'bind');
assert.ok(binds.some((x) => x.source === u) && binds.filter((x) => x.source === u).every((x) => Math.abs(x.duration - sk.bb['ling_s2_unmovable.duration']) < 1e-9), 'her 束缚');
for (const s of ss) {
const hits = from(h, s).filter((x) => x.dmg.tags?.includes('ling:s2'));
assert.equal(hits.length, 2, `${s.uid}: two targets`);
for (const x of hits) approx(x.amount, s.s.atk * tv.skill.bb['2.atk_scale'] * (1 - x.target.s.res / 100), `${s.uid}: its scale`);
assert.ok(binds.filter((x) => x.source === s).length === 2, `${s.uid}: 束缚 ×2`);
}
const struck = new Set(h.hooksOf('damaged').filter((x) => x.dmg?.isSkill).map((x) => x.target));
assert.ok(struck.size >= 2 && [...struck].every((e) => e.s.flags.bind), 'every struck enemy bound');
void a; void b; void c;
// 1.2 s after the cast: the summon below half HP is recalled (T2: +3 SP, a stack)
h.runUntil(() => h.b.time >= castAt + 1.1, 2);
assert.ok(hurt.alive, 'not before 1.2 s');
const spBefore = u.skill.sp;
assert.ok(h.runUntil(() => !hurt.alive, 0.2), 'recalled at 1.2 s');
assert.deepEqual([hurt.alive, muted.alive, fine.alive], [false, true, true], 'only the one below half');
assert.equal(deck.held, 2, '+1 held');
assert.ok(Math.abs(u.skill.sp - spBefore - 3) < 0.2, `T2: +3 SP (${spBefore} → ${u.skill.sp})`);
assert.equal(u.findBuff('ling:t2').stacks, 1, 'T2 stack');
void sp0;
done(h);
}
});
test('S3 宁作吾: she and her summons ATK +55 % / +70 %, DEF +50 % / +60 % for 25 / 30 s; every 0.5 s each summon\'s x-5 ground enemies take 20 % of 令\'s ATK as arts (her damage, not dodgeable; no air, nothing outside); +1 held at its end', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, ss, deck } = field({ tier, elite, mod, skill: 2, souls: [[J, 10, 6, 'RIGHT']] });
const s = ss[0];
assert.deepEqual([sk.bb.atk, sk.bb.def, sk.bb.atk_scale, sk.bb.interval, sk.bb.cnt, sk.duration], [elite ? 0.7 : 0.55, elite ? 0.6 : 0.5, 0.2, 0.5, 1, elite ? 30 : 25]);
const front = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [11, 6] });
const fly = h.spawn('enemy_fly', { pos: [9, 6] }), diag = h.spawn('enemy_dummy', { pos: [11, 7] });
h.spawn('enemy_dummy', { pos: [10, 4] });
deck.held = 0;
const atk0 = u.s.atk, satk0 = s.s.atk, sdef0 = s.s.def;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
approx(u.s.atk, atk0 * (1 + sk.bb.atk), 'her ATK');
approx(s.s.atk, satk0 * (1 + sk.bb.atk), 'its ATK');
approx(s.s.def, sdef0 * (1 + sk.bb.def), 'its DEF');
h.run(3.05);
const pulses = h.hooksOf('damaged').filter((x) => x.dmg?.tags?.includes('ling:s3'));
assert.ok(pulses.length > 0, 'pulses');
assert.ok(pulses.every((x) => x.source === u), '令\'s damage');
assert.deepEqual(new Set(pulses.map((x) => x.target)), new Set([front, side]), 'its x-5 ground enemies only');
for (const x of pulses) { approx(x.amount, u.s.atk * 0.2, '20 % of her ATK'); assert.deepEqual([x.type, x.dmg.canDodge, x.dmg.isSplash], ['arts', false, true]); }
assert.equal(pulses.filter((x) => x.target === front).length, 6, 'every 0.5 s (6 in 3 s)');
void fly; void diag;
h.runUntil(() => !u.skill.active, 40);
assert.equal(deck.held, 1, '技能结束时获得1个召唤物');
done(h);
}
});
test('the 弦惊 merge: another basic 弦惊 on a new one\'s range ⇒ that one turns 高级形态 and the new one is absorbed (T2); a new one on the range of basic ones ⇒ it turns 高级形态 and the latest of them is absorbed; 高级形态 = HP / ATK / DEF ×(1 + 1 / 0.8 / 0.8), RES ×2, interval +0.8 s, block +2, arts, full HP; never with a 高级形态 one', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const tb = tokOf(J, tier, elite, 2, mod).skill.bb;
assert.deepEqual(tb, { '2.max_hp': 1, '2.atk': 0.8, '2.def': 0.8, '2.magic_resistance': 1, '2.base_attack_time': 0.8, '2.block_cnt': 2 });
// case 1: B (deployed second) faces A — A on B's range ⇒ A evolves, B absorbed
const { h, u, ss } = field({ tier, elite, mod, skill: 2, souls: [[J, 9, 5, 'RIGHT'], [J, 9, 6, 'LEFT']] });
const [a, b] = ss;
assert.deepEqual([a.alive, a.mem.lingAdvanced, b.alive], [true, true, false], 'case 1');
const base = tokOf(J, tier, elite, 2, mod).stats;
approx(a.s.maxHp, base.maxHp * 2, 'HP ×2');
approx(a.hp, a.s.maxHp, 'full HP');
approx(a.s.atk, base.atk * 1.8, 'ATK ×1.8');
approx(a.s.def, base.def * 1.8, 'DEF ×1.8');
approx(a.s.res, base.res * 2, 'RES ×2');
approx(a.s.interval, base.bat + 0.8, 'interval +0.8 s');
assert.equal(a.s.blockCnt, base.blockCnt + 2, 'block +2');
assert.equal(u.findBuff('ling:t2')?.stacks, 1, 'T2: absorbed');
// its attacks are arts
h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => from(h, a).some((x) => x.dmg?.isAttack), 60), 'it fights');
assert.ok(from(h, a).filter((x) => x.dmg?.isAttack).every((x) => x.type === 'arts'), 'arts');
// the absorbed one comes back basic beside it (no merge with a 高级形态 one)
assert.ok(h.runUntil(() => b.alive, 25), 'the absorbed piece comes back');
assert.deepEqual([b.mem.lingAdvanced, a.mem.lingAdvanced, a.alive], [false, true, true], 'no second merge');
done(h);
// case 2: A faces B and B deploys later — B on A's range ⇒ B evolves, A (the latest such) absorbed
const r2 = field({ tier, elite, mod, skill: 2, souls: [[J, 10, 5, 'RIGHT'], [J, 10, 6, 'RIGHT']] });
const [a2, b2] = r2.ss;
assert.deepEqual([a2.alive, b2.alive, b2.mem.lingAdvanced], [false, true, true], 'case 2');
// not facing each other: no merge
const r3 = field({ tier, elite, mod, skill: 2, souls: [[J, 10, 5, 'LEFT'], [J, 10, 6, 'RIGHT']] });
assert.ok(r3.ss.every((x) => x.alive && !x.mem.lingAdvanced), 'apart: none');
done(r2.h);
done(r3.h);
}
});
test('SUM-Y 诗短梦长: +3 held on the card and cap 8 (5 + 3) at both stages, 4 standing at stage 3; the module\'s talent text is the stage-3 T1', () => {
const { h, deck } = field({ tier: 5, elite: true, mod: SUMY, skill: 0 });
assert.deepEqual([deck.held, deck.cap, deck.maxDeployed], [8, 8, 3], 'stage 1');
done(h);
const { h: h2, deck: d2 } = field({ tier: 6, elite: true, mod: SUMY, skill: 0 });
assert.deepEqual([d2.held, d2.cap, d2.maxDeployed], [8, 8, 4], 'stage 3');
assert.match(modOf(6, SUMY).talentChanges[0].desc, /最多同时部署4个/);
done(h2);
});
+349
View File
@@ -0,0 +1,349 @@
// test/content/op_mgllan.test.js — the 自选 operator kit of 麦哲伦 (char_248_mgllan, 6★ 召唤师; kit
// server/sim/content/kits/ops/op-mgllan.js, the summon deck of kits/shared/summoner.js) and of her drones 龙腾.F / .L / .A,
// fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; the drone as the placed hand piece of her player)
// in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SUM-X
// 专业无人机操作模块 or SUM-Y 教学用龙腾无人机 at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity
// checklist of kits/README.md.
// Run: node --test test/content/op_mgllan.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 { canTargetAlly } from '../../server/sim/targeting.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 MG = 'char_248_mgllan';
const FORMS = BACKUPS.units[MG].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SUMX = 'uniequip_002_mgllan', SUMY = 'uniequip_003_mgllan';
const S1 = 'skchr_mgllan_1', S2 = 'skchr_mgllan_2', S3 = 'skchr_mgllan_3';
const F = 'token_10005_mgllan_drone1', L = 'token_10005_mgllan_drone2', A = 'token_10005_mgllan_drone3';
const DRONE_OF = [F, L, A];
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);
/** A drone's variant of a form, with the pick's skill / module (getDiyToken). */
const tokOf = (tok, tier, elite, skill, mod) => {
let v = BACKUPS.tokens[tok].variants[`${MG}@${statusOf(tier, elite)}`];
if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] };
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_archer: dummy('enemy_archer', { range: 3, atk: 100 }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SUMX, SUMY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** 麦哲伦 as uid 1 at (row, col) facing RIGHT; `drones` = [[tokenId, row, col, dir?]] placed pieces (uid 10 +). */
function field({ tier = 5, elite = false, mod = null, skill = 0, drones = [], row = 10, col = 4, seed = 5, dp = 99 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, captureNoisy: true,
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack'],
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: MG, skillIndex: skill, uniEquipId: mod }, elite, row, col },
...drones.map(([tokenId, r, c, dir], i) => ({ uid: 10 + i, kind: 'token', tokenId, ownerUid: 1, row: r, col: c, dir: dir ?? 'RIGHT' }))],
});
h.step();
const u = h.unit(1);
return { h, u, ds: h.b.allyUnits.filter((a) => a.kind === 'token'), deck: u.mem.summonDeck };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src && c.dmg?.isAttack);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('麦哲伦 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-1, ranged arts, hits air, blocks 1, ground-targetable, the data triggers (DEFAULT), 协防, no 特质', () => {
assert.equal(OPERATOR_KITS[MG], KITS[MG]);
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], [MG, 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.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 召唤师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.skill.rule, form.skills[skill].trigger.rule], ['DEFAULT', 'DEFAULT'], `${label(f)}: the data's trigger`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 871 / 403 / 116, E2 Lv60 1025 / 447 / 132; SUM-X +100 / +30 → +150 / +50 HP /
// ATK, SUM-Y +25 / +25 → +40 / +40 ATK / DEF
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [871, 403, 1025, 447]);
assert.deepEqual([modOf(5, SUMX).attr, modOf(6, SUMX).attr, modOf(5, SUMY).attr, modOf(6, SUMY).attr], [{ maxHp: 100, atk: 30 }, { maxHp: 150, atk: 50 }, { atk: 25, def: 25 }, { atk: 40, def: 40 }]);
});
test('麦哲伦\'s skills in every form: the data\'s SP (time SP, spCost, initSp, charges) and her 3-1 range while each runs (none changes it)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const sk = formOf(tier, elite).skills[skill];
const { h, u } = field({ tier, elite, mod, skill });
assert.deepEqual([u.skill.spType, u.skill.spCost, u.skill.maxCharges], ['time', sk.spCost, sk.maxChargeTime], `${label(f)} S${skill + 1}: SP`);
assert.ok(Math.abs(u.skill.sp - sk.initSp) < 0.1, `${label(f)} S${skill + 1}: initSp ${sk.initSp} (${u.skill.sp})`);
h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations > 0, 3), `${label(f)} S${skill + 1}: cast with an enemy in her range`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)} S${skill + 1}: 3-1 while it runs`);
done(h);
}
}
});
test('a 自选 pick: 麦哲伦 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks with either module', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(MG));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MG), `tier ${t}`);
for (const uniEquipId of [SUMX, SUMY, null]) {
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: MG, skillIndex: 2, uniEquipId } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: MG, skillIndex: 2, uniEquipId } } });
}
});
test('T1 支援无人机·龙腾: her skill\'s drone is her piece (the others are not hers to field); the deck — 5 held at her deployment, cap 5 (8 with SUM-Y, which adds 3 on the card), at most 3 standing (4 at SUM-Y stage 3); every drone holds 禁疗, blocks nothing, has its data and no skill', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const tok = DRONE_OF[skill];
const { h, u, ds, deck } = field({ tier, elite, mod, skill, drones: [[tok, 11, 6]] });
const d = ds[0], v = tokOf(tok, tier, elite, skill, mod);
const y = elite && mod === SUMY;
assert.ok(h.b.producesToken(u, tok) && DRONE_OF.filter((x) => x !== tok).every((x) => !h.b.producesToken(u, x)), `${label(f)} S${skill + 1}: only ${tok}`);
assert.deepEqual([deck.charge, deck.cap, deck.held, deck.maxDeployed], [5, y ? 8 : 5, y ? 8 : 5, y && tier === 6 ? 4 : 3], `${label(f)} S${skill + 1}: deck`);
assert.deepEqual([d.alive, d.ownerUnit, d.kit.skill, d.skill.noSkill || !d.kit.skill], [true, u, null, true], `${label(f)}: deployed, hers, no skill`);
assert.deepEqual([d.base.maxHp, d.base.atk, d.base.def, d.base.cost, d.base.respawnTime, d.base.bat], [v.stats.maxHp, v.stats.atk, v.stats.def, v.stats.cost, v.stats.respawnTime, v.stats.bat], `${label(f)}: drone stats`);
assert.deepEqual([d.s.blockCnt, !!d.s.flags.noHeal], [0, true], `${label(f)}: no block, 禁疗`);
assert.deepEqual(d.liveRangeGrid, v.rangeGrid, `${label(f)}: drone range`);
if (tok === F) assert.deepEqual([d.profile.noAttack, d.base.atk], [true, 0], '龙腾.F: no attack (ATK 0)');
if (tok === L) assert.deepEqual([d.profile.attack, d.profile.dmgType, d.profile.canHitFly, d.liveRangeGrid], ['melee', 'arts', false, [[0, 0]]], '龙腾.L: melee arts on its tile');
if (tok === A) assert.deepEqual([d.profile.attack, d.profile.dmgType, d.profile.canHitFly, d.profile.splashRadius], ['ranged', 'phys', true, 0.75], '龙腾.A: shells, air too, blast 0.75');
done(h);
}
}
// the SUM-Y drones: F 2 / L 4 / A 5 DP ("部署费用-1 / -3"); stage 3 F 2100 HP / 250 DEF, L ATK 515, A ATK 749, ASPD +3
assert.deepEqual([F, L, A].map((t, i) => tokOf(t, 5, true, i, SUMY).stats.cost), [2, 4, 5]);
assert.deepEqual([tokOf(F, 6, true, 0, SUMY).stats.maxHp, tokOf(F, 6, true, 0, SUMY).stats.def, tokOf(L, 6, true, 1, SUMY).stats.atk, tokOf(A, 6, true, 2, SUMY).stats.atk, tokOf(A, 6, true, 2, SUMY).stats.aspd], [2100, 250, 515, 749, 103]);
});
test('the deck: a knocked-out drone comes back on its tile its redeploy time (10 s) later, paying its cost and using one held; with none held it stays off; the drones leave with her and come back after her next deployment (+5 held)', () => {
const { h, u, ds, deck } = field({ tier: 6, elite: true, mod: SUMX, skill: 2, drones: [[A, 11, 6], [A, 9, 6]] });
const [d1, d2] = ds;
const p = h.b.players[0];
h.b.kill(d1, null);
assert.deepEqual([d1.alive, d1.removed], [false, false], 'the piece stays');
h.run(9.5);
assert.equal(d1.alive, false, 'not before 10 s');
const dp0 = p.dp;
assert.ok(h.runUntil(() => d1.alive, 1), 'back after 10 s');
assert.deepEqual([d1.tileR, d1.tileC, deck.held], [11, 6, 4], 'on its tile, one held used');
approx(dp0 - p.dp, d1.base.cost, 'its cost paid');
// none held: it stays off
deck.held = 0;
h.b.kill(d1, null);
h.run(15);
assert.equal(d1.alive, false, 'none held');
deck.held = 2;
assert.ok(h.runUntil(() => d1.alive, 1), 'back once one is held');
// she leaves: her drones go with her (no knock-down); back after her redeployment
h.b.retreat(u);
h.step();
assert.deepEqual([d1.alive, d2.alive], [false, false], 'withdrawn with her');
assert.ok(h.hooksOf('death').filter((c) => c.unit === d1 || c.unit === d2).slice(-2).every((c) => c.reason === 'retreat'), 'withdrawn, not knocked out');
h.run(20);
assert.deepEqual([d1.alive, d2.alive], [false, false], 'never while she is off the field');
deck.held = 0;
assert.ok(h.b.redeploy(u), 'she comes back');
assert.ok(h.runUntil(() => d1.alive && d2.alive, 1), 'her drones follow');
assert.equal(deck.held, 3, '+5 at her deployment, two used');
done(h);
});
test('T2 光学折射配件: a drone is 隐匿 for 20 s (26 s with SUM-X stage 3) from each deployment — no ranged enemy targets it meanwhile; SUM-X stage 3: 麦哲伦 beside a drone inside that window is 隐匿 too (not two tiles away, not at stage 1)', () => {
for (const [tier, elite, mod] of [[5, false, null], [5, true, SUMX], [6, true, SUMX], [6, true, SUMY]]) {
const x3 = elite && mod === SUMX && tier === 6;
const want = x3 ? 26 : 20;
const { h, u, ds } = field({ tier, elite, mod, skill: 1, drones: [[L, 10, 5], [L, 11, 7]] });
const [near, far] = ds;
assert.equal(tokOf(L, tier, elite, 1, mod).talents.find((t) => t.bb.hidden_duration != null).bb.hidden_duration, want, `${label([tier, elite, mod])}: data`);
const archer = h.spawn('enemy_archer', { pos: [11, 9] });
assert.ok(near.s.flags.stealth && far.s.flags.stealth, '隐匿 at deployment');
assert.ok(!canTargetAlly(archer, far, true), 'a ranged enemy cannot target it');
assert.equal(!!u.s.flags.stealth, x3, `${label([tier, elite, mod])}: her own 隐匿 beside the drone`);
h.run(want - 0.5);
assert.ok(near.s.flags.stealth, 'still 隐匿');
assert.equal(!!u.s.flags.stealth, x3, 'hers still');
h.run(1);
assert.ok(!near.s.flags.stealth && !far.s.flags.stealth, `over after ${want} s`);
assert.ok(canTargetAlly(archer, far, true), 'targetable again');
assert.ok(!u.s.flags.stealth, 'hers over with it');
// a new deployment: a new window
h.b.kill(far, null);
h.runUntil(() => far.alive, 12);
assert.ok(far.s.flags.stealth, 'again at its next deployment');
done(h);
}
// her 隐匿 needs the drone on one of her four neighbouring tiles
const { h, u } = field({ tier: 6, elite: true, mod: SUMX, skill: 1, drones: [[L, 10, 6]] });
assert.ok(!u.s.flags.stealth, 'two tiles away: no');
done(h);
});
test('S1 高效制冷模块: passive — every 3 s from her deployment 停顿 0.8 / 0.9 s on every enemy of her 3-1 and her 龙腾.F\'s x-4 (air units too), none elsewhere; running (15 s) — 束缚 1.9 / 2.2 s every 3 s from the cast instead; it ends recalling her drones', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, ds, deck } = field({ tier, elite, mod, skill: 0, row: 10, col: 3, drones: [[F, 11, 8]] });
const d = ds[0];
assert.deepEqual([sk.bb['attack@interval'], sk.bb['attack@sluggish'], sk.bb['attack@frozen_duration'], sk.duration], [3, elite ? 0.9 : 0.8, elite ? 2.2 : 1.9, 15]);
const at = (pos, key = 'enemy_dummy') => h.spawn(key, { pos });
const her = at([10, 5]), fly = at([11, 4], 'enemy_fly'), droneE = at([12, 9]), out = at([9, 9]);
const st = (status) => h.hooksOf('statusApplied').filter((c) => c.status === status);
h.run(2.9);
assert.equal(st('sluggish').length, 0, 'nothing before 3 s');
h.run(0.2);
const first = st('sluggish');
assert.deepEqual(new Set(first.map((c) => c.target)), new Set([her, fly, droneE]), 'her range + the drone\'s x-4');
for (const c of first) approx(c.duration, sk.bb['attack@sluggish'], 'duration');
assert.equal(first.find((c) => c.target === droneE).source, d, 'the drone\'s own area');
assert.ok(!first.some((c) => c.target === out), 'outside: untouched');
h.run(3);
assert.equal(st('sluggish').length, 6, 'again 3 s later');
// the cast: 束缚 every 3 s instead
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast on an attack');
const t0 = h.b.time, n0 = st('sluggish').length;
h.run(2.9);
assert.equal(st('bind').length, 0, 'no 束缚 before 3 s into it');
h.run(0.2);
assert.deepEqual(new Set(st('bind').map((c) => c.target)), new Set([her, fly, droneE]), '束缚 on the same areas');
for (const c of st('bind')) approx(c.duration, sk.bb['attack@frozen_duration'], '束缚 duration');
h.runUntil(() => !u.skill.active, 15);
assert.equal(st('sluggish').length, n0, 'no 停顿 while it runs');
approx(h.b.time - t0, 15, '15 s', 0.01);
// 技能结束时回收所有无人机
h.step();
assert.deepEqual([d.alive, d.removed, deck.held], [false, false, deck.cap], 'recalled (+1 held, at most the cap)');
assert.ok(h.runUntil(() => d.alive, 10.5), 'back on its tile 10 s later');
done(h);
}
});
test('S2 激光开采模块: she and her drones ASPD +80 / +100 for 15 s; a 龙腾.L strikes every enemy on its tile while it runs (one at a time otherwise); recalled at the end', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMX]]) {
const sk = skillOf(tier, elite, S2);
const { h, u, ds } = field({ tier, elite, mod, skill: 1, row: 10, col: 3, drones: [[L, 10, 6]] });
const d = ds[0];
const a = h.spawn('enemy_dummy', { pos: [10, 6] }), b = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(3);
const before = from(h, d);
assert.ok(before.length >= 2, 'it attacks');
assert.ok(new Set(before.map((c) => c.dmg.attackId)).size === before.length, 'one target per attack');
for (const c of before) assert.deepEqual([c.type, Math.abs(c.amount - Math.max(d.s.atk * 0.05, d.s.atk * (1 - c.target.s.res / 100))) < 1e-6], ['arts', true], 'arts');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
assert.deepEqual(d.findBuff('mgllan:s2:drone')?.mods, { aspd: sk.bb.attack_speed }, 'the drone\'s bonus');
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, 'her ASPD');
assert.equal(d.s.aspd, 100 + sk.bb.attack_speed, 'the drone\'s');
const n0 = from(h, d).length;
h.run(3);
const during = from(h, d).slice(n0);
const byAttack = new Map();
for (const c of during) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c.target]);
assert.ok([...byAttack.values()].every((ts) => ts.length === 2 && ts.includes(a) && ts.includes(b)), '群体: both enemies of its tile each attack');
// knocked out during it: back 10 s later, still inside the 15 s — with the skill's ASPD
h.b.kill(d, null);
assert.ok(h.runUntil(() => d.alive, 10.5) && u.skill.active, 'back while it runs');
assert.equal(d.s.aspd, 100 + sk.bb.attack_speed, 'a drone deployed during it takes its ASPD');
h.runUntil(() => !u.skill.active, 15);
h.step();
assert.deepEqual([d.alive, u.s.aspd], [false, 100], 'recalled; her ASPD back');
assert.ok(h.runUntil(() => d.alive, 10.5), 'back');
assert.equal(d.findBuff('mgllan:s2:drone'), null, 'no bonus after it');
done(h);
}
});
test('S3 武装打击模块: she and her drones ATK +80 % / +100 % for 15 s; a 龙腾.A\'s blast radius 0.75 → 1.25 while it runs; recalled at the end', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, ds } = field({ tier, elite, mod, skill: 2, row: 10, col: 3, drones: [[A, 10, 5]] });
const d = ds[0];
const main = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [10, 8] });
const close = h.spawn('enemy_fly', { pos: [10.6, 7] });
h.spawn('enemy_dummy', { pos: [11, 4] }); // hers (outside the drone's range)
h.run(5);
const before = from(h, d);
assert.ok(before.some((c) => c.target === main) && before.some((c) => c.target === close), 'its target and a flyer 0.6 away');
assert.ok(!before.some((c) => c.target === side), 'one 1.0 away: outside 0.75');
for (const c of before.filter((x) => x.target === main)) approx(c.amount, d.s.atk, 'phys (DEF 0)');
const atk0 = u.s.atk, datk0 = d.s.atk;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
approx(u.s.atk, atk0 * (1 + sk.bb.atk) / 1, 'her ATK +', 1e-9);
approx(d.s.atk, datk0 * (1 + sk.bb.atk), 'the drone\'s ATK +');
assert.equal(d.profile.splashRadius, 1.25, 'blast 1.25');
const n0 = from(h, d).length;
h.run(5);
assert.ok(from(h, d).slice(n0).some((c) => c.target === side), 'now 1.0 away too');
h.runUntil(() => !u.skill.active, 15);
h.step();
assert.equal(d.alive, false, 'recalled');
assert.ok(h.runUntil(() => d.alive, 10.5), 'back');
assert.deepEqual([d.profile.splashRadius, d.s.atk], [0.75, datk0], 'blast and ATK back');
done(h);
}
});
test('the recall triggers each drone\'s own skill: a stunned or silenced drone stays (PRTS 备注 "技能无法触发的场合不会产生效果"); none when she leaves the field with the skill running (her drones go with her)', () => {
const { h, u, ds } = field({ tier: 6, elite: true, mod: SUMX, skill: 2, row: 10, col: 3, drones: [[A, 10, 5], [A, 11, 5], [A, 9, 5]] });
const [ok, stunned, silenced] = ds;
h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
u.skill.timeLeft = 0.2;
h.b.applyStatus(stunned, 'stun', { duration: 5, force: true });
h.b.applyStatus(silenced, 'silence', { duration: 5, force: true });
h.runUntil(() => !u.skill.active, 1);
h.step();
assert.deepEqual([ok.alive, stunned.alive, silenced.alive], [false, true, true], 'only the free one');
// her leaving with the skill on: withdrawn with her, no recall (no +1 held)
const { h: h2, u: u2, ds: ds2, deck } = field({ tier: 5, elite: false, skill: 2, row: 10, col: 3, drones: [[A, 10, 5]] });
h2.spawn('enemy_dummy', { pos: [10, 5] });
u2.skill.gainSp(999);
h2.runUntil(() => u2.skill.active, 3);
deck.held = 2;
h2.b.kill(u2, null);
h2.step();
assert.deepEqual([ds2[0].alive, deck.held], [false, 2], 'gone with her, nothing held more');
done(h);
done(h2);
});
test('modules: SUM-X\'s trait (首个召唤物部署时不消耗部署位) is a prep deploy-cap rule — the deck is the same as without it; SUM-Y\'s +3 held on the card and cap 8 hold through her redeployment (+5, at most 8)', () => {
const { h, deck } = field({ tier: 6, elite: true, mod: SUMX, skill: 0 });
assert.deepEqual([deck.held, deck.cap, deck.maxDeployed], [5, 5, 3], 'SUM-X: the base deck');
assert.equal(formOf(6, true).modules.find((m) => m.uniEquipId === SUMX).traitOverride.bb.cnt, 1, 'its trait blackboard (unused)');
done(h);
const { h: h2, u, deck: d2 } = field({ tier: 5, elite: true, mod: SUMY, skill: 0 });
assert.deepEqual([d2.held, d2.cap, d2.maxDeployed], [8, 8, 3], 'SUM-Y stage 1: 3 + 5 held, cap 8, 3 standing');
d2.held = 1;
h2.b.retreat(u);
h2.b.redeploy(u);
assert.equal(d2.held, 6, '+5 at her next deployment');
h2.b.retreat(u);
h2.b.redeploy(u);
assert.equal(d2.held, 8, 'at most 8');
done(h2);
});
+308
View File
@@ -0,0 +1,308 @@
// test/content/op_radian.test.js — the 自选 operator kit of 电弧 (char_4195_radian, 6★ 召唤师; kit
// server/sim/content/kits/ops/op-radian.js, the summon deck of kits/shared/summoner.js) and of her summons 戴乌 / 赛柯 /
// 桑特拉, fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; the summons as the placed hand pieces of her
// player) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module,
// SO-A 电弧特勤证章 or SO-B 新起点 at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of
// kits/README.md.
// Run: node --test test/content/op_radian.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 RAD = 'char_4195_radian';
const FORMS = BACKUPS.units[RAD].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SOA = 'uniequip_002_radian', SOB = 'uniequip_003_radian';
const S1 = 'skchr_radian_1', S2 = 'skchr_radian_2', S3 = 'skchr_radian_3';
const DIVE = 'token_10051_radian_tower1', CYCLE = 'token_10052_radian_tower2', CENTRE = 'token_10053_radian_tower3';
const TOWER_OF = [DIVE, CYCLE, CENTRE];
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 tokOf = (tok, tier, elite, skill, mod) => {
let v = BACKUPS.tokens[tok].variants[`${RAD}@${statusOf(tier, elite)}`];
if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] };
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' }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SOA, SOB].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** 电弧 as uid 1 at (row, col) facing RIGHT; `towers` = [[tokenId, row, col, dir?]] placed pieces (uid 10 +). */
function field({ tier = 5, elite = false, mod = null, skill = 0, towers = [], row = 10, col = 3, seed = 5, dp = 99 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, captureNoisy: true,
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack'],
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: RAD, skillIndex: skill, uniEquipId: mod }, elite, row, col },
...towers.map(([tokenId, r, c, dir], i) => ({ uid: 10 + i, kind: 'token', tokenId, ownerUid: 1, row: r, col: c, dir: dir ?? 'RIGHT' }))],
});
h.step();
const u = h.unit(1);
return { h, u, ts: h.b.allyUnits.filter((a) => a.kind === 'token'), deck: u.mem.summonDeck };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('电弧 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-1, ranged arts, hits air, blocks 1, ground-targetable, the data triggers (DEFAULT), 协防, no 特质', () => {
assert.equal(OPERATOR_KITS[RAD], KITS[RAD]);
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], [RAD, 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.dmgType, u.profile.canHitFly, u.profile.maxTargets, u.base.bat], [1, 'ranged', 'arts', true, 1, 1.6], `${label(f)}: 召唤师 (SO-B's 额外攻击一个目标 is 集成战略-only)`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.skill.rule, form.skills[skill].trigger.rule], ['DEFAULT', 'DEFAULT'], `${label(f)}: the data's trigger`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 844 / 400 / 118, E2 Lv60 993 / 444 / 135; SO-A +125 / +27 → +165 / +43 HP / ATK,
// SO-B +41 → +65 ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [844, 400, 993, 444]);
assert.deepEqual([modOf(5, SOA).attr, modOf(6, SOA).attr, modOf(5, SOB).attr, modOf(6, SOB).attr], [{ maxHp: 125, atk: 27 }, { maxHp: 165, atk: 43 }, { atk: 41 }, { atk: 65 }]);
});
test('电弧\'s skills in every form: the data\'s SP (time SP, spCost, initSp, charges) and her 3-1 range while each runs (none changes it)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const sk = formOf(tier, elite).skills[skill];
const { h, u } = field({ tier, elite, mod, skill });
assert.deepEqual([u.skill.spType, u.skill.spCost, u.skill.maxCharges], ['time', sk.spCost, sk.maxChargeTime], `${label(f)} S${skill + 1}: SP`);
assert.ok(Math.abs(u.skill.sp - sk.initSp) < 0.1, `${label(f)} S${skill + 1}: initSp ${sk.initSp} (${u.skill.sp})`);
h.spawn('enemy_dummy', { pos: [10, 4] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations > 0, 3), `${label(f)} S${skill + 1}: cast with an enemy in her range`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)} S${skill + 1}: 3-1 while it runs`);
done(h);
}
}
});
test('a 自选 pick: 电弧 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks (the 岁的界园志异 modules SO-A / SO-B are not ISW-*)', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(RAD));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(RAD), `tier ${t}`);
for (const uniEquipId of [SOA, SOB, null]) {
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } } });
}
});
test('T1 卡带里的灵感: her skill\'s summon is her piece; the deck — 5 held, cap 5, at most 3 standing; every summon holds 禁疗 and fights with its data (戴乌 0-1 melee physical blocks 3, 赛柯 blocks 2 and fires straight ahead, 桑特拉 3-1 arts hits air)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const tok = TOWER_OF[skill];
const { h, u, ts, deck } = field({ tier, elite, mod, skill, towers: [[tok, 10, 6]] });
const t = ts[0], v = tokOf(tok, tier, elite, skill, mod);
assert.ok(h.b.producesToken(u, tok) && TOWER_OF.filter((x) => x !== tok).every((x) => !h.b.producesToken(u, x)), `${label(f)} S${skill + 1}: only ${tok}`);
assert.deepEqual([deck.charge, deck.cap, deck.held, deck.maxDeployed], [5, 5, 5, 3], `${label(f)} S${skill + 1}: deck`);
assert.deepEqual([t.base.maxHp, t.base.atk, t.base.def, t.base.res, t.base.cost, t.base.respawnTime, t.base.bat], [v.stats.maxHp, v.stats.atk, v.stats.def, v.stats.res, v.stats.cost, v.stats.respawnTime, v.stats.bat], `${label(f)}: summon stats (no module change)`);
assert.deepEqual([t.alive, !!t.s.flags.noHeal, t.kit.skill], [true, true, null], `${label(f)}: deployed, 禁疗, no skill`);
const want = { [DIVE]: [3, 'melee', 'phys', false, false], [CYCLE]: [2, 'melee', 'phys', false, true], [CENTRE]: [1, 'ranged', 'arts', true, false] }[tok];
assert.deepEqual([t.s.blockCnt, t.profile.attack, t.profile.dmgType, t.profile.canHitFly, !!t.profile.noAttack], want, `${label(f)}: ${tok}`);
done(h);
}
}
});
test('T2 加油~: her summons on the field get 鼓舞 = 12 % of HER current ATK / DEF / max HP (PRTS 修正 电弧自身), added after their own multipliers, refreshed every second; the strongest 鼓舞 of a kind is kept', () => {
for (const f of [[5, false, null], [6, true, SOA]]) {
const [tier, elite, mod] = f;
const { h, u, ts } = field({ tier, elite, mod, skill: 0, towers: [[DIVE, 10, 6]] });
const t = ts[0];
assert.deepEqual(formOf(tier, elite).talents.find((x) => x.index === 1).bb, { max_hp: 0.12, def: 0.12, atk: 0.12 });
approx(t.s.atk, t.base.atk + 0.12 * u.s.atk, `${label(f)}: ATK`);
approx(t.s.def, t.base.def + 0.12 * u.s.def, `${label(f)}: DEF`);
approx(t.s.maxHp, t.base.maxHp + 0.12 * u.s.maxHp, `${label(f)}: max HP`);
// her ATK rises: the 鼓舞 follows within a second
h.b.addBuff(u, { key: 'test:atk', mods: { atkPct: 1 } });
h.run(1.05);
approx(t.s.atk, t.base.atk + 0.12 * u.s.atk, 'updated every second');
// a summon's own ATK +% does not scale it
h.b.addBuff(t, { key: 'test:tatk', mods: { atkPct: 0.5 } });
h.run(1.05);
approx(t.s.atk, t.base.atk * 1.5 + 0.12 * u.s.atk, 'after its multipliers');
// a stronger 鼓舞 of another source stays
h.b.addBuff(t, { key: 'inspire', mods: { atkFlat: 5000 }, duration: 30, data: { src: -1, val: 5000 } });
h.run(1.05);
assert.equal(t.findBuff('inspire').data.val, 5000, 'the strongest kept');
done(h);
}
});
test('S1 律动线: +1 held; she and her summons DEF +40 % / +60 % and a 屏障 of 30 % / 70 % of their max HP until it ends (25 s; it stops every type but 元素伤害); a summon deployed during it takes it at its deployment with its 鼓舞 HP', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SOB]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, ts, deck } = field({ tier, elite, mod, skill: 0, towers: [[DIVE, 10, 6], [DIVE, 11, 6]] });
const [t, late] = ts;
assert.deepEqual([sk.bb.hp_ratio, sk.bb.def, sk.bb.cnt, sk.duration], [elite ? 0.7 : 0.3, elite ? 0.6 : 0.4, 1, 25]);
h.b.kill(late, null);
h.spawn('enemy_dummy', { pos: [10, 4] });
deck.held = 0;
const def0 = u.s.def;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
assert.equal(deck.held, 1, '技能开启时获得1个召唤物');
approx(u.s.def, def0 * (1 + sk.bb.def), 'her DEF');
assert.deepEqual(t.findBuff('radian:s1:tower')?.mods, { defPct: sk.bb.def }, 'its DEF');
const bu = u.findBuff('radian:s1:barrier'), bt = t.findBuff('radian:s1:barrier');
approx(bu.shield, u.s.maxHp * sk.bb.hp_ratio, 'her 屏障');
approx(bt.shield, t.s.maxHp * sk.bb.hp_ratio, 'its 屏障');
assert.deepEqual(bt.shieldType, ['phys', 'arts', 'true'], 'not 元素伤害');
// the knocked-out one comes back during the skill: its 屏障 counts its 鼓舞 HP
assert.ok(h.runUntil(() => late.alive, 21), 'back during the skill');
approx(late.findBuff('radian:s1:barrier').shield, (late.base.maxHp + 0.12 * u.s.maxHp) * sk.bb.hp_ratio, 'with 鼓舞 HP');
h.runUntil(() => !u.skill.active, 30);
assert.ok([u, t, late].every((x) => !x.findBuff('radian:s1:barrier')), 'gone at its end');
assert.equal(t.findBuff('radian:s1:tower'), null, 'DEF back');
done(h);
}
});
test('赛柯 (S2\'s summon): picks no target — every attack interval it fires a bullet straight ahead (5 tiles/s, 0.6 s, radius 0.5) at the first ground enemy it touches (100 % ATK physical 普通伤害), none at air units or farther than 3 tiles; stunned: no shot', () => {
const { h, u, ts } = field({ tier: 5, elite: false, skill: 1, towers: [[CYCLE, 10, 5]] });
const t = ts[0];
const near = h.spawn('enemy_dummy', { pos: [10, 7] }), behind = h.spawn('enemy_dummy', { pos: [10, 7.4] });
const fly = h.spawn('enemy_fly', { pos: [10, 6] }), far = h.spawn('enemy_dummy', { pos: [11, 9] });
h.run(4);
const hits = from(h, t);
assert.equal(hits.length, 3, '3 shots landed in 4 s (one at its deployment, then every 1.8 s; 0.3 s of flight)');
assert.ok(hits.every((c) => c.target === near), 'the first ground enemy ahead only');
for (const c of hits) assert.deepEqual([c.type, c.dmg.isAttack, Math.abs(c.amount - t.s.atk) < 1e-6], ['phys', true, true]);
void behind; void fly; void far;
// nothing past its 3 tiles
near.hp = 0; h.b.kill(near, null); behind.hp = 0; h.b.kill(behind, null);
const out = h.spawn('enemy_dummy', { pos: [10, 8.6] });
const n0 = from(h, t).length;
h.run(4);
assert.equal(from(h, t).length, n0, 'out of reach (3 tiles of flight + 0.5 radius)');
void out;
// stunned: no shot
const close = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.applyStatus(t, 'stun', { duration: 5, force: true });
const n1 = from(h, t).length;
h.run(4);
assert.equal(from(h, t).length, n1, 'no shot while stunned');
h.run(3);
assert.ok(from(h, t).some((c) => c.target === close), 'shoots again');
void u;
done(h);
});
test('S2 环形鳞地: +1 held; she and her summons ATK +35 % / +50 % for 25 s; 赛柯 fires three bullets per attack that fly 0.6 / 0.8 s (rank 4 / 7: "子弹飞行距离+1")', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SOA]]) {
const sk = skillOf(tier, elite, S2);
const life = tokOf(CYCLE, tier, elite, 1, mod).skill.bb['attack@projectile_life_time'];
assert.deepEqual([sk.bb.atk, sk.bb.base_attack_time, life], [elite ? 0.5 : 0.35, 0, elite ? 0.8 : 0.6]);
const { h, u, ts, deck } = field({ tier, elite, mod, skill: 1, towers: [[CYCLE, 10, 5]] });
const t = ts[0];
h.spawn('enemy_dummy', { pos: [10, 4] });
const reach = h.spawn('enemy_dummy', { pos: [10, 5 + 3.8] }); // 3.8 tiles ahead: past 0.6 s + 0.5, inside 0.8 s + 0.5
deck.held = 0;
const atk0 = u.s.atk, tatk0 = t.s.atk;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
assert.equal(deck.held, 1, '+1 held');
approx(u.s.atk, atk0 * (1 + sk.bb.atk), 'her ATK');
approx(t.s.atk, tatk0 * (1 + sk.bb.atk), 'its ATK');
const n0 = from(h, t).length;
h.run(3.7);
const hits = from(h, t).slice(n0);
if (elite) {
assert.ok(hits.length >= 3 && hits.length % 3 === 0, `three bullets per attack: ${hits.length}`);
assert.ok(hits.every((c) => c.target === reach), 'they reach 3.8 tiles');
} else assert.equal(hits.length, 0, 'rank 4: 0.6 s — 3.8 tiles is out of reach');
done(h);
}
});
test('S3 手牵手: +1 held; she and her summons ATK +80 % / +110 % for 30 s; before each damage of her attacks and of her summons on an enemy: 停顿 2 s and 法术脆弱 10 % / 20 % 2 s', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SOB]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, ts, deck } = field({ tier, elite, mod, skill: 2, towers: [[CENTRE, 11, 5]] });
const t = ts[0];
assert.deepEqual([sk.bb.atk, sk.bb.sluggish, sk.bb['weak[magic][limit]'], sk.bb.damage_scale, sk.duration], [elite ? 1.1 : 0.8, 2, 2, elite ? 1.2 : 1.1, 30]);
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.run(3);
assert.equal(h.hooksOf('statusApplied').filter((c) => c.status === 'artsFragile').length, 0, 'nothing before it');
deck.held = 0;
const atk0 = u.s.atk, tatk0 = t.s.atk;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
assert.equal(deck.held, 1, '+1 held');
approx(u.s.atk, atk0 * (1 + sk.bb.atk), 'her ATK');
approx(t.s.atk, tatk0 * (1 + sk.bb.atk), 'its ATK');
h.run(3);
const st = h.hooksOf('statusApplied').filter((c) => c.target === e);
assert.ok(st.some((c) => c.status === 'sluggish' && c.source === u) && st.some((c) => c.status === 'sluggish' && c.source === t), '停顿 from both');
const frag = st.filter((c) => c.status === 'artsFragile');
assert.ok(frag.length && frag.every((c) => Math.abs(c.value - (sk.bb.damage_scale - 1)) < 1e-9 && Math.abs(c.duration - 2) < 1e-9), '法术脆弱');
// before the damage: a hit of hers right after it is ×(1 + fragile)
const hit = from(h, u).filter((c) => c.dmg.isAttack).at(-1);
approx(hit.amount, u.s.atk * (1 + sk.bb.damage_scale - 1), 'her hit carries it (RES 0)');
done(h);
}
});
test('桑特拉 协同攻击: when one attacks, another of the field with no target of its own, its attack ready and the first inside its range shoots a link that strikes the first one\'s target (100 % of ITS ATK, arts), then asks the others; not one with a target, nor one out of range', () => {
// a sees e; b has a in its range; c has b (not a) in its range — it answers b's request in turn; d sees none of them
const { h, ts } = field({ tier: 6, elite: true, skill: 2, towers: [[CENTRE, 10, 5], [CENTRE, 11, 5], [CENTRE, 12, 3], [CENTRE, 12, 9]] });
const [a, b, c, d] = ts;
const e = h.spawn('enemy_dummy', { pos: [10, 8] }); // a's range only
h.run(4);
const links = h.hooksOf('damaged').filter((x) => x.dmg?.tags?.includes('radian:link'));
assert.ok(links.length >= 4, `links: ${links.length}`);
assert.ok(links.every((x) => x.target === e), 'the first one\'s target');
assert.ok(links.some((x) => x.source === b), 'b (a inside its range) answers');
assert.ok(links.some((x) => x.source === c), 'c answers b\'s request (the chain)');
for (const x of links) assert.deepEqual([x.type, x.dmg.isAttack, Math.abs(x.amount - x.source.s.atk) < 1e-6], ['arts', true, true], 'its own ATK, arts');
assert.ok(!links.some((x) => x.source === d), 'd (none of them in its range) does not');
const nb = links.filter((x) => x.source === b).length, na = h.hooksOf('attack').filter((x) => x.attacker === a).length;
assert.ok(nb <= na && nb >= na - 1, `at most one link per attack of a (b answers when its own attack is ready): ${nb} / ${na}`);
// b with a target of its own attacks that one instead
const mine = h.spawn('enemy_dummy', { pos: [12, 6] });
const n0 = links.length;
h.run(4);
const after = h.hooksOf('damaged').filter((x) => x.dmg?.tags?.includes('radian:link')).slice(n0);
assert.ok(!after.some((x) => x.source === b), 'b busy with its own target');
assert.ok(from(h, b).some((x) => x.target === mine && !x.dmg.tags?.includes('radian:link')), 'b attacks it');
done(h);
});
test('modules SO-A / SO-B: only their attributes act — the 集成战略 parts the record carries (SO-A\'s respawn_time / sp, SO-B\'s attack@max_target and the x-5 recall) change nothing: same deck, one target, the summons\' redeploy time and no SP when one leaves', () => {
for (const mod of [SOA, SOB]) {
const { h, u, ts, deck } = field({ tier: 6, elite: true, mod, skill: 0, towers: [[DIVE, 10, 4]] });
const t = ts[0];
assert.ok(u.def.raw.talents.some((x) => x.index === -1), `${mod}: the record carries its hidden 集成战略 talent`);
assert.deepEqual([deck.charge, deck.cap, deck.maxDeployed, u.profile.maxTargets, u.s.maxTargets], [5, 5, 3, 1, 0], `${mod}: nothing`);
assert.ok(t.alive, `${mod}: the summon beside her stays (no x-5 recall)`);
const sp0 = u.skill.sp;
h.b.kill(t, null);
assert.equal(u.skill.sp, sp0, `${mod}: no SP`);
h.run(19.5);
assert.equal(t.alive, false, `${mod}: its full redeploy time (20 s)`);
assert.ok(h.runUntil(() => t.alive, 1), `${mod}: back after 20 s`);
done(h);
}
});
+338 -99
View File
@@ -5013,128 +5013,128 @@
"snaps": "389ca6e3 c8f155a4 e2e7a3b2 2f4c8838 6a9c72f0 8c585b7e a26f5d0c 80f56fed a04d35ab 88374c55 043f03c3 acc9e8b4 6dc5a029 a28ee86b d72cad57 77ed9536 4cd333ad 94a7e729 29475fd8 b7079863 f08523df a6844f3a"
},
"diy-117": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
"fast": true,
"about": "5_diy1_a=mgllan/S1 5_diy2_a=mgllan/S2 5_diy1_a=mgllan/S3 5_diy2_b=mgllan/S1 5_diy1_b=mgllan/S2 5_diy2_b=mgllan/S3 5_diy1_b=mgllan/S1/002_mgllan 5_diy2_b=mgllan/S2/002_mgllan 5_diy1_b=mgllan/S3/002_mgllan 5_diy2_b=mgllan/S1/003_mgllan 5_diy1_b=mgllan/S2/003_mgllan 5_diy2_b=mgllan/S3/003_mgllan",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m07",
"modeId": "mode_single_normal",
"round": 12,
"seed": 3494003672,
"waveId": "act1autochess_h05",
"time": 208.03,
"ticks": 6241,
"time": 213.97,
"ticks": 6419,
"reason": "cleared",
"killed": 9,
"killed": 15,
"total": 90,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":9,"total":90,"perfect":false,"leaks":81,"leaksUncounted":0,"coins":0,"dmg":238041,"bossDmg":0,"heal":19797,"deaths":10,"layerGains":{}}},
"players": {"p1":{"killed":15,"total":90,"perfect":false,"leaks":75,"leaksUncounted":0,"coins":0,"dmg":319094,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,109,0,248,6384,0,18,1],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,110,0,1996,16584,0,32,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,77,1,25968,11854,0,42,1],
["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,81,5,36674,11879,0,46,1],
["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,4,2,18567,0,0,111,2],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,7,1,33348,0,0,111,3],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,10,0,22416,0,0,81,3],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,13,0,29317,0,0,89,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,14,0,0,0,0,0,0],
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,5,0,0,0,19797,34,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,8,0,30810,0,0,130,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,11,0,38698,0,0,139,3],
["p1",1,"op","chess_char_5_diy1_a",0,null,871,1,5,1,19697,0,0,82,4],
["p1",2,"op","chess_char_5_diy2_a",1,null,871,1,7,1,22145,0,0,94,3],
["p1",3,"op","chess_char_5_diy1_a",2,null,871,1,9,2,23747,0,0,76,3],
["p1",4,"op","chess_char_5_diy2_b",0,"none",1025,1,11,1,19133,0,0,72,3],
["p1",5,"op","chess_char_5_diy1_b",1,"none",1025,1,13,0,25747,0,0,96,3],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1025,1,6,1,36750,0,0,109,3],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_mgllan",1125,1,8,4,22142,0,0,82,4],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_mgllan",1125,1,10,0,40068,0,0,140,3],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_mgllan",1125,1,12,0,41499,0,0,115,3],
["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_mgllan",1025,1,14,0,32851,0,0,116,4],
["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_mgllan",0,0,86,3,17285,2050,0,66,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_mgllan",0,0,78,2,18031,2050,0,50,2],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1229_darmy":[27,6,21,6919,59891],"enemy_1229_darmy_2":[51,3,48,32825,159835],"enemy_9007_acelem":[12,0,12,6956,18315]},
"hooks": [1,110,6241,935,935,993,1059,34,19,100,24,23,0,28623,269,28,90,81,1,19,0,6,0],
"events": {"atk":935,"deploy":18,"die":19,"dmg":1059,"engage":10,"fx":9,"heal":34,"leak":81,"skill":47,"spawn":104,"status":450},
"fx": {"beam":3,"burst":6},
"snaps": "d6115a0f f64a22f0 da36003f c04f951a d2e6b4b9 f2d67641 021ab897 9f23a6d9 bf41f23a b8d9411e 65fe19ec 18e60605 ae0ce253 88406339 be5685a9 b32fa604 43885b89 1fbc15fd a8e87436 b8c12c1d e3c58fb5"
"enemies": {"enemy_1229_darmy":[27,3,24,0,66690],"enemy_1229_darmy_2":[51,12,39,1337,220728],"enemy_9007_acelem":[12,0,12,2763,31676]},
"hooks": [1,106,6419,1103,1103,1114,1118,0,19,94,37,37,0,47654,366,6,90,75,1,19,0,0,0],
"events": {"atk":1103,"deploy":14,"die":19,"dmg":1118,"engage":12,"fx":263,"leak":75,"skill":74,"spawn":104,"status":693},
"fx": {"beam":3,"coldWind":260},
"snaps": "cdf6977b 7cabeaaa afc75510 8bf97ba8 5517d2ad 50048b5f 34d74272 d1a72434 cb51b143 3c1080cf 2cb4cce8 776ef9cb 77b6a0cf a2b8a9a0 e27519e3 1a990665 27d1aec5 d205bea1 9102b70a 054facb5 dfea0d3a 0554da3e"
},
"diy-118": {
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc",
"about": "6_diy1_a=mgllan/S1 6_diy2_a=mgllan/S2 6_diy1_a=mgllan/S3 6_diy2_b=mgllan/S1 6_diy1_b=mgllan/S2 6_diy2_b=mgllan/S3 6_diy1_b=mgllan/S1/002_mgllan 6_diy2_b=mgllan/S2/002_mgllan 6_diy1_b=mgllan/S3/002_mgllan 6_diy2_b=mgllan/S1/003_mgllan 6_diy1_b=mgllan/S2/003_mgllan 6_diy2_b=mgllan/S3/003_mgllan",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m01",
"modeId": "mode_multi_abyss",
"round": 12,
"round": 13,
"seed": 1853835342,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 1,
"total": 129,
"waveId": "act1autochess_h06",
"time": 223.27,
"ticks": 6698,
"reason": "cleared",
"killed": 0,
"total": 171,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 848,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":1,"total":129,"perfect":false,"leaks":128,"leaksUncounted":0,"coins":0,"dmg":1177825,"bossDmg":0,"heal":55179,"deaths":16,"layerGains":{}}},
"players": {"p1":{"killed":0,"total":171,"perfect":false,"leaks":171,"leaksUncounted":0,"coins":0,"dmg":12883,"bossDmg":0,"heal":0,"deaths":30,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,94,0,2330,5208,0,10,0],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,93,0,1940,6742,0,12,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,146,0,5578,4587,0,13,1],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,151,0,116734,5916,0,14,2],
["p1",5,"op","chess_char_5_diy1_a",2,null,2638,1,153,0,19958,6350,0,27,1],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",3411,1,152,0,69928,9476,0,48,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,1272,1,2,0,102341,0,0,144,2],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,3,1,210580,0,0,209,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1372,1,4,0,310285,0,0,76,2],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",601.28,1,142,0,338151,18053,0,69,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,400.92,1,137,0,0,15824,20934,46,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",272.18,1,136,0,0,21460,34246,50,2]
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,185,0,1934,2613,0,7,1],
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,194,0,2096,2613,0,8,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,200,0,742,2613,0,3,1],
["p1",4,"op","chess_char_6_diy2_b",0,"none",0,0,201,0,2682,3075,0,8,1],
["p1",5,"op","chess_char_6_diy1_b",1,"none",1025,1,202,0,1788,2050,0,5,1],
["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,177,0,358,3075,0,2,0],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_mgllan",0,0,108,0,537,2350,0,2,1],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_mgllan",0,0,100,0,199,2350,0,1,0],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_mgllan",0,0,103,0,795,2350,0,3,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_mgllan",0,0,173,0,1169,3075,0,5,0],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_mgllan",0,0,109,0,0,2050,0,0,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_mgllan",0,0,97,0,584,2050,0,3,0]
],
"enemies": {"enemy_1288_duskls":[36,0,35,26018,369746],"enemy_1288_duskls_2":[51,1,50,14309,439126],"enemy_1292_duskld":[42,0,40,53289,368953]},
"hooks": [1,153,6900,969,969,2729,2729,177,17,142,20,19,0,27982,0,39,129,125,1,28,0,0,0],
"events": {"atk":1241,"deploy":24,"die":17,"dmg":2729,"engage":10,"fx":78,"heal":149,"leak":125,"skill":39,"spawn":141,"status":15},
"fx": {"aoe":41,"dp":3,"ember":7,"revive":2,"slash":24,"spGain":1},
"snaps": "94746129 c4497abf 869d1ff0 c8a802a6 00313d1a b879dff8 d1296660 c150f5dd d0345c24 25fcc07d 82243fcb 562ecf32 3b75946a 3670dc3d 7dd8e146 f4b80737 b5f882f3 b32d44ac 89672509 befca016 ee3088ba db9acdb1 8b7c51ba"
"enemies": {"enemy_1009_lurker":[66,0,66,1458,6482],"enemy_1019_jshoot_2":[57,0,57,21857,2218],"enemy_1404_msnip":[48,0,48,6949,4183]},
"hooks": [1,202,6698,144,144,124,124,0,30,201,6,6,0,6590,12,14,171,171,1,30,0,0,0],
"events": {"atk":144,"deploy":31,"die":30,"dmg":124,"engage":11,"fx":24,"leak":171,"skill":12,"spawn":183,"status":22},
"fx": {"coldWind":24},
"snaps": "add66b6b bc33f052 d942af87 757546fb 938da648 723744a8 be236402 d4e1f16d 919b1c68 b727c264 40ccd1ed de11fcf6 522f92a4 b55e94a9 6a5d29dd a7fef55f b8c5febd 92bcceb3 02756870 13f215f1 6323f704 2b1ae8a4 144e693c"
},
"diy-119": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo",
"about": "5_diy1_a=ling/S1 5_diy2_a=ling/S2 5_diy1_a=ling/S3 5_diy2_b=ling/S1 5_diy1_b=ling/S2 5_diy2_b=ling/S3 5_diy1_b=ling/S1/002_ling 5_diy2_b=ling/S2/002_ling 5_diy1_b=ling/S3/002_ling 6_diy1_a=ling/S1 6_diy2_a=ling/S2 6_diy1_a=ling/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m02",
"modeId": "mode_multi_funny",
"round": 13,
"round": 12,
"seed": 691884976,
"waveId": "act1autochess_h06",
"time": 208.6,
"ticks": 6258,
"waveId": "act1autochess_h05",
"time": 198.8,
"ticks": 5964,
"reason": "cleared",
"killed": 63,
"total": 150,
"killed": 4,
"total": 114,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 358,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":63,"total":150,"perfect":false,"leaks":87,"leaksUncounted":0,"coins":0,"dmg":404183,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{}}},
"players": {"p1":{"killed":4,"total":114,"perfect":false,"leaks":110,"leaksUncounted":0,"coins":0,"dmg":27034,"bossDmg":0,"heal":0,"deaths":28,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,3,1,13787,2604,0,71,1],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,4,5,35731,3431,0,95,2],
["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,146,4,11953,3730,0,64,1],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,149,6,62164,4748,0,84,2],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,166,3,14422,4587,0,29,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1882.66,1,178,17,72869,3829,0,50,5],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,174,2,19446,8078,0,101,0],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,179,5,20751,2896,0,91,0],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,122,4,22858,5276,0,67,2],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",819.68,1,10,8,21407,2711,0,41,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,6,4,49762,0,0,118,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,7,4,59032,0,0,120,2],
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,139,0,1137,2556,0,3,0],
["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,144,1,1212,2556,0,2,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,133,1,990,2556,0,3,0],
["p1",4,"op","chess_char_5_diy2_b",0,"none",131.6,1,145,0,4455,2874,0,8,1],
["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,104,1,5250,2004,0,9,1],
["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,80,0,3510,2004,0,8,0],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_ling",0,0,125,0,960,3306,0,2,0],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_ling",0,0,81,0,3245,2204,0,5,1],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_ling",0,0,72,0,1440,2204,0,3,0],
["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,77,0,731,1704,0,2,0],
["p1",11,"op","chess_char_6_diy2_a",1,null,0,0,74,0,1218,1704,0,3,0],
["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,115,1,2886,1704,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]
],
"enemies": {"enemy_1249_lysdb_2":[60,30,30,10889,186403],"enemy_1251_lysyta":[51,20,31,805,65178],"enemy_1329_cbshld":[39,13,26,30196,152603]},
"hooks": [1,179,6258,1416,1416,2055,2115,0,85,172,21,30,0,27987,5299,89,150,87,1,85,0,0,15],
"events": {"atk":1493,"deploy":27,"die":85,"dmg":2093,"engage":12,"fx":231,"leak":87,"skill":61,"spawn":164,"status":604},
"fx": {"aoe":79,"displace":10,"dodge":32,"dp":26,"shieldBreak":42,"slash":42},
"snaps": "3ce28529 cc12e586 95201dc6 6c9aca1e 1e7226c2 a3d70186 81ecad92 1fed47c5 cbd5c0dc 3152ee2f 3055e90a 589e6d44 c7ebaa8a a972c019 5f6cbcd0 b8f3328f 053ceecf d3b9779c b17a13db 64226ab5 44455b7b"
"enemies": {"enemy_1019_jshoot_2":[51,1,50,6619,3277],"enemy_1381_winman":[27,3,24,528,22162],"enemy_1389_winbab_2":[36,0,36,20229,1595]},
"hooks": [1,145,5964,219,219,161,161,0,32,142,4,4,0,6844,2,6,114,110,1,32,0,0,0],
"events": {"atk":219,"deploy":29,"die":32,"dmg":161,"engage":12,"leak":110,"skill":8,"spawn":128,"status":2},
"fx": {},
"snaps": "9d6a6b77 717637b7 e56311a9 738171fe e89fce36 7d6f9bff f197c749 0c3494ae e9cff211 79b15ac9 a0bbbb99 e426117c ba0535ff f057fb44 c8005d49 b0185e0b a81cdad2 c9367c5a ff47d167 685f68f4"
},
"diy-120": {
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo",
"about": "6_diy1_b=ling/S1 6_diy2_b=ling/S2 6_diy1_b=ling/S3 6_diy2_b=ling/S1/002_ling 6_diy1_b=ling/S2/002_ling 6_diy2_b=ling/S3/002_ling 5_diy1_a=radian/S1 5_diy2_a=radian/S2 5_diy1_a=radian/S3 5_diy2_b=radian/S1 5_diy1_b=radian/S2 5_diy2_b=radian/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m03",
@@ -5142,38 +5142,277 @@
"round": 13,
"seed": 3346378653,
"waveId": "act1autochess_h06",
"time": 207.23,
"ticks": 6217,
"time": 219.2,
"ticks": 6576,
"reason": "cleared",
"killed": 76,
"killed": 1,
"total": 111,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1266,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":76,"total":111,"perfect":false,"leaks":35,"leaksUncounted":0,"coins":0,"dmg":1092661,"bossDmg":0,"heal":53750,"deaths":8,"layerGains":{}}},
"players": {"p1":{"killed":1,"total":111,"perfect":false,"leaks":110,"leaksUncounted":0,"coins":0,"dmg":325220,"bossDmg":0,"heal":0,"deaths":12,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,1865,1,125,6,58880,3125,0,87,3],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",1492.02,1,124,20,229213,8940,0,96,3],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,12,4,34876,23710,0,174,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1119.02,1,102,8,87347,26340,0,194,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,9,10,106608,0,0,205,2],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,11,15,182398,0,0,197,2],
["p1",7,"op","chess_char_6_diy1_a",2,null,1372,1,13,1,39394,0,0,32,2],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,15,10,329307,0,0,83,3],
["p1",9,"op","chess_char_6_diy1_a",2,null,1224,1,4,0,0,0,27594,61,2],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,10,0,0,0,26156,62,2],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,79,0,8304,2320,0,26,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,132,2,16334,2900,0,38,2],
["p1",1,"op","chess_char_6_diy1_b",0,"none",1002,1,8,0,46449,0,0,146,4],
["p1",2,"op","chess_char_6_diy2_b",1,"none",1002,1,11,0,54194,0,0,125,13],
["p1",3,"op","chess_char_6_diy1_b",2,"none",1002,1,13,0,42642,0,0,125,3],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_ling",1152,1,15,0,51373,0,0,145,4],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_ling",1152,1,4,1,60276,0,0,127,13],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_ling",1152,1,9,0,49010,0,0,129,3],
["p1",7,"op","chess_char_5_diy1_a",0,null,844,1,125,0,1000,1688,0,5,0],
["p1",8,"op","chess_char_5_diy2_a",1,null,844,1,123,0,2080,1688,0,10,0],
["p1",9,"op","chess_char_5_diy1_a",2,null,844,1,138,0,3992,1688,0,17,1],
["p1",10,"op","chess_char_5_diy2_b",0,"none",993,1,119,0,1776,1986,0,8,0],
["p1",11,"op","chess_char_5_diy1_b",1,"none",993,1,117,0,1332,1986,0,6,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",993,1,127,0,11096,1986,0,32,2],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1010_demon":[54,41,13,60645,583391],"enemy_1320_wdrrl_2":[18,0,18,0,173024],"enemy_1422_lrsldr_2":[39,35,4,6690,336246]},
"hooks": [1,132,6217,1474,1474,2387,2519,184,84,119,23,22,0,31679,1160,68,111,35,1,84,0,0,0],
"events": {"atk":1796,"deploy":18,"die":84,"dmg":2511,"engage":10,"fx":16,"heal":146,"leak":35,"skill":49,"spawn":126,"status":204},
"fx": {"aoe":8,"displace":3,"spGain":5},
"snaps": "4f5252e4 7af484c8 985c1669 401f5692 e50a7480 36fa6869 528a1ad8 45b94806 2fe07627 421f7817 76310c6c 18ea76d0 387f4397 04e121f1 02f4774a 680f0f8e ae7859ea 695d9065 6e8b9449 70a6b885 bb680183"
"enemies": {"enemy_1010_demon":[54,0,54,10178,143795],"enemy_1320_wdrrl_2":[18,0,18,0,66040],"enemy_1422_lrsldr_2":[39,1,38,844,115385]},
"hooks": [1,138,6576,900,900,925,925,0,13,123,43,42,0,36669,73,12,111,110,1,13,0,0,0],
"events": {"atk":925,"deploy":24,"die":13,"dmg":925,"engage":12,"leak":110,"skill":85,"spawn":126,"status":118},
"fx": {},
"snaps": "8efc04ec ae9896f7 651a72a6 43c421a2 6c1dec26 0f806649 fd3d4551 42e17397 4be566f2 40c91c86 483fb067 ec5672f1 7e0cbae8 aca47d63 768af9de 4a53fb7e 2a33f4d5 805d7c69 8d790aca 9475e9b0 fbd08352 7a59b53e"
},
"diy-121": {
"about": "5_diy1_b=radian/S1/002_radian 5_diy2_b=radian/S2/002_radian 5_diy1_b=radian/S3/002_radian 5_diy2_b=radian/S1/003_radian 5_diy1_b=radian/S2/003_radian 5_diy2_b=radian/S3/003_radian 6_diy1_a=radian/S1 6_diy2_a=radian/S2 6_diy1_a=radian/S3 6_diy2_b=radian/S1 6_diy1_b=radian/S2 6_diy2_b=radian/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m04",
"modeId": "mode_multi_normal",
"round": 13,
"seed": 4264415273,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 0,
"total": 135,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":135,"perfect":false,"leaks":135,"leaksUncounted":0,"coins":0,"dmg":309307,"bossDmg":0,"heal":0,"deaths":8,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_radian",1118,1,3,0,28326,0,0,130,4],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_radian",1118,1,4,0,34713,0,0,128,4],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_radian",1118,1,11,0,33948,0,0,107,2],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_radian",993,1,13,0,22873,0,0,103,3],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_radian",993,1,5,0,36036,0,0,132,4],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_radian",993,1,8,0,48884,0,0,131,3],
["p1",7,"op","chess_char_6_diy1_a",0,null,844,1,12,0,25056,0,0,132,4],
["p1",8,"op","chess_char_6_diy2_a",1,null,844,1,14,0,25864,0,0,124,3],
["p1",9,"op","chess_char_6_diy1_a",2,null,844,1,9,0,31435,0,0,128,3],
["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,152,0,5372,2979,0,26,1],
["p1",11,"op","chess_char_6_diy1_b",1,"none",993,1,155,0,12805,1986,0,48,2],
["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,142,0,3996,2979,0,20,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1288_duskls":[27,0,26,993,76165],"enemy_1288_duskls_2":[57,0,55,1986,142338],"enemy_1292_duskld":[51,0,51,4965,146324]},
"hooks": [1,155,6900,1243,1243,2631,2631,0,8,140,33,30,0,38103,280,8,135,132,1,8,0,0,0],
"events": {"atk":1243,"deploy":18,"die":8,"dmg":2628,"engage":12,"fx":12,"leak":132,"skill":63,"spawn":149,"status":284},
"fx": {"shield":12},
"snaps": "a3cff5cf d0583459 7816abc1 39def2d7 5b7f0286 c110a75a 695623ec 79136be7 a4d0b9a8 c2fa26f5 89fd1b16 5b47f0ed a212ac82 437033f1 c8d1ff58 534daf69 0c5a4ec8 caa8471f 0b73655b 49a2eb59 1a30387d 9158dadd 38cd69de"
},
"diy-122": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=radian/S1/002_radian 6_diy2_b=radian/S2/002_radian 6_diy1_b=radian/S3/002_radian 6_diy2_b=radian/S1/003_radian 6_diy1_b=radian/S2/003_radian 6_diy2_b=radian/S3/003_radian 5_diy1_a=csnipe 5_diy2_b=csnipe",
"fast": true,
"kind": "normal",
"stageId": "act1autochess_m01",
"modeId": "mode_multi_hard",
"round": 13,
"seed": 2623895589,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 0,
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":290050,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,138,0,152,6384,0,4,0],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,136,0,648,8724,0,9,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,140,0,7110,7692,0,18,0],
["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,133,0,7740,10002,0,15,0],
["p1",5,"op","chess_char_6_diy1_b",0,"uniequip_002_radian",1158,1,4,0,33369,0,0,131,4],
["p1",6,"op","chess_char_6_diy2_b",1,"uniequip_002_radian",1158,1,5,0,40275,0,0,127,4],
["p1",7,"op","chess_char_6_diy1_b",2,"uniequip_002_radian",1158,1,11,0,54384,0,0,129,3],
["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_003_radian",993,1,13,0,37218,0,0,135,4],
["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_003_radian",993,1,6,0,40898,0,0,122,4],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_003_radian",993,1,14,0,59223,0,0,132,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,151,0,882,3813,0,7,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,143,0,8152,4530,0,23,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1010_demon":[54,0,53,18271,131927],"enemy_1422_lrsldr_2":[39,0,39,2889,92931],"enemy_1425_lrcmra_2":[30,0,30,19985,65192]},
"hooks": [1,151,6900,901,901,901,901,0,18,140,23,23,0,28074,220,29,123,122,1,18,0,0,0],
"events": {"atk":901,"deploy":25,"die":18,"dmg":901,"engage":12,"fx":8,"leak":122,"skill":46,"spawn":138,"status":236},
"fx": {"shield":8},
"snaps": "a5a3cc35 894a57a2 4b9f5176 6f48baf1 d88ae70f 473a84f3 74691a8e c52ef874 00871d23 c05d950a 33730f93 855c888e 8745c85a 2ff3981d ee3f55ee b28e36be 40d4d1ab 8151b1a7 10d62df3 1d5b7380 425c900d 412ab4f6 ce1f9af0"
},
"diy-123": {
"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_m02",
"modeId": "mode_single_normal",
"round": 12,
"seed": 983089170,
"waveId": "act1autochess_h05",
"time": 217.53,
"ticks": 6526,
"reason": "cleared",
"killed": 16,
"total": 90,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":16,"total":90,"perfect":false,"leaks":74,"leaksUncounted":0,"coins":0,"dmg":305235,"bossDmg":0,"heal":0,"deaths":16,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,112,0,611,7812,0,19,0],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,99,0,6445,6742,0,26,2],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,89,4,25438,3058,0,24,4],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,79,3,83870,3808,0,25,4],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,84,1,37691,5276,0,34,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,71,6,46607,6822,0,31,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,5,2,26403,0,0,95,3],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,8,0,52045,0,0,158,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,0,0,0,0],
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,9,0,0,0,0,0,0],
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,105,0,6636,1864,0,28,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,103,0,19488,1131,0,70,2]
],
"enemies": {"enemy_1229_darmy":[27,5,22,5381,47975],"enemy_1229_darmy_2":[51,10,41,14921,150809],"enemy_9007_acelem":[12,1,11,16212,106450]},
"hooks": [1,112,6526,573,573,842,898,0,32,106,22,21,0,22719,98,33,90,74,1,37,0,2,0],
"events": {"atk":573,"deploy":22,"die":32,"dmg":898,"engage":10,"fx":106,"leak":74,"skill":43,"spawn":102,"status":238},
"fx": {"aoe":24,"beam":12,"burst":2,"dp":15,"slash":53},
"snaps": "06c160ad 60856643 f2759344 c1828e1a 2d800fed b15a5a70 1dde37fe a2c4da33 062734bc b8121d72 29030c51 f7b6b38b 6ba27ec2 429425fd 35458d83 075dbff4 e90e78aa 235ce236 5dbe07be 649356b2 6da48fb9 024e3c4c"
},
"diy-124": {
"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_m03",
"modeId": "mode_multi_abyss",
"round": 12,
"seed": 3637921807,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 19,
"total": 114,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1060,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":19,"total":114,"perfect":false,"leaks":95,"leaksUncounted":0,"coins":0,"dmg":875741,"bossDmg":0,"heal":90677,"deaths":7,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,1865,1,134,2,32671,3730,0,52,2],
["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,136,5,86795,7181,0,59,2],
["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,135,1,35817,31242,0,195,0],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",789.36,1,122,4,60783,31411,0,213,0],
["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,8,2,32124,0,0,111,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,12,0,55163,0,0,120,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,14,2,80444,0,0,49,2],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,4,3,402021,0,0,84,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,6,0,0,0,39232,76,2],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1211.94,1,9,0,0,30207,51445,76,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,13,0,44372,0,0,158,3],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,15,0,45551,0,0,130,2],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_10124_uashld_2":[12,0,12,1442,109491],"enemy_1043_zomsbr":[72,19,53,46440,483740],"enemy_1061_zomshd":[30,0,29,55889,282511]},
"hooks": [1,136,6900,1658,1658,2344,2424,42351,26,120,23,23,0,36633,7376,27,114,94,1,26,0,0,0],
"events": {"atk":1877,"deploy":19,"die":26,"dmg":2416,"engage":10,"fx":48,"heal":262,"leak":94,"skill":53,"spawn":129,"status":840},
"fx": {"aoe":20,"displace":4,"dodge":16,"spGain":8},
"snaps": "1eb6ea3d 97f3ff30 4261b18e c01456c9 0b5c62d2 fab04043 177e6583 b52b5b2e be9178b3 8b79e9c3 1922dfdf a92f76da 8d4df36e ef41dec7 ad0b61b1 661c90f4 e2890b0a 59077292 95d5bd04 0243fc56 ac750351 03a10c61 9ba3299d"
},
"diy-125": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m04",
"modeId": "mode_multi_funny",
"round": 13,
"seed": 1997254780,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 84,
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 343,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":84,"total":123,"perfect":false,"leaks":39,"leaksUncounted":0,"coins":0,"dmg":357927,"bossDmg":0,"heal":66543,"deaths":16,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,1893.31,1,123,2,5031,3315,0,33,1],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",3431,1,151,5,13919,3431,0,21,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,126.12,1,127,12,20826,2932,0,21,5],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1887.63,1,150,7,32985,6563,0,15,3],
["p1",5,"op","chess_char_6_diy1_a",2,null,2498,1,94,17,82020,30131,0,83,4],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3415.68,1,122,22,98741,12217,0,83,2],
["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,142,0,3495,1272,0,4,1],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,146,10,48097,1643,0,73,1],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,136,1,10025,4116,0,8,0],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,7,4,42788,1819,0,23,1],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,88,0,0,10841,20168,35,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1186.2,1,84,0,0,21228,46375,67,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1019_jshoot_2":[57,47,9,26899,136695],"enemy_1299_ymkilr_2":[27,14,13,42870,161687],"enemy_1422_lrsldr_2":[39,23,16,8181,142594]},
"hooks": [1,151,6900,1072,1072,2676,2599,203,100,138,24,23,0,26014,0,97,123,38,1,100,0,0,77],
"events": {"atk":1124,"deploy":25,"die":100,"dmg":2599,"engage":10,"fx":179,"heal":178,"leak":38,"skill":47,"spawn":138,"status":16},
"fx": {"aoe":33,"dodge":77,"dp":27,"slash":40,"spGain":2},
"snaps": "21dd8d16 cabbff5b caf3b53e 7c015a2c 6747114f f22456ce ed18dbd1 652b0a6d 11ae3137 c8c632f8 f25abdbc 4667ab53 fab2e5d0 ed9faf27 fcec0ce7 e591aee0 5f8d1706 17d2d702 aeab44ae 4e764aa3 2c433a83 5127ab47 837a8334"
},
"diy-126": {
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m05",
"modeId": "mode_single_hard",
"round": 13,
"seed": 356460561,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 10,
"total": 105,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 170,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":10,"total":105,"perfect":false,"leaks":95,"leaksUncounted":0,"coins":0,"dmg":171811,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,1865,1,128,2,23625,3730,0,51,2],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",2494,1,129,6,88477,4988,0,57,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,127,2,19834,9232,0,83,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,130,0,14467,11872,0,70,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,5,0,4933,0,0,31,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,6,0,20475,0,0,82,2]
],
"enemies": {"enemy_1165_duhond":[51,10,41,866,73785],"enemy_1174_duholy_2":[24,0,23,12955,102349],"enemy_1175_dushdo_2":[30,0,30,16001,67989]},
"hooks": [1,130,6900,441,441,2345,2417,0,30,124,6,21,0,10384,2389,33,105,94,1,30,0,0,3],
"events": {"atk":485,"deploy":25,"die":30,"dmg":2387,"engage":6,"fx":8,"leak":94,"skill":45,"spawn":111,"status":481},
"fx": {"displace":5,"dodge":3},
"snaps": "08186948 cba346f8 3113c1fa 3712ce41 c1534fdd 8a993ce5 c092176d 86d47d10 903c4f73 0bf3993b f31f3e91 67179f99 c5384e6c 41f8b47e 8d537e1e d1394a25 4f860256 61990651 bf5d7786 d729c8d1 3aa4f821 3e5745ec aaa11631"
}
}
}