From ba66c2c32e2e32c42dc27cb7369c3e5143115c36 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 00:04:18 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20O3:=20=E8=87=AA=E9=80=89=20operator?= =?UTF-8?q?=20kits=20for=20=E9=80=BB=E5=90=84=E6=96=AF,=20=E7=9C=9F?= =?UTF-8?q?=E8=A8=80=20and=20=E7=BB=B4=E4=BC=8A=20(every=20skill,=20talent?= =?UTF-8?q?,=20trait=20and=20module=20at=20stages=201=20/=203),=20the=20en?= =?UTF-8?q?gine=20hook=20palsyTrigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kits/ops/op-logos.js (中坚术师; CCR-Δ, CCR-Y), op-mantra.js (本源术师; PRI-X), op-veen.js (秘术师; MSC-Y), registered in OPERATOR_KIT_FILES one line per file; numbers from data/backups.json, mechanics from the PRTS 备注 (cited in each header), [ASSUMED] items marked there. Data triggers kept; 维伊 S1 (an AUTO self buff) fires at full SP (the owner's rule). - server/sim/ai.js: enemyAttack emits `palsyTrigger { enemy, buff, keep }` when a 麻痹 stack interrupts an attack (PRTS 真言 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时"); `keep` leaves the stack (真言 噤声限域). Additive: no listener, no change. docs/SIM.md §3 / §5 rows; test/sim/palsy-trigger.test.js. - Tests: test/content/op_logos.test.js, op_mantra.test.js, op_veen.test.js (every form, skill, talent, module, range, trigger, targeting, damage type). - Golden: only the diy family moved — diy-003 … diy-007 now field 逻各斯 / 真言 / 维伊 in every form × module × skill (60 configurations; diy-003 … 006 held the prototypes before), the prototype picks moved to diy-008 … 011 (same packing, new ids); diy-001 / 002 (推进之王) and roster, bonds, fields, matches, standins byte-identical. --- docs/SIM.md | 3 +- server/sim/ai.js | 11 +- server/sim/content/kits/index.js | 7 +- server/sim/content/kits/ops/op-logos.js | 279 ++++++++++++++++ server/sim/content/kits/ops/op-mantra.js | 315 ++++++++++++++++++ server/sim/content/kits/ops/op-veen.js | 239 ++++++++++++++ test/content/op_logos.test.js | 390 ++++++++++++++++++++++ test/content/op_mantra.test.js | 371 +++++++++++++++++++++ test/content/op_veen.test.js | 269 +++++++++++++++ test/golden/diy.json | 404 +++++++++++++++++------ test/sim/palsy-trigger.test.js | 62 ++++ 11 files changed, 2246 insertions(+), 104 deletions(-) create mode 100644 server/sim/content/kits/ops/op-logos.js create mode 100644 server/sim/content/kits/ops/op-mantra.js create mode 100644 server/sim/content/kits/ops/op-veen.js create mode 100644 test/content/op_logos.test.js create mode 100644 test/content/op_mantra.test.js create mode 100644 test/content/op_veen.test.js create mode 100644 test/sim/palsy-trigger.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 67b576a8..b487cd01 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -586,7 +586,7 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba | `silence` | no skill activation | – | | `fear` (恐惧) | 无法被阻挡并四散逃跑: enemy cannot attack, is unblockable (released) and leaves its route: it runs between random checkpoints of the fan away from the source (§1.2 恐惧 movement; no source / itself ⇒ inside its own tile) | – | | `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – | -| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 | +| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`); each cancel fires the hook `palsyTrigger` (§5) | stacks, default 1 | | `disarm` | no normal attacks | – | | `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it, and an enemy's area effects and buff auras skip it even when it blocks that enemy — `targeting.js areaSelectable` / `auraSelectable`, GitHub #97, owner 2026-10-04; 0.1.2 made an exception for that blocker) / cancels stealth. An enemy's 隐匿 also stays off after a block: each block's end (`Battle._stealthSwitch`, every release path) switches each of its 隐匿 sources off for `STEALTH_RESTORE` (3) s — PRTS 作战机制 §隐匿 "不被阻挡的3秒后重新进入隐匿" — or the source's own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`: 0 s for 业余竞演者, 节日爵士乐手, 假想敌:骨刺, 流泪小子, 访问团强攻冠军 and 清明's veil, 1 s for the 家族灭迹人); a new block inside it lifts it again and its end restarts the window; our operators' 隐匿 / 迷彩 never lift by blocking (DESIGN §22.8). `targeting.js enemyStealthed` is the one test: the b.snap stealth bit is set only while its 隐匿 is on (drawn solid otherwise); an operator's radius area damage (`foesInRadius`) skips it too (PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 the splash still hit it) | – | | `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标(无法躲避溅射类攻击)"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) — but an enemy's splash and other area effects still hit it (`areaSelectable` does not check it) — and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – | @@ -749,6 +749,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `blocked` | `{ blocker, enemy }` | enemy became blocked | | `enemySpawn` / `enemyLeak` | `{ enemy }` | | | `elementBurst` | `{ source, target, element }` | before the burst's lock/effects; same-element fills of `target` are already refused | +| `palsyTrigger` | `{ enemy, buff, keep }` | a 麻痹 stack interrupts the enemy's normal attack ("触发麻痹"), before the stack is used: set `keep` to leave it (真言 噤声限域); a handler may kill the enemy (its turn ends) | | `dodge` | `{ source, target, dmg }` | an attack was dodged | | `layerGain` | `{ playerId, bondId, n, reason, source, tile }` | mutable `n` before recording (魔王 +1 …), then clamped to the room left under `BOND_LAYER_CAP` (999); not emitted for a bond already at the cap; `tile` = `[r, c]` where `source` stands — or was knocked out this very instant ("被击倒时" gains) — else null (`addLayers` opts.tile overrides) | | `merchantPay` | `{ unit, cost, cancel }` | a merchant (行商) is about to pay its periodic DP; change `cost` or set `cancel` | diff --git a/server/sim/ai.js b/server/sim/ai.js index f40b4882..f060faa6 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -656,10 +656,17 @@ function enemyAttack(b, e) { if (own && targets.length) targets = targets.filter((a) => own(a)); if (targets.length > 1) sortAllyTargets(e, targets); if (!targets.length) return false; - // 麻痹 (ba.palsy): each stack interrupts one normal attack + // 麻痹 (ba.palsy): each stack interrupts one normal attack. The `palsyTrigger` hook { enemy, buff, keep } fires as it + // does (PRTS 真言 备注: "触发麻痹时" = the attack interrupted by 麻痹, not a stack gained): a handler may set `keep` — the + // stack is not consumed (真言 噤声限域 "触发麻痹时有N%概率不消耗麻痹层数") — or deal damage (the enemy may die there) const palsy = e.buffs.length ? e.findBuff('palsy') : null; if (palsy) { - if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty(); + let keep = false; + if (b._hooks.palsyTrigger) { + keep = !!b.emit('palsyTrigger', { enemy: e, buff: palsy, keep: false }).keep; + if (!e.alive) return false; + } + if (!keep) { if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty(); } e.atkCd = e.s.interval; // the interrupted attack ends its clip: the old short stand after it (PRTS 异常效果 麻痹: 0.5 s 麻痹震颤 — not modelled) if (!e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves)) e.atkStandUntil = b.time + ATTACK_PAUSE; diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index b47d568b..90cd490d 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -78,7 +78,12 @@ export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sh * The 自选 operator kit files (`ops/op-.js`, registry key = the operator's charId, an owned-6★ pick of * data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS). */ -export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js']); +export const OPERATOR_KIT_FILES = Object.freeze([ + 'op-siege.js', + 'op-logos.js', + 'op-mantra.js', + 'op-veen.js', +]); /** * The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 — diff --git a/server/sim/content/kits/ops/op-logos.js b/server/sim/content/kits/ops/op-logos.js new file mode 100644 index 00000000..c67ad2f5 --- /dev/null +++ b/server/sim/content/kits/ops/op-logos.js @@ -0,0 +1,279 @@ +// server/sim/content/kits/ops/op-logos.js — 逻各斯 (char_4133_logos) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★ pick +// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (CCR-Δ, CCR-Y) at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4133_logos, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 逻各斯 (the +// 备注 quoted below). Every number is read from the record (`chess` / the skill blackboards); the few constants below exist +// only in the PRTS 备注. +// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts bolt, hits air units, targetable by ground +// enemies). CCR-Δ “来自河谷的笔盒” adds "造成法术伤害时附带相当于8%伤害的凋亡损伤" (hidden module talent +// `ep_damage_ratio`): every arts damage he deals — attacks, the talent's extra hits, 殁亡's follow-up — fills 8 % of the +// HP it removed as 凋亡 损伤 (the kits' element convention, docs/SIM.md §7.2). CCR-Y “《语义范式百科》” adds +// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb `sp`): +sp SP per normal-attack hit on an elite or leader enemy +// (none while a timed skill runs — AK) and, per the 语汇演化 备注 "…可触发模组提供的额外特性", per talent hit too. +// - T1 语汇演化 "对一个目标发起攻击时,有40%几率额外对攻击范围内一个随机目标造成相当于攻击力60%的法术伤害并使其停顿0.8秒" +// (bb prob / atk_scale / sluggish): rolled for every target of each of his attacks and after 殁亡's follow-up hit (S1 备注 +// "可触发第一天赋"); the extra hit lands at once on a random targetable enemy of his current range [ASSUMED: the attacked +// target can be drawn], then 停顿 (the engine `sluggish`) for `sluggish` s. 备注 "该天赋的触发不依赖普通攻击,不受缴械类效果 +// 制约". CCR-Δ stage 2+: prob 0.5 / 0.6 and, on a target in its 凋亡 burst, `element_atk_scale` × ATK 元素伤害 after the arts +// hit (备注 "先造成法术伤害,后造成元素伤害"). CCR-Y stage 2+: `emit_count` 2 — 备注 "本质上是将该天赋重复执行2次": two +// independent executions (each its own roll and random target). +// - T2 剜魂具辞 "攻击使目标在5秒内法术抗性-10且受到的法术伤害提高150点" (bb duration / magic_resistance / atk_addition): +// each of his attack hits gives the target, for `duration` s, RES `magic_resistance` and +`atk_addition` on every arts +// damage it takes (备注 "在计算攻击倍率后、计算法术抗性前增加当次伤害", "可对当次伤害生效", "即使法术伤害的攻击力为0也可 +// 生效"): added to the hit's pre-mitigation amount, the hit that applies it included. One instance per enemy whoever +// applies it (同名效果: applyStrongest). +// - S1 殁亡 (AUTO, 持续时间无限 ⇒ toggle; the data's DEFAULT trigger — it acts on enemies): range 3-3, ATK +atk, and an aura +// (备注 "光环效果,不依赖攻击行为"): every enemy of his range whose HP is below attack@kill_atk_scale × his ATK takes +// attack@kill_damage (9999999) 无来源 true damage (备注 "无来源真实斩杀伤害"; no dodge), then another random enemy of his +// range takes arts damage equal to the HP the first had left (备注: his T2 bonus applies, T1 rolls). At most one try per +// enemy per entry into the range, and none ever again on an enemy that survived one (备注; a 频次 unit takes 1 HP, a +// leader in a boss battle loses nothing to 限伤 — 备注 "卫戍协议的最终攻势Boss…只能以常规的“当前生命值”处理斩杀的触发"). +// - S2 提喻 (MANUAL, 20 s, data DEFAULT): RES +magic_resistance; the attack locks one target (an instant hit, no bolt +// [ASSUMED: a lock-on beam]) every attack@cooldown s whatever the attack speed (备注 "攻击间隔固定为0.5s"), each hit +// attack@atk_scale_base + attack@atk_scale_delta × n of his ATK and the target's movement speed × (1 + attack@move_speed +// × n), n = the hits made on it so far, at most attack@max_stack_cnt (备注 "线性提升" — 3× and 40 % after 5 s). A stun / +// freeze, the target's fall or no attack for 0.1 s past the cadence (备注 "每0.1s,若自身未处于攻击状态,则退出锁定状态") +// resets the lock and its slow; the next attack locks anew. +// - S3 延异视阈 (MANUAL, 30 s, data ACTIVE_RANGE on its 3-4): ATK +atk, attack@max_target targets, and every enemy +// projectile whose position is on his range flies at projectile_move_scale × its speed (备注 "降低至0.05倍"; leaving the +// range restores it) and is removed when the skill ends — not when he is disarmed then (备注 "受缴械类效果制约") nor when +// he is knocked out [ASSUMED]. Every enemy projectile counts as 可变速 / 可消除 [ASSUMED]. The sim's projectiles live in +// `battle.projectiles.list` (ProjectileSystem, server/sim/projectiles.js): the kit adjusts `speed` / `maxAge` there. + +import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp, once } from '../shared/tier1.js'; +import { isElite, elementDmg } from '../shared/tier6.js'; +import { hasHp } from '../../../damage.js'; +import { COLS, ROWS } from '../../../constants.js'; + +const S1 = 'skchr_logos_1'; +const S2 = 'skchr_logos_2'; +const S3 = 'skchr_logos_3'; +const ANY = Object.freeze({ canHitFly: true }); +/** 剜魂具辞: the debuff on an enemy (one instance whoever applies it). */ +const T2_KEY = 'logos:soulRend'; +/** 提喻 备注 "每0.1s,若自身未处于攻击状态,则退出锁定状态": the lock ends 0.1 s after a missed attack. */ +const LOCK_IDLE = 0.1; +/** Enemy projectiles slowed by a running 延异视阈 (projectile → { orig speed, by: Set of unit ids }); per projectile. */ +const SLOWED = new WeakMap(); + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const tileOf = (p) => { + const r = Math.round(p.y), c = Math.round(p.x); + return r >= 0 && r < ROWS && c >= 0 && c < COLS ? r * COLS + c : -1; +}; +/** Enemy projectiles on the field (ProjectileSystem list: the sim's only projectile store). */ +const enemyShots = (battle) => (battle.projectiles?.list ?? []).filter((p) => p && p.source && p.source.side === 'enemy'); + +/** 延异视阈: slow the enemy projectiles on `unit`'s range, restore those that left it. */ +function slowShots(battle, unit, scale) { + const set = unit.rangeKeySet; + for (const p of enemyShots(battle)) { + let s = SLOWED.get(p); + if (set && set.has(tileOf(p))) { + if (!s) { s = { orig: p.speed, by: new Set() }; SLOWED.set(p, s); } + s.by.add(unit.id); + p.speed = s.orig * scale; + p.maxAge = Math.max(p.maxAge, p.age + 1); // a slowed shot never lands by age while it is held + } else if (s && s.by.delete(unit.id) && !s.by.size) { + p.speed = s.orig; + SLOWED.delete(p); + } + } +} + +/** 延异视阈 ends: clear the enemy projectiles on the range (unless disarmed / knocked out), release the rest. */ +function endShots(battle, unit, clear) { + const set = unit.rangeKeySet; + let n = 0; + if (clear && set && battle.projectiles) { + const keep = []; + for (const p of battle.projectiles.list) { + if (p && p.source && p.source.side === 'enemy' && set.has(tileOf(p))) { n++; SLOWED.delete(p); } else keep.push(p); + } + battle.projectiles.list = keep; + } + for (const p of enemyShots(battle)) { + const s = SLOWED.get(p); + if (s && s.by.delete(unit.id) && !s.by.size) { p.speed = s.orig; SLOWED.delete(p); } + } + if (n) battle.fx('bulletClear', { x: unit.x, y: unit.y, id: unit.id, n }); +} + +export default { + char_4133_logos: (bb, chess) => { + const t0 = talentBb(chess, 0); + const t1 = talentBb(chess, 1); + const hidden = moduleBb(chess); // CCR-Δ: ep_damage_ratio + const tb = traitBb(chess); // CCR-Y: sp + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3); + const lex = { + prob: num(t0.prob), scale: num(t0.atk_scale), sluggish: num(t0.sluggish), + elemScale: num(t0.element_atk_scale), emit: Math.max(1, Math.floor(num(t0.emit_count, 1))), + }; + + /** 语汇演化: `emit` executions, each a `prob` roll for a random enemy of his range. */ + function lexicon(battle, unit) { + if (!(lex.prob > 0) || !(lex.scale > 0)) return; + for (let k = 0; k < lex.emit; k++) { + if (!up(unit) || !battle.rng.chance(lex.prob)) continue; + const t = battle.rng.pick(battle.enemiesInKeys(unit.rangeKeys, unit, ANY)); + if (!t) continue; + battle.fx('logosLexicon', { x: t.x, y: t.y, id: unit.id, target: t.id }); + battle.dealDamage(unit, t, { amount: unit.s.atk * lex.scale, type: 'arts', tags: ['talent', 'logosLexicon'] }); + if (t.alive && lex.sluggish > 0) battle.applyStatus(t, 'sluggish', { duration: lex.sluggish, source: unit }); + if (lex.elemScale > 0 && hasHp(t) && t.findBuff('apoptosisBurst')) { + battle.dealDamage(unit, t, { amount: unit.s.atk * lex.elemScale, type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'logosLexicon'] }); + } + } + } + + /** 殁亡's execution of `e` (HP below the threshold), then the follow-up arts hit on another random enemy of his range. */ + function execute(battle, unit, e, st) { + const left = e.hp; + battle.fx('logosExecute', { x: e.x, y: e.y, id: unit.id, target: e.id }); + battle.dealDamage(unit, e, { amount: num(b1['attack@kill_damage'], 9999999), type: 'true', canDodge: false, sourceless: true, isSkill: true, tags: ['skill', 'execute'] }); + if (e.alive) st.spared.add(e.id); // survived (不死, 频次, 限伤 …): never tried again + const t = battle.rng.pick(battle.enemiesInKeys(unit.rangeKeys, unit, ANY).filter((x) => x !== e)); + if (!t || !(left > 0)) return; + battle.dealDamage(unit, t, { amount: left, type: 'arts', isSkill: true, tags: ['skill', 'logosPerish'] }); + lexicon(battle, unit); + } + + /** 提喻: end the lock (its slow leaves the target). */ + function unlock(battle, unit) { + const L = unit.mem.logosLock; + if (L && L.target) battle.removeBuff(L.target, `logos:lockSlow:${unit.id}`); + unit.mem.logosLock = null; + } + const lockIv = num(b2['attack@cooldown'], 0.5); + const base2 = num(b2['attack@atk_scale_base'], 1), delta2 = num(b2['attack@atk_scale_delta']); + const maxN = Math.max(0, Math.floor(num(b2['attack@max_stack_cnt']))); + const s2On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S2); + + return { + skills: { + [S1]: { + kind: 'toggle', + mods: { atkPct: num(b1.atk) }, + targeting: { rangeGrid: s1?.rangeGrid ?? null }, + onStart({ unit }) { unit.mem.logosExec = { tried: new Set(), spared: unit.mem.logosExec?.spared ?? new Set() }; }, + onTick({ battle, unit }) { + const st = unit.mem.logosExec; + if (!st || !up(unit)) return; + const inRange = battle.enemiesInKeys(unit.rangeKeys, unit, ANY); + const ids = new Set(inRange.map((e) => e.id)); + for (const id of st.tried) if (!ids.has(id)) st.tried.delete(id); // left the range: a new entry may try again + const thr = num(b1['attack@kill_atk_scale']) * unit.s.atk; + for (const e of inRange) { + if (st.tried.has(e.id) || st.spared.has(e.id) || !e.alive || !hasHp(e) || !(e.hp < thr)) continue; + st.tried.add(e.id); + execute(battle, unit, e, st); + if (!up(unit)) return; + } + }, + }, + [S2]: { + kind: 'duration', + mods: { resFlat: num(b2.magic_resistance) }, + attack: { + projectile: 'beam', + atkScale: base2, + // the hit's scale: base + delta × n (n = the hits already made on the locked target) + dmgMul: (battle, unit, target) => { + const L = unit.mem.logosLock; + const n = L && L.target === target ? L.n : 0; + return base2 > 0 ? (base2 + delta2 * n) / base2 : 1; + }, + onEachHit({ battle, unit, target, kind }) { + const L = unit.mem.logosLock; + if (kind !== 'main' || !L || L.target !== target || !target.alive) return; + L.n = Math.min(maxN, L.n + 1); + const mul = Math.max(0, 1 + num(b2['attack@move_speed']) * L.n); + battle.addBuff(target, { key: `logos:lockSlow:${unit.id}`, duration: lockIv + LOCK_IDLE + 0.2, source: unit, mods: { moveMul: mul } }); + }, + }, + onStart({ battle, unit }) { unlock(battle, unit); unit.mem.logosLockAt = null; }, + onAttack({ battle, unit }) { unit.mem.logosLockAt = battle.time; }, + onTick({ battle, unit }) { + const L = unit.mem.logosLock; + // a stun / freeze breaks the lock; so does no attack for LOCK_IDLE past the cadence + if (L && (!unit.canAct || !L.target.alive || battle.time - unit.mem.logosLockAt > lockIv + LOCK_IDLE + 1e-9)) unlock(battle, unit); + if (!unit.canAct) return; + // 攻击间隔固定为0.5s: the next attack exactly lockIv after the last one (the engine counts atkCd down right after) + const at = unit.mem.logosLockAt; + if (at == null) { unit.atkCd = Math.min(unit.atkCd, lockIv); return; } + const left = at + lockIv - battle.time; + unit.atkCd = left > 1e-6 ? left + battle.dt : 0; + }, + onEnd({ battle, unit }) { unlock(battle, unit); }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk) }, + targeting: { rangeGrid: s3?.rangeGrid ?? null, maxTargets: num(b3['attack@max_target'], 1) }, + onTick({ battle, unit }) { slowShots(battle, unit, num(b3.projectile_move_scale, 1)); }, + onEnd({ battle, unit, reason }) { endShots(battle, unit, reason !== 'death' && !unit.s.flags.disarm); }, + }, + }, + talents: [ + { install(battle, unit) { // 语汇演化: every target of each of his attacks + battle.on('attack', (ctx) => { + if (ctx.attacker !== unit || !up(unit)) return; + for (let i = 0; i < ctx.targets.length; i++) lexicon(battle, unit); + }, { owner: unit }); + } }, + { install(battle, unit) { // 剜魂具辞: his attack hits mark the target (before mitigation: the hit itself counts) + const dur = num(t1.duration), res = num(t1.magic_resistance), add = num(t1.atk_addition); + if (!(dur > 0) || !(add > 0 || res)) return; + battle.on('hit', (ctx) => { + if (ctx.source !== unit || !ctx.dmg?.isAttack || !ctx.target || ctx.target.side !== 'enemy') return; + battle.applyStrongest(ctx.target, T2_KEY, { duration: dur, value: add, source: unit, mods: () => (res ? { resFlat: res } : {}) }); + }, { owner: unit, priority: 10 }); + // "受到的法术伤害提高N点": every arts damage on a marked enemy, after the attack multiplier, before its RES + once(battle, 'logos:artsAddition', () => battle.on('hit', (ctx) => { + const d = ctx.dmg; + if (!d || d.type !== 'arts' || !ctx.target || ctx.target.side !== 'enemy') return; + const m = ctx.target.findBuff(T2_KEY); + if (m && m.data && m.data.value > 0) d.amount += m.data.value; + }, { priority: -50 })); + } }, + ], + install(battle, unit) { + // 提喻: keep attacking the locked target while it stays a target; a new lock otherwise + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !s2On(unit) || !ctx.targets.length) return; + const L = unit.mem.logosLock; + const prof = ctx.profile || unit.profile; + if (L && L.target.alive) { + const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof); + if (cands.includes(L.target) || battle.blockedTargets(unit, prof).includes(L.target)) { ctx.targets = [L.target]; return; } + } + unlock(battle, unit); + unit.mem.logosLock = { target: ctx.targets[0], n: 0 }; + ctx.targets = [ctx.targets[0]]; + }, { owner: unit }); + // CCR-Δ: 8 % of every arts damage as 凋亡 损伤 + const ep = num(hidden.ep_damage_ratio); + if (ep > 0) { + battle.on('damaged', (ctx) => { + if (ctx.source !== unit || ctx.type !== 'arts' || !(ctx.amount > 0) || !ctx.target || ctx.target.side !== 'enemy') return; + elementDmg(battle, unit, ctx.target, 'apoptosis', ctx.amount * ep, ['trait', 'logosCcrd']); + }, { owner: unit }); + } + // CCR-Y: a normal-attack (or 语汇演化) hit on an elite / leader enemy gives SP + const sp = num(tb.sp); + if (sp > 0) { + battle.on('damaged', (ctx) => { + const d = ctx.dmg; + if (ctx.source !== unit || !d || ctx.type === 'element' || !ctx.target || ctx.target.side !== 'enemy') return; + if ((d.isAttack || (d.tags || []).includes('logosLexicon')) && isElite(ctx.target)) giveSp(unit, sp, 'trait'); + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-mantra.js b/server/sim/content/kits/ops/op-mantra.js new file mode 100644 index 00000000..65ab3eeb --- /dev/null +++ b/server/sim/content/kits/ops/op-mantra.js @@ -0,0 +1,315 @@ +// server/sim/content/kits/ops/op-mantra.js — 真言 (char_4204_mantra) 自选 operator kit: 6★ 本源术师 (术师), an owned-6★ pick of +// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the module (PRI-X) at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4204_mantra, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, PRI-X at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 真言 (the 备注 +// quoted below). 麻痹 (ba.palsy, PRTS 术语释义): each stack interrupts one normal attack of an enemy, at most 3, kept until +// used — the engine `palsy` status; the moment a stack interrupts an attack is the engine hook `palsyTrigger` (ai.js). +// - Trait (本源术师) "攻击造成法术伤害,可以造成元素伤害": the profession default (ranged arts bolt, hits air units, +// targetable by ground enemies); 元素伤害 = the DamageInfo type 'elemental', 神经 损伤 = element 'neural' (SIM.md §3). PRI-X +// “中枢神经探测模块” "对处于元素爆发期间的敌人造成的伤害提升至110%" (trait bb damage_scale): ×damage_scale on every damage +// she deals to an enemy in an element burst (shared/tier5.js burstDamageUp, as 烛煌 / 妮芙). +// - T1 噤声限域 "场上敌人触发麻痹效果时立即受到相当于真言攻击力135%的元素伤害,且触发麻痹时有10%概率不消耗麻痹层数" (bb +// atk_scale / prob; PRI-X stage 3: 1.6 / 0.15): 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时(而非获得麻痹时)", "造成的元素 +// 伤害为元素持续伤害" (tagged 'dot'), "全场光环存在0.5秒检测周期,单位无法被真言选择的情况下将失去效果" (every 0.5 s the +// enemies she can select — not untargetable, 隐匿 or asleep; "三技能期间,此天赋的检测无视隐匿与沉睡" — carry her mark), +// "麻痹不消耗效果在同类效果中取概率最高" (two 真言: one roll at the higher chance; each deals her own hit [ASSUMED]). +// - T2 全局洞悉 "距离真言最近的一个侵入点出场的敌人出现时立刻获得1层麻痹": at each deployment she picks the 侵入点 (the +// stage's 'start' tiles) nearest by Manhattan distance, a tie drawn at random (备注 "优先选取距离自身曼哈顿距离最近的侵入点, +// 相同距离下随机选取"); every enemy spawning on it gets 1 麻痹 0.1 s later (备注 "【延迟麻痹】:0.1s后获得1层麻痹;付与选取 +// 无视隐匿"). +// - S1 共鸣溃缩 (AUTO, attack SP, data DEFAULT): the next attack deals atk_scale × ATK arts, then ep_damage_ratio of the +// damage as 神经 损伤, then — on a target in its 神经 burst — element_atk_scale × ATK 元素伤害 (S2 备注 "伤害顺序为:法术伤害 → +// 神经损伤 → 元素伤害", the same riders). +// - S2 意识联协 (MANUAL, 25 s, data DEFAULT): attack interval base_attack_time (a flat −0.7 / −0.6 s); each attack deals +// attack@atk_scale × ATK arts and jumps to chain_times other enemies (attack@chain.atk_scale × ATK arts each), every hit +// with the S1 riders at attack@ep_damage_ratio / attack@element_atk_scale. 备注 "跳跃半径2.0,首次跳跃开始后弹道速度降低至 +// 3.2": each jump flies at 3.2 tiles/s to the nearest not-yet-hit enemy within 2.0 tiles [ASSUMED: nearest, as the +// 链术师]. The first shot keeps the engine's bolt (11 tiles/s; 备注 10). +// - S3 无言为真 (MANUAL, 40 s, data ACTIVE_RANGE on its 3-17): ATK +atk, attack@max_target targets; on her range (备注: +// 【反隐】 / the overflow by 碰撞判定 — an enemy's body —, 【麻痹付与】 by the enemy's tile) enemies lose 隐匿 (reveal) and +// their 麻痹 cap falls to 2 (text "降为2"; no blackboard key): stacks above it, held when the cap falls or gained later, become +// "溢出麻痹" (麻痹免疫 enemies count too — 备注 "仅为令麻痹失效": the stacks 真言 gives them are kept here); each one turns +// into an 元素伤害 bounce of atk_scale × ATK — on the holder, then (chain_times) to the nearest other enemy within 2.0 tiles +// at 3.2 tiles/s, max_target hits — one at once when gained and one every interval_projectile_trigger s after, all of them +// at once when the holder leaves the range, falls or the skill ends (备注). 【麻痹付与】: 0.1 s after the cast and whenever +// another operator on her range starts a skill, every enemy on her range gets per_active 麻痹, max_trigger_cnt times per +// cast, a grant with no enemy there kept (备注 "在范围内不存在可选敌方单位时保留可付与次数"). + +import { num, talentBb, traitBb, skillRec, batMod, up, once } from '../shared/tier1.js'; +import { elementDmg } from '../shared/tier6.js'; +import { burstDamageUp } from '../shared/tier5.js'; +import { hasHp } from '../../../damage.js'; +import { canTargetEnemy, enemyStealthed } from '../../../targeting.js'; +import { bodyInKeys } from '../../../body.js'; +import { COLS, PALSY_MAX } from '../../../constants.js'; + +const S1 = 'skchr_mantra_1'; +const S2 = 'skchr_mantra_2'; +const S3 = 'skchr_mantra_3'; +const ANY = Object.freeze({ canHitFly: true }); +/** 意识联协 / 无言为真 备注: "跳跃半径2.0", "速度3.2,弹射半径2.0". */ +const JUMP_RADIUS = 2.0; +const JUMP_SPEED = 3.2; +/** 无言为真 "麻痹层数上限降为2" (text only). */ +const S3_CAP = 2; +/** 无言为真 备注 "【麻痹付与】效果在自身技能开启后的0.1s触发一次". */ +const GRANT_DELAY = 0.1; +/** 噤声限域 备注 "全场光环存在0.5秒检测周期". */ +const MARK_IV = 0.5; +const MARK = 'mantra:hush:'; +/** 全局洞悉 备注 "0.1s后获得1层麻痹". */ +const SPAWN_DELAY = 0.1; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const palsyImmune = (e) => !!e?.def?.immune?.has?.('palsy'); +/** 麻痹 stacks of an enemy — a 麻痹免疫 one keeps the ineffective stacks 真言 gave it in `mem.mantraPalsy`. */ +const palsyOf = (e) => (palsyImmune(e) ? (e.mem.mantraPalsy ?? 0) : (e.findBuff('palsy')?.stacks ?? 0)); +function setPalsy(battle, e, n) { + if (palsyImmune(e)) { e.mem.mantraPalsy = n; return; } + const b = e.findBuff('palsy'); + if (!b) return; + if (n <= 0) battle.removeBuff(e, b); + else { b.stacks = n; e.markDirty(); } +} +const tileIn = (e, set) => set.has(Math.round(e.y) * COLS + Math.round(e.x)); + +/** The S1 / S2 riders: share × the damage dealt as 神经 损伤, then elemScale × ATK 元素伤害 on a target in its 神经 burst. */ +function riders(battle, unit, t, dealt, share, elemScale) { + if (!t || t.side !== 'enemy') return; + if (share > 0 && dealt > 0) elementDmg(battle, unit, t, 'neural', dealt * share, ['skill', 'mantra']); + if (elemScale > 0 && hasHp(t) && t.findBuff('neuralBurst')) { + battle.dealDamage(unit, t, { amount: unit.s.atk * elemScale, type: 'elemental', element: 'neural', canDodge: false, isSkill: true, tags: ['skill', 'mantra'] }); + } +} + +/** The nearest enemy within `r` of (x, y) that `unit` can select, none of `skip`. */ +function nearestFoe(battle, unit, x, y, r, skip) { + let best = null, bd = Infinity; + for (const e of battle.enemiesInRadius(x, y, r)) { + if (skip.has(e) || !canTargetEnemy(unit, e, ANY)) continue; + const d = Math.hypot(e.x - x, e.y - y); + if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; } + } + return best; +} + +export default { + char_4204_mantra: (bb, chess) => { + const t0 = talentBb(chess, 0); + const tb = traitBb(chess); // PRI-X: damage_scale + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s3 = skillRec(chess, S3); + const s3On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S3 && unit.mem.mantraS3); + + /** 意识联协: `left` jumps from `from`, each to the nearest not-yet-hit enemy, at JUMP_SPEED. */ + function chain(battle, unit, from, hit, left, attackId) { + if (left <= 0) return; + const next = nearestFoe(battle, unit, from.x, from.y, JUMP_RADIUS, hit); + if (!next) return; + hit.add(next); + battle.addProjectile({ + from: { x: from.x, y: from.y }, target: next, speed: JUMP_SPEED, visual: 'bolt', source: unit, hitDead: true, + onHit: ({ target, x, y }) => { + if (target && target.alive) { + const d = battle.dealDamage(unit, target, { amount: unit.s.atk * num(b2['attack@chain.atk_scale'], 1), type: 'arts', isAttack: true, isSkill: true, tags: ['chain'], attackId }); + riders(battle, unit, target, d, num(b2['attack@ep_damage_ratio']), num(b2['attack@element_atk_scale'])); + } + chain(battle, unit, target && target.alive ? target : { x, y }, hit, left - 1, attackId); + }, + }); + } + + /** 无言为真: one 溢出麻痹 → an 元素伤害 bounce on `holder`, then to the nearest other enemy (chain_times jumps). */ + function bounce(battle, unit, holder) { + const amount = unit.s.atk * num(b3.atk_scale); + if (!(amount > 0)) return; + const hit = new Set([holder]); + const elem = (t) => battle.dealDamage(unit, t, { amount, type: 'elemental', element: 'neural', canDodge: false, isSkill: true, tags: ['skill', 'mantraOverflow'] }); + let left = Math.max(1, Math.floor(num(b3.max_target, 2))); + if (holder.alive && hasHp(holder)) { elem(holder); left--; } + battle.fx('mantraArc', { x: holder.x, y: holder.y, id: unit.id, target: holder.id }); + const jump = (from, jumps) => { + if (jumps <= 0 || left <= 0) return; + const next = nearestFoe(battle, unit, from.x, from.y, JUMP_RADIUS, hit); + if (!next) return; + hit.add(next); + left--; + battle.addProjectile({ + from: { x: from.x, y: from.y }, target: next, speed: JUMP_SPEED, visual: 'bolt', source: unit, + onHit: ({ target }) => { if (target && target.alive) elem(target); if (target) jump(target, jumps - 1); }, + }); + }; + jump(holder, Math.max(0, Math.floor(num(b3.chain_times, 1)))); + } + + /** 溢出麻痹 gained by `e`: one turns into a bounce at once, the next one interval later. */ + function overflow(battle, unit, st, e, rec, n) { + if (!(n > 0)) return; + rec.over += n; + rec.over--; + rec.next = battle.time + num(b3.interval_projectile_trigger, 1.5); + bounce(battle, unit, e); + } + /** `e` leaves the overflow (range, fall, skill end): every 溢出麻痹 left bounces at once. */ + function flush(battle, unit, e, rec) { + while (rec.over > 0) { rec.over--; bounce(battle, unit, e); } + } + /** 真言's own 麻痹 (S3 grants, 全局洞悉): a 麻痹免疫 enemy keeps them (ineffective) for the overflow count. */ + function givePalsy(battle, unit, e, n) { + if (!e.alive || !(n > 0)) return; + if (!palsyImmune(e)) { battle.applyStatus(e, 'palsy', { value: n, source: unit }); return; } + const owner = battle.allyUnits.find((a) => s3On(a) && a.mem.mantraS3.map.has(e)); + const cap = owner ? S3_CAP : PALSY_MAX; + const cur = palsyOf(e); + const add = Math.min(n, Math.max(0, cap - cur)); + e.mem.mantraPalsy = cur + add; + if (owner && n > add) overflow(battle, owner, owner.mem.mantraS3, e, owner.mem.mantraS3.map.get(e), n - add); + } + /** 【麻痹付与】: per_active 麻痹 to every enemy on her range (by its tile); kept when there is none. */ + function grant(battle, unit) { + const st = unit.mem.mantraS3; + if (!st || st.grants <= 0 || !s3On(unit)) return; + const set = unit.rangeKeySet; + const foes = battle.enemies.filter((e) => e.alive && !e.hidden && set && tileIn(e, set)); + if (!foes.length) return; + st.grants--; + battle.fx('mantraGrant', { x: unit.x, y: unit.y, id: unit.id, n: foes.length }); + for (const e of foes) givePalsy(battle, unit, e, Math.max(1, Math.floor(num(b3.per_active, 1)))); + } + + return { + skills: { + [S1]: { + kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant', + attack: { + atkScale: num(b1.atk_scale, 1), + onHit({ battle, unit, target, dealt }) { riders(battle, unit, target, dealt, num(b1.ep_damage_ratio), num(b1.element_atk_scale)); }, + }, + }, + [S2]: { + kind: 'duration', + mods: { batPct: batMod(b2.base_attack_time, chess) }, + attack: { + atkScale: num(b2['attack@atk_scale'], 1), + onEachHit({ battle, unit, target, dealt, kind, attackId }) { + if (kind !== 'main' || !target) return; + riders(battle, unit, target, dealt, num(b2['attack@ep_damage_ratio']), num(b2['attack@element_atk_scale'])); + chain(battle, unit, target, new Set([target]), Math.max(0, Math.floor(num(b2.chain_times))), attackId); + }, + }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk) }, + targeting: { rangeGrid: s3?.rangeGrid ?? null, maxTargets: num(b3['attack@max_target'], 1) }, + onStart({ battle, unit, skill }) { + const n = skill.activations; + unit.mem.mantraS3 = { grants: Math.max(0, Math.floor(num(b3.max_trigger_cnt))), map: new Map() }; + battle.after(GRANT_DELAY, () => { if (skill.activations === n) grant(battle, unit); }); + }, + onTick({ battle, unit, dt }) { + const st = unit.mem.mantraS3; + const set = unit.rangeKeySet; + if (!st || !set) return; + const seen = new Set(); + for (const e of battle.enemies) { + if (!e.alive || e.hidden || !bodyInKeys(e, set)) continue; + seen.add(e); + // 【反隐】 (隐匿免疫) on her range + if (e.s.flags.stealth) battle.applyStatus(e, 'reveal', { duration: 2 * dt + 0.05, source: unit }); + let rec = st.map.get(e); + if (!rec) { + rec = { over: 0, next: Infinity }; + st.map.set(e, rec); + const cur = palsyOf(e); + if (cur > S3_CAP) { setPalsy(battle, e, S3_CAP); overflow(battle, unit, st, e, rec, cur - S3_CAP); } + } else if (rec.over > 0 && battle.time >= rec.next - 1e-9) { + rec.over--; + rec.next = battle.time + num(b3.interval_projectile_trigger, 1.5); + bounce(battle, unit, e); + } + } + for (const [e, rec] of [...st.map]) if (!seen.has(e)) { st.map.delete(e); flush(battle, unit, e, rec); } + }, + onEnd({ battle, unit }) { + const st = unit.mem.mantraS3; + unit.mem.mantraS3 = null; + if (st) for (const [e, rec] of st.map) flush(battle, unit, e, rec); + }, + }, + }, + talents: [ + { install(battle, unit) { // 噤声限域: her mark on every enemy she can select (0.5 s); a 麻痹 interrupt hits it + const scale = num(t0.atk_scale), prob = num(t0.prob); + if (!(scale > 0) && !(prob > 0)) return; + battle.every(MARK_IV, () => { + if (!up(unit)) return; + const all = s3On(unit); // 三技能期间: 隐匿 / 沉睡 do not hide an enemy from the check + for (const e of battle.enemies) { + if (!e.alive || e.hidden || !e.deployed || e.s.flags.untargetable) continue; + if (!all && (e.s.flags.sleep || (e.s.flags.stealth && enemyStealthed(e)))) continue; + battle.addBuff(e, { key: MARK + unit.id, duration: MARK_IV + 0.1, source: unit, data: { scale, prob } }); + } + }, { owner: unit, immediate: true }); + once(battle, 'mantra:palsyTrigger', () => battle.on('palsyTrigger', (ctx) => { + const e = ctx.enemy; + let p = 0; + for (const m of e.buffs.slice()) { + if (typeof m.key !== 'string' || !m.key.startsWith(MARK) || !m.data) continue; + const src = m.source; + if (!up(src)) continue; + if (m.data.scale > 0 && hasHp(e)) { + battle.dealDamage(src, e, { amount: src.s.atk * m.data.scale, type: 'elemental', element: 'neural', canDodge: false, tags: ['talent', 'dot', 'mantraHush'] }); + } + p = Math.max(p, m.data.prob); + if (!e.alive) return; + } + if (p > 0 && battle.rng.chance(p)) ctx.keep = true; + })); + } }, + { install(battle, unit) { // 全局洞悉: the nearest 侵入点; its spawns get 1 麻痹 0.1 s later + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + let best = [], bd = Infinity; + for (const [r, c] of battle.grid.specialTiles('start')) { + const d = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC); + if (d < bd) { bd = d; best = [[r, c]]; } else if (d === bd) best.push([r, c]); + } + unit.mem.mantraGate = best.length > 1 ? battle.rng.pick(best) : best[0] ?? null; + if (unit.mem.mantraGate) battle.fx('mantraGate', { x: unit.mem.mantraGate[1], y: unit.mem.mantraGate[0], id: unit.id }); + }, { owner: unit }); + battle.on('enemySpawn', ({ enemy: e }) => { + const g = unit.mem.mantraGate; + if (!g || !up(unit) || Math.round(e.spawnY) !== g[0] || Math.round(e.spawnX) !== g[1]) return; + battle.after(SPAWN_DELAY, () => givePalsy(battle, unit, e, 1)); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // PRI-X: ×damage_scale on enemies in an element burst + burstDamageUp(battle, unit, num(tb.damage_scale)); + // 无言为真: the 麻痹 cap 2 on her range; the stacks above it become 溢出麻痹 + battle.on('beforeStatus', (c) => { + if (c.status !== 'palsy' || !s3On(unit) || !c.target || c.target.side !== 'enemy') return; + const st = unit.mem.mantraS3; + const rec = st.map.get(c.target); + if (!rec) return; + const n = Math.max(1, Math.floor(Number.isFinite(c.value) ? c.value : 1)); + const room = Math.max(0, S3_CAP - palsyOf(c.target)); + if (n <= room) return; + if (room > 0) c.value = room; else c.cancel = true; + overflow(battle, unit, st, c.target, rec, n - room); + }, { owner: unit }); + // 【麻痹付与】 when another operator on her range starts a skill + battle.on('skillStart', (ctx) => { + const u = ctx.unit; + if (u === unit || !u || u.kind !== 'op' || !s3On(unit) || !unit.rangeKeySet?.has(u.tileR * COLS + u.tileC)) return; + grant(battle, unit); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-veen.js b/server/sim/content/kits/ops/op-veen.js new file mode 100644 index 00000000..64b018ef --- /dev/null +++ b/server/sim/content/kits/ops/op-veen.js @@ -0,0 +1,239 @@ +// server/sim/content/kits/ops/op-veen.js — 维伊 (char_4226_veen) 自选 operator kit: 6★ 秘术师 (术师), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the module (MSC-Y) at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4226_veen, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, MSC-Y at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 维伊 (the 备注 +// quoted below). +// - Trait (秘术师) "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射" (ranged arts, hits air units, +// targetable by ground enemies). PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在 +// 有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为)" — when her attack is ready +// and she has no valid target she stores one energy and her attack interval starts again (this kit replaces the +// engine's mystic profile, which stores one interval after the attack is ready); the energies fly with her next attack, +// at its target ("由储存能量形成的弹道造成攻击力100%的法术普通伤害"). +// T1 “在挥刀之前” "特性储存的能量达到3个时,合成为1个转置能量(至多储存等同于9倍的能量)" (trait bb merge_cnt 3, times 9): +// 3 stored make 1 转置能量, at most times / merge_cnt of those, then no more storing (备注 "转置能量数量达到上限时,停止储存 +// 攻击能量"); a 转置能量 hits for merge_cnt × ATK (备注 "攻击力300%的法术普通伤害"). Every shot uses the ATK it left with +// (备注 "所有攻击、储存能量、转置能量的弹道均使用脱手时的缓存攻击力"), released 转置能量 → main attack → stored energies (S2 +// 备注 "弹道创建顺序"); one volley flies to one target and lands at once, so the kit resolves all its hits as the main bolt +// lands (the engine's own hit makes none: `hitsFn` 0), with that attack's attackId, the later ones only on a target still +// alive. [ASSUMED] a redeployment holds no energy. +// - T1 “在挥刀之前” "拥有转置能量时攻击力+10%,未拥有转置能量时攻击速度+15" (bb atk / attack_speed): two toggles on her +// stored state (checked every tick — the 备注's "攻击力加成失效存在短暂延迟": the volley that spends the 转置能量 keeps it). +// - T2 战争技艺 "攻击和储存的能量使目标在5秒内每秒受到90点法术伤害(至多叠加3次,发射转置能量可以叠加3次),维伊不以该效果影响的 +// 敌人为攻击目标" (bb attack@duration / attack@value / attack@max_stack_cnt; MSC-Y stage 3: 120 / 4): each hit adds 1 +// stack (a 转置能量 3, an S3 bounce 1 — S3 备注 "相当于一发储存能量"), each stack attack@value arts per second, the 5 s +// restarting with every stack [ASSUMED]; she never picks a marked enemy (canAttack / beforeAttack, as 深靛 skips bound +// enemies — with only marked enemies in range she stores energy). +// - MSC-Y “军械库” "拥有已储存的攻击能量时,攻击速度+30" (hidden module talent attack_speed): while she holds any energy. +// - S1 “自呼号生发” (AUTO, 18 s): ASPD +attack_speed — an AUTO skill acting on herself fires at full SP (the owner's rule, +// kits/README.md checklist 5: `trigger: 'SP_FULL'`). +// - S2 “以鲜血洗去” (MANUAL, 28 s, data DEFAULT): attack interval base_attack_time (a flat −0.5 s on 3.0 s); every released +// main attack or stored energy adds a stack (a 转置能量 3), at most …max_stack_cnt: ATK +…atk and ASPD +…attack_speed each, +// until the skill ends; a shot leaving later in the same volley already has the stacks of those before it (备注). +// - S3 “用赤铁铭记” (MANUAL, 21 / 18 bullets, data DEFAULT): ASPD +attack_speed; 备注 "技能期间发射的储存能量与转置能量不会 +// 产生伤害,维伊仅在每次攻击时根据当前储存能量与转置能量消耗相应数量的弹药(不考虑是否足够),并令本次攻击获得相应弹射次数": +// an attack spends 1 bullet per stored energy and merge_cnt per 转置能量 (none without) and its main hit +// (attack@base_atk_scale × ATK) bounces that many times, every 0.5 s within 1.7 tiles (备注 "弹射间隔0.5,弹射半径1.7"), +// preferring an enemy not hit yet by it, else another one, else the same (text "优先不同目标" [ASSUMED order]), each +// attack@bounce_atk_scale × the cached ATK arts. The skill ends when the bullets run out. + +import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } from '../shared/tier1.js'; +import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; + +const S1 = 'skchr_veen_1'; +const S2 = 'skchr_veen_2'; +const S3 = 'skchr_veen_3'; +const ANY = Object.freeze({ canHitFly: true }); +/** 用赤铁铭记 备注 "弹射间隔0.5,弹射半径1.7". */ +const BOUNCE_IV = 0.5; +const BOUNCE_R = 1.7; +const S2_STACK = 'veen:s2stack'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const warKey = (unit) => `veen:war:${unit.id}`; +const marked = (unit, e) => !!e.findBuff(warKey(unit)); +const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id); +/** + * Her stored energies (per deployment): `s` stored, `t` 转置能量, `q` the volleys in flight per target, `use` the S3 bullets of + * the attack just made, `k` the S2 stacks. + */ +const stateOf = (unit) => unit.trait.veen ?? (unit.trait.veen = { s: 0, t: 0, q: new Map(), use: 0, k: 0 }); + +/** Enemies she may pick: in her range (or blocked by her), none under her 战争技艺. */ +function validTargets(battle, unit, prof) { + const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof); + for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e); + return cands.filter((e) => !marked(unit, e)); +} + +export default { + char_4226_veen: (bb, chess) => { + const t0 = talentBb(chess, 0); + const t1 = talentBb(chess, 1); + const tb = traitBb(chess); + const hidden = moduleBb(chess); // MSC-Y: attack_speed + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const merge = Math.max(1, Math.floor(num(tb.merge_cnt, 3))); + const tMax = Math.max(1, Math.floor(num(tb.times, 9) / merge)); + const war = { dur: num(t1['attack@duration']), value: num(t1['attack@value']), max: Math.max(1, Math.floor(num(t1['attack@max_stack_cnt'], 1))) }; + const s2 = { atk: num(b2['attack@veen_s_2_buff[stack].atk']), aspd: num(b2['attack@veen_s_2_buff[stack].attack_speed']), max: Math.max(0, Math.floor(num(b2['attack@veen_s_2_buff[stack].max_stack_cnt']))) }; + + /** 战争技艺: `n` stacks on `e`. */ + function warOn(battle, unit, e, n) { + if (!e || !e.alive || !(war.value > 0) || !(war.dur > 0) || !(n > 0)) return; + battle.addBuff(e, { + key: warKey(unit), duration: war.dur, refresh: 'stack', stacks: Math.min(n, war.max), maxStacks: war.max, source: unit, + interval: 1, data: { value: war.value }, + onTick: ({ battle: b, unit: t, buff }) => { b.dealDamage(unit, t, { amount: buff.data.value * buff.stacks, type: 'arts', canDodge: false, tags: ['talent', 'dot', 'veenWar'] }); }, + }); + } + /** S2: `n` more stacks (a released shot). */ + function s2Stack(battle, unit, n) { + if (!skillOn(unit, S2) || !(s2.max > 0)) return; + const v = stateOf(unit); + v.k = Math.min(s2.max, v.k + n); + battle.addBuff(unit, { key: S2_STACK, mods: { atkPct: s2.atk * v.k, aspd: s2.aspd * v.k }, tags: ['skill'] }); + } + /** One hit of the volley: `atk` (cached at release) × `scale` arts, then `stacks` of 战争技艺. */ + function hitOnce(battle, unit, t, atk, scale, stacks, attackId, isSkill) { + battle.dealDamage(unit, t, { amount: atk * scale * unit.s.atkScaleMul, type: 'arts', isAttack: true, isSkill, attackId, tags: ['veen'] }); + warOn(battle, unit, t, stacks); + } + /** S3: `n` bounces from `from`, every BOUNCE_IV s, each bounce × the cached `atk`. */ + function bounces(battle, unit, from, n, atk, attackId) { + const hit = new Set([from]); + let cur = from; + const step = (left) => { + if (left <= 0) return; + battle.after(BOUNCE_IV, () => { + const near = battle.enemiesInRadius(cur.x, cur.y, BOUNCE_R).filter((e) => canTargetEnemy(unit, e, ANY)); + let pool = near.filter((e) => !hit.has(e)); + if (!pool.length) pool = near.filter((e) => e !== cur); + if (!pool.length) pool = near; + let next = null, bd = Infinity; + for (const e of pool) { + const d = Math.hypot(e.x - cur.x, e.y - cur.y); + if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && next && e.spawnSeq < next.spawnSeq)) { bd = d; next = e; } + } + if (!next) return; // nobody within reach: the bounces left are lost + battle.fx('veenBounce', { x: cur.x, y: cur.y, id: unit.id, target: next.id }); + hit.add(next); + cur = next; + battle.dealDamage(unit, next, { amount: atk * num(b3['attack@bounce_atk_scale'], 1) * unit.s.atkScaleMul, type: 'arts', isAttack: true, attackId, tags: ['veen', 'veenBounce'] }); + warOn(battle, unit, next, 1); + step(left - 1); + }); + }; + step(n); + } + /** The main bolt of an attack lands on `victim`: every hit of its volley (released in order), then the S3 bounces. */ + function land(battle, unit, victim, attackId, isSkill) { + const v = stateOf(unit); + const list = v.q.get(victim.id); + const rec = list ? list.shift() : null; + if (list && !list.length) v.q.delete(victim.id); + if (!rec) { hitOnce(battle, unit, victim, unit.s.atk, 1, 1, attackId, isSkill); return; } + for (const p of rec.pre) { if (!victim.alive) return; hitOnce(battle, unit, victim, p.atk, p.scale, p.stacks, attackId, isSkill); } + if (!victim.alive) return; + hitOnce(battle, unit, victim, rec.main.atk, rec.main.scale, 1, attackId, isSkill); + if (rec.main.bounces > 0) bounces(battle, unit, victim, rec.main.bounces, rec.main.atk, attackId); + for (const p of rec.post) { if (!victim.alive) return; hitOnce(battle, unit, victim, p.atk, p.scale, p.stacks, attackId, isSkill); } + } + + return { + trait: { + hitsFn: () => 0, // the volley's hits are the kit's (land) + onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main' && victim && victim.side === 'enemy') land(battle, unit, victim, hc.attackId, !!hc.isSkill); }, + // only enemies under her 战争技艺 in range: she holds her fire (and stores energy) + canAttack(battle, u) { + const ok = validTargets(battle, u, u.profile).length > 0; + if (!ok) u.trait.hadTarget = false; + return ok; + }, + install(battle, unit) { + battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.veen = null; }, { owner: unit }); + // the mystic store: her attack ready and no valid target ⇒ one energy, an attack action (the interval restarts); + // 3 merge into a 转置能量 (the attack loop ran first this tick: a valid target was attacked instead) + battle.on('tick', () => { + const v = stateOf(unit); + if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0 || v.t >= tMax) return; + if (validTargets(battle, unit, unit.profile).length) return; + if (++v.s >= merge) { v.s -= merge; v.t++; } + unit.atkCd = unit.s.interval; + }, { owner: unit }); + battle.on('death', (ctx) => { if (ctx.unit && ctx.unit.side === 'enemy') stateOf(unit).q.delete(ctx.unit.id); }, { owner: unit }); + }, + }, + skills: { + [S1]: { kind: 'duration', trigger: { rule: 'SP_FULL' }, mods: { aspd: num(b1.attack_speed) } }, + [S2]: { + kind: 'duration', + mods: { batPct: batMod(b2.base_attack_time, chess) }, + onStart({ battle, unit }) { stateOf(unit).k = 0; battle.removeBuff(unit, S2_STACK); }, + onEnd({ battle, unit }) { stateOf(unit).k = 0; battle.removeBuff(unit, S2_STACK); }, + }, + [S3]: { + kind: 'ammo', + ammo: num(b3['attack@trigger_time'], 1), + mods: { aspd: num(b3.attack_speed) }, + // bullets: 1 per stored energy, merge_cnt per 转置能量 the attack released (none without) + onAttack(ctx) { + ctx.noAmmo = true; + const { battle, unit, skill } = ctx; + const v = stateOf(unit); + const n = v.use; + v.use = 0; + for (let i = 0; i < n && skill.active; i++) { + skill.ammoLeft--; + battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); + if (skill.ammoLeft <= 0) skill.end('ammo'); + } + }, + }, + }, + talents: [ + { install(battle, unit) { // “在挥刀之前”: ATK while holding a 转置能量, ASPD while holding none + const atk = num(t0.atk), aspd = num(t0.attack_speed); + if (atk) toggleBuff(battle, unit, 'talent:veen:merged', () => stateOf(unit).t > 0, { atkPct: atk }); + if (aspd) toggleBuff(battle, unit, 'talent:veen:unmerged', () => stateOf(unit).t === 0, { aspd }); + } }, + { install(battle, unit) { // 战争技艺: the stacks come with every hit (warOn); she never picks a marked enemy + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || ctx.targets.every((e) => !marked(unit, e))) return; + const prof = ctx.profile || unit.profile; + const cands = validTargets(battle, unit, prof); + sortEnemyTargets(battle, unit, cands, prof.priority); + ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length)); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // MSC-Y: ASPD while she holds stored energy + const msc = num(hidden.attack_speed); + if (msc) toggleBuff(battle, unit, 'trait:veen:stored', () => { const v = stateOf(unit); return v.s + v.t > 0; }, { aspd: msc }); + // the volley leaves with the attack: 转置能量 → main → stored, each with the ATK of its release (S2 stacks between) + battle.on('attack', (ctx) => { + if (ctx.attacker !== unit || !ctx.targets.length) return; + const v = stateOf(unit); + const t = v.t, s = v.s; + v.t = 0; v.s = 0; + const s3 = skillOn(unit, S3); + const pre = [], post = []; + for (let i = 0; i < t; i++) { if (!s3) pre.push({ atk: unit.s.atk, scale: merge, stacks: merge }); s2Stack(battle, unit, merge); } + const main = { atk: unit.s.atk, scale: s3 ? num(b3['attack@base_atk_scale'], 1) : 1, bounces: s3 ? s + merge * t : 0 }; + s2Stack(battle, unit, 1); + for (let i = 0; i < s; i++) { if (!s3) post.push({ atk: unit.s.atk, scale: 1, stacks: 1 }); s2Stack(battle, unit, 1); } + v.use = s3 ? s + merge * t : 0; + ctx.targets.forEach((tg, i) => { + const list = v.q.get(tg.id) ?? []; + list.push(i === 0 ? { pre, main, post } : { pre: [], main: { atk: main.atk, scale: main.scale, bounces: 0 }, post: [] }); + v.q.set(tg.id, list); + }); + if (t || s) battle.fx('veenVolley', { x: unit.x, y: unit.y, id: unit.id, target: ctx.targets[0].id, stored: s, merged: t }); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/test/content/op_logos.test.js b/test/content/op_logos.test.js new file mode 100644 index 00000000..8a694059 --- /dev/null +++ b/test/content/op_logos.test.js @@ -0,0 +1,390 @@ +// test/content/op_logos.test.js — the 自选 operator kit of 逻各斯 (char_4133_logos, 6★ 中坚术师; kit +// server/sim/content/kits/ops/op-logos.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in +// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-Δ +// “来自河谷的笔盒” or CCR-Y “《语义范式百科》” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from +// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_logos.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 LOGOS = 'char_4133_logos'; +const FORMS = BACKUPS.units[LOGOS].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const CCRD = 'uniequip_002_logos', CCRY = 'uniequip_003_logos'; +const S1 = 'skchr_logos_1', S2 = 'skchr_logos_2', S3 = 'skchr_logos_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +/** The talent bb of a form with a module's changes (stage 2+ rewrites 语汇演化). */ +const talentOf = (tier, elite, mod, i) => { + const m = elite ? modOf(tier, mod) : null; + return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb; +}; +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_elite: dummy('enemy_elite', { rank: 'ELITE' }), + enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }), enemy_low: dummy('enemy_low', { hp: 300 }), + enemy_gun: dummy('enemy_gun', { atk: 100, range: 3, bat: 1, applyWay: 'RANGED' }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRD, CCRY].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** A battle with 逻各斯 as uid 1 at (row, col) facing RIGHT. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, setup } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup, + flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'elementBurst'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LOGOS, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes(tag)); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +/** Start the skill now (SP filled, no enemy needed). */ +function cast(u) { u.skill.gainSp(999); assert.ok(u.skill.activate('test')); } + +test('逻各斯 in every 自选 form: his operator kit (all three skills authored), stats + module attributes, 3-1 range, ranged arts that hits air units, targetable by ground enemies, 空 bond, no 特质', () => { + assert.equal(OPERATOR_KITS[LOGOS], KITS[LOGOS]); + 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], [LOGOS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res + (m?.attr.res ?? 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.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 him`); + done(h); + } + } + // the numbers of the forms (zh_CN): E2 Lv1 1297 / 556, E2 Lv60 1540 / 632; CCR-Δ +100 / +36 → +170 / +67 HP / ATK, + // CCR-Y +35 / +5 → +60 / +5 ATK / RES + 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], [1297, 556, 1540, 632]); + assert.deepEqual([modOf(5, CCRD).attr, modOf(6, CCRD).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr], [{ maxHp: 100, atk: 36 }, { maxHp: 170, atk: 67 }, { atk: 35, res: 5 }, { atk: 60, res: 5 }]); +}); + +test('a 自选 pick: 逻各斯 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(LOGOS)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LOGOS), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: LOGOS, skillIndex: 2, uniEquipId: CCRY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: LOGOS, skillIndex: 2, uniEquipId: CCRY } } }); +}); + +test('S1 殁亡 (AUTO, data DEFAULT): 74 / 68 SP from 0, cast as he is about to attack an enemy of his 3-1; then for good the 3-3 range and ATK +40 % / +70 %', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, sk.initSp, sk.bb.atk, sk.rangeId], ['DEFAULT', 'toggle', elite ? 68 : 74, 0, elite ? 0.7 : 0.4, '3-3'], `T${tier}`); + h.run(sk.spCost - 1); + assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`); + h.run(3); + assert.equal(u.skill.activations, 0, `T${tier}: full SP, no enemy: no cast`); + h.spawn('enemy_dummy', { pos: [10, 8] }); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in his range`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while on`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + h.run(200); + assert.ok(u.skill.active, `T${tier}: 持续时间无限`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: still 3-3`); + done(h); + } +}); + +test('S1 殁亡 execution: an enemy of his range under 90 % / 120 % of his ATK falls at once (9999999 无来源 true damage, no dodge), then another random enemy of the range takes arts damage equal to the HP it had left; one try only on a survivor; air units too', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + let guard = null; + const { h, u } = field({ tier, elite, skill: 0, setup: (b) => b.on('fatal', (c) => { if (c.unit === guard) c.prevented = true; }) }); + cast(u); + const thr = sk.bb['attack@kill_atk_scale'] * u.s.atk; + assert.equal(sk.bb['attack@kill_damage'], 9999999); + const big = h.spawn('enemy_dummy', { pos: [11, 8] }); // a 3-3 tile outside the 3-1 (the toggle's range counts) + const low = h.spawn('enemy_fly', { pos: [10, 7] }); + const hp0 = thr * 0.6; + low.hp = hp0; + const high = h.spawn('enemy_dummy', { pos: [9, 6] }); + h.step(); + assert.ok(!low.alive, `T${tier}: the flyer under the threshold fell`); + const kill = h.hooksOf('damaged').find((c) => c.target === low && (c.dmg?.tags || []).includes('execute')); + assert.ok(kill && kill.source === null && kill.credit === u && kill.type === 'true' && kill.dmg.sourceless && !kill.dmg.canDodge, `T${tier}: 无来源 true, credited to him`); + assert.equal(low.removeReason, 'killed'); + const follow = tagged(h, u, 'logosPerish'); + assert.equal(follow.length, 1, `T${tier}: one follow-up`); + assert.ok([big, high].includes(follow[0].target) && follow[0].type === 'arts', `T${tier}: on another enemy of his range`); + approx(follow[0].amount, hp0, `T${tier}: the HP the fallen one had left`); + assert.ok(![big, high].some((e) => h.hooksOf('damaged').some((c) => c.target === e && (c.dmg?.tags || []).includes('execute'))), `T${tier}: above the threshold: no execution`); + // an enemy that falls under the threshold while on his range is executed then (the aura checks every tick) + high.hp = thr * 0.9; + h.step(); + assert.ok(!high.alive, `T${tier}: executed once under the threshold`); + // a survivor (不死) is never tried again + guard = h.spawn('enemy_dummy', { pos: [10, 6] }); + guard.hp = thr * 0.5; + h.step(); + const tries = () => h.hooksOf('damaged').filter((c) => c.target === guard && (c.dmg?.tags || []).includes('execute')).length; + assert.equal(tries(), 1, `T${tier}: tried once`); + assert.ok(guard.alive && guard.hp <= 1 + 1e-9, `T${tier}: held at 1 HP`); + h.run(3); + assert.equal(tries(), 1, `T${tier}: never again`); + done(h); + } +}); + +test('S2 提喻 (MANUAL, data DEFAULT): 20 s, RES +40 / +50; locks one target with a hit every 0.5 s whatever the attack speed, 35 %→105 % / 50 %→150 % of ATK and its speed down to 40 % over 10 hits; a stun breaks the lock', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const b = sk.bb; + const { h, u } = field({ tier, elite, skill: 1, seed: 7 }); + assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, b.magic_resistance, b['attack@cooldown'], b['attack@atk_scale_base'], b['attack@atk_scale_delta'], b['attack@move_speed'], b['attack@max_stack_cnt']], + ['DEFAULT', 'duration', 20, elite ? 50 : 40, 0.5, elite ? 0.5 : 0.35, elite ? 0.1 : 0.07, -0.06, 10], `T${tier}`); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 300 } }); // the lock ignores the attack speed + const res0 = u.s.res; + cast(u); + approx(u.s.res, res0 + b.magic_resistance, `T${tier}: RES`); + const n0 = atkHits(h, u).length; + h.run(6.02); + const hits = atkHits(h, u).slice(n0); + assert.ok(hits.length >= 12 && hits.length <= 13, `T${tier}: ${hits.length} hits in 6 s (one per 0.5 s)`); + for (let i = 1; i < hits.length; i++) approx(hits[i].t - hits[i - 1].t, 0.5, `T${tier}: cadence`, 0.07); + hits.forEach((c, i) => { + assert.equal(c.target, e, `T${tier}: the locked target`); + // + 剜魂具辞's 150 (the first hit marks it already); the target's RES 0 + approx(c.amount, u.s.atk * (b['attack@atk_scale_base'] + b['attack@atk_scale_delta'] * Math.min(10, i)) + 150, `T${tier}: hit ${i}`, 1e-4); + }); + assert.ok(Math.abs(hits[10].amount - (u.s.atk * b['attack@atk_scale_base'] * 3 + 150)) < 1e-3 * hits[10].amount, `T${tier}: 3× after 5 s`); + approx(e.findBuff(`logos:lockSlow:${u.id}`).mods.moveMul, 0.4, `T${tier}: 40 % speed`); + // a stun breaks it: the slow goes, the next hits start over + h.b.applyStatus(u, 'stun', { duration: 1, source: e }); + h.step(); + assert.equal(e.findBuff(`logos:lockSlow:${u.id}`), null, `T${tier}: slow gone with the lock`); + const n1 = atkHits(h, u).length; + h.run(1.6); + const after = atkHits(h, u).slice(n1); + approx(after[0].amount, u.s.atk * b['attack@atk_scale_base'] + 150, `T${tier}: reset`, 1e-4); + // beam: the hit lands as the attack is made (no bolt in flight) + const atks = h.hooksOf('attack').filter((c) => c.attacker === u); + assert.ok(Math.abs(atks[atks.length - 1].t - after[after.length - 1].t) < 1e-9, `T${tier}: instant`); + h.runUntil(() => !u.skill.active, 25); + approx(u.s.res, res0, `T${tier}: RES back`); + done(h); + } +}); + +test('S2 提喻 keeps its lock while another enemy comes first in the order, and locks anew when its target leaves the field', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 1 }); + const a = h.spawn('enemy_dummy', { pos: [10, 8] }); + cast(u); + h.run(1.1); + assert.ok(atkHits(h, u).every((c) => c.target === a)); + h.spawn('enemy_dummy', { pos: [10, 6], routeIndex: 0 }); // nearer the goal: first in the order + const n0 = atkHits(h, u).length; + h.run(1.6); + assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === a), 'still the locked one'); + h.b.kill(a, null); + const n1 = atkHits(h, u).length; + h.run(1.1); + const next = atkHits(h, u).slice(n1); + assert.ok(next.length >= 2 && next.every((c) => c.target !== a)); + approx(next[0].amount, u.s.atk * 0.5 + 150, 'a new lock starts at the base scale', 1e-4); + done(h); +}); + +test('S3 延异视阈 (MANUAL, data ACTIVE_RANGE on its 3-4): cast with an enemy on the 3-4 only, 30 s, ATK +160 % / +220 %, 3 targets at once', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const { h, u } = field({ tier, elite, skill: 2 }); + assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.projectile_move_scale], ['ACTIVE_RANGE', '3-4', 30, elite ? 2.2 : 1.6, 3, 0.05], `T${tier}`); + assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is the 3-4`); + u.skill.gainSp(999); + h.run(1); + assert.equal(u.skill.activations, 0, `T${tier}: no enemy`); + const far = h.spawn('enemy_dummy', { pos: [12, 6] }); // dRow +2: the 3-4 only + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for an enemy outside his own range`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-4 while on`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + const more = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_fly', { pos: [9, 7] }), h.spawn('enemy_dummy', { pos: [11, 8] })]; + const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length; + h.run(5); + const atks = h.hooksOf('attack').filter((c) => c.attacker === u).slice(a0); + assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 3), `T${tier}: 3 targets per attack`); + assert.ok(atks.every((c) => c.targets.every((e) => [far, ...more].includes(e)))); + h.runUntil(() => !u.skill.active, 40); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`); + done(h); + } +}); + +test('S3 延异视阈: enemy shots on his range fly at 5 % of their speed (back once they leave it) and are cleared when the skill ends — not when he is disarmed then', () => { + for (const disarm of [false, true]) { + const { h, u } = field({ tier: 6, elite: true, skill: 2 }); + h.spawn('enemy_gun', { pos: [10, 7] }); + h.run(1.5); + cast(u); + h.run(0.2); + h.run(3); + const shots = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy'); + assert.ok(shots.length >= 2, `${shots.length} enemy shots held`); + for (const p of shots) approx(p.speed, 10 * 0.05, 'slowed to 0.05×'); + if (disarm) h.b.applyStatus(u, 'disarm', { duration: 40, source: null }); + h.runUntil(() => u.skill.timeLeft < 0.5, 40); + for (const g of h.enemies()) h.b.applyStatus(g, 'stun', { duration: 99, source: null }); // no new shot + const held = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy').length; + assert.ok(held >= 1, `${held} shots in the air as it ends`); + const taken = u.stats.taken; + h.runUntil(() => !u.skill.active, 2); + h.step(); + const left = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy'); + if (!disarm) { + assert.equal(left.length, 0, 'cleared'); + h.run(5); + assert.equal(u.stats.taken, taken, 'none of the held shots hit him'); + } else { + assert.ok(left.length > 0 && left.every((p) => Math.abs(p.speed - 10) < 1e-9), 'disarmed: kept, at full speed again'); + } + done(h); + } +}); + +test('T1 语汇演化: 40 % per attacked target for an extra 60 % ATK arts hit on a random enemy of his range + 停顿 0.8 s; CCR-Δ stage 3: 60 % and 60 % ATK 元素伤害 on a target in its 凋亡 burst; CCR-Y stage 3: two executions at 65 %', () => { + for (const f of [[5, false, null], [5, true, CCRD], [6, true, CCRD], [6, true, CCRY]]) { + const [tier, elite, mod] = f; + const t = talentOf(tier, elite, mod, 0); + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 }); + h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 500 } }); + const es = [h.spawn('enemy_dummy', { pos: [10, 7] }), h.spawn('enemy_fly', { pos: [11, 6] })]; + h.run(50); + const attacks = h.hooksOf('attack').filter((c) => c.attacker === u).reduce((n, c) => n + c.targets.length, 0); + const extra = tagged(h, u, 'logosLexicon').filter((c) => c.type === 'arts'); + const emit = t.emit_count ?? 1; + const ratio = extra.length / (attacks * emit); + assert.ok(attacks > 150 && Math.abs(ratio - t.prob) < 0.08, `${label(f)}: ${extra.length} / ${attacks}×${emit} ≈ ${t.prob}`); + for (const c of extra.slice(0, 6)) { + assert.ok(es.includes(c.target), `${label(f)}: an enemy of his range (air too)`); + const marked = h.hooksOf('damaged').some((d) => d.source === u && d.target === c.target && d.dmg?.isAttack && d.t <= c.t && c.t - d.t <= 5); + approx(c.amount, u.s.atk * t.atk_scale + (marked ? 150 : 0), `${label(f)}: ${t.atk_scale * 100} % ATK (+ 剜魂具辞 on a marked one)`, 1e-4); + } + const slug = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish'); + assert.ok(slug.length >= extra.length * 0.95 && slug.every((c) => Math.abs(c.duration - t.sluggish) < 1e-9), `${label(f)}: 停顿 ${t.sluggish} s`); + const elem = tagged(h, u, 'logosLexicon').filter((c) => c.type === 'elemental'); + if (t.element_atk_scale) { + assert.ok(elem.length > 0, `${label(f)}: 元素伤害 during the 凋亡 bursts (his own CCR-Δ fills them)`); + for (const c of elem) { + approx(c.amount, u.s.atk * t.element_atk_scale, `${label(f)}: ${t.element_atk_scale * 100} % ATK 元素伤害`, 1e-4); + assert.ok(h.hooksOf('elementBurst').some((b) => b.target === c.target && b.element === 'apoptosis' && b.t <= c.t && c.t - b.t < 15), 'in its 凋亡 burst'); + } + } else assert.equal(elem.length, 0, `${label(f)}: no 元素伤害`); + done(h); + } + // the module texts + assert.match(modOf(6, CCRD).talentChanges.find((t) => t.talentIndex === 0).desc, /60%几率.*凋亡损伤爆发期间则同时造成相当于攻击力60%的元素伤害/); + assert.match(modOf(6, CCRY).talentChanges.find((t) => t.talentIndex === 0).desc, /两个随机目标造成相当于攻击力65%/); + assert.equal(modOf(5, CCRD).talentChanges.find((t) => t.talentIndex === 0), undefined, 'stage 1: the talent unchanged'); +}); + +test('T1 语汇演化 also follows 殁亡\'s follow-up hit (备注 "可触发第一天赋") and works while he is disarmed', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 0, seed: 3 }); + cast(u); + h.b.applyStatus(u, 'disarm', { duration: 999, source: null }); + const thr = 1.2 * u.s.atk; + let rolls = 0; + for (let i = 0; i < 40; i++) { + h.spawn('enemy_dummy', { pos: [9, 7] }); + const low = h.spawn('enemy_dummy', { pos: [10, 7] }); + low.hp = thr * 0.5; + h.step(); + rolls++; + for (const e of h.enemies()) h.b.kill(e, null); + h.step(); + } + assert.equal(atkHits(h, u).length, 0, 'disarmed: no attack'); + assert.equal(tagged(h, u, 'logosPerish').length, rolls); + const extra = tagged(h, u, 'logosLexicon'); + assert.ok(extra.length > rolls * 0.2 && extra.length < rolls * 0.6, `${extra.length} / ${rolls} ≈ 40 %`); + done(h); +}); + +test('T2 剜魂具辞: each of his attack hits gives the target RES −10 and +150 on every arts damage it takes for 5 s — the hit itself included, other casters\' arts too; one instance per enemy', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, CCRY]]) { + const t = talentOf(tier, elite, mod, 1); + assert.deepEqual(t, { duration: 5, magic_resistance: -10, atk_addition: 150 }); + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 9 }); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + e.base.res = 30; e.markDirty(); + const first = () => atkHits(h, u)[0]; + assert.ok(h.runUntil(() => !!first(), 4)); + const c = first(); + approx(c.amount, (u.s.atk + 150) * (1 - 0.2), `T${tier}: (ATK + 150) × (1 − (30 − 10) %) on the very hit`, 1e-4); + const mark = e.findBuff('logos:soulRend'); + assert.ok(mark && Math.abs(mark.mods.resFlat + 10) < 1e-9 && mark.data.value === 150); + approx(e.s.res, 20, `T${tier}: RES −10`); + // another source's arts damage gets the 150 too; physical does not + approx(h.b.dealDamage(null, e, { amount: 1000, type: 'arts', canDodge: false }), (1000 + 150) * 0.8, 'arts +150'); + approx(h.b.dealDamage(null, e, { amount: 1000, type: 'phys', canDodge: false }), 1000, 'phys unchanged'); + // it lapses 5 s after his last hit + h.b.applyStatus(u, 'stun', { duration: 20, source: null }); + h.run(5.2); + assert.equal(e.findBuff('logos:soulRend'), null, `T${tier}: gone after 5 s`); + approx(e.s.res, 30, `T${tier}: RES back`); + done(h); + } +}); + +test('CCR-Δ “来自河谷的笔盒” (stages 1 and 3): 8 % of every arts damage he deals as 凋亡 损伤; none without it', () => { + for (const f of [[5, false, null], [5, true, null], [5, true, CCRD], [6, true, CCRD], [6, true, CCRY]]) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 4 }); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.run(4); + const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'arts'); + const fills = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'element'); + assert.ok(arts.length > 0); + if (elite && mod === CCRD) { + assert.equal(u.def.raw.talents.find((t) => t.index === -1).bb.ep_damage_ratio, 0.08); + assert.equal(fills.length, arts.length, `${label(f)}: one fill per arts hit`); + fills.forEach((c, i) => { + assert.equal(c.dmg.element, 'apoptosis'); + approx(c.amount, arts[i].amount * 0.08, `${label(f)}: 8 %`); + }); + } else assert.equal(fills.length, 0, `${label(f)}: no 凋亡`); + done(h); + } +}); + +test('CCR-Y “《语义范式百科》” (stages 1 and 3): +1 SP per normal-attack hit on an elite or leader enemy (none on a normal one, none while a skill runs); none without it', () => { + for (const f of [[5, true, CCRY], [6, true, CCRY], [6, true, CCRD], [6, false, null]]) { + const [tier, elite, mod] = f; + const y = elite && mod === CCRY; + if (y) assert.deepEqual(modOf(tier, CCRY).traitOverride.bb, { sp: 1 }); + for (const key of ['enemy_dummy', 'enemy_elite', 'enemy_boss']) { + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 2 }); + const e = h.spawn(key, { pos: [10, 7] }); + h.b.applyStatus(u, 'disarm', { duration: 3, source: null }); + h.run(3.1); + const sp0 = u.skill.sp, t0 = h.b.time; + h.run(5); + const hits = [...atkHits(h, u), ...tagged(h, u, 'logosLexicon')].filter((c) => c.target === e && c.t >= t0 - 1e-9).length; + const gain = u.skill.sp - sp0 - 5; // 5 s of natural SP + const want = y && key !== 'enemy_dummy' ? hits : 0; + approx(gain, want, `${label(f)} ${key}: ${hits} hits`, 1e-6); + done(h); + } + } +}); diff --git a/test/content/op_mantra.test.js b/test/content/op_mantra.test.js new file mode 100644 index 00000000..3a5e2a69 --- /dev/null +++ b/test/content/op_mantra.test.js @@ -0,0 +1,371 @@ +// test/content/op_mantra.test.js — the 自选 operator kit of 真言 (char_4204_mantra, 6★ 本源术师; kit +// server/sim/content/kits/ops/op-mantra.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in +// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or PRI-X +// “中枢神经探测模块” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that +// slot status); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_mantra.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 MANTRA = 'char_4204_mantra'; +const FORMS = BACKUPS.units[MANTRA].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const PRIX = 'uniequip_002_mantra'; +const S1 = 'skchr_mantra_1', S2 = 'skchr_mantra_2', S3 = 'skchr_mantra_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const talentOf = (tier, elite, mod, i) => { + const m = elite ? modOf(tier, mod) : null; + return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb; +}; +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_shooter: dummy('enemy_shooter', { atk: 1, bat: 1, range: 4 }), + enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e9, speed: 0.3, mass: 0 }), + enemy_immune: dummy('enemy_immune', { otherImmunities: ['palsy'] }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PRIX].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, setup, stage } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup, ...(stage ? { flat: stage } : null), + flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'elementBurst'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: MANTRA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +const tagged = (h, u, tag, src = u) => h.hooksOf('damaged').filter((c) => (c.source === src || c.credit === src) && (c.dmg?.tags || []).includes(tag)); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +function cast(u) { u.skill.gainSp(999); assert.ok(u.skill.activate('test')); } +const palsy = (e) => e.findBuff('palsy')?.stacks ?? 0; + +test('真言 in every 自选 form: her operator kit (all three skills authored), stats + PRI-X attributes, 3-1 range, ranged arts that hits air units, targetable by ground enemies, 萨尔贡 bond, no 特质', () => { + assert.equal(OPERATOR_KITS[MANTRA], KITS[MANTRA]); + 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], [MANTRA, 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], `${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.def.bonds, u.def.raw.garrisonIds], [['sargonShip'], []], `${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); + } + } + 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], [1071, 583, 1308, 657]); + assert.deepEqual([modOf(5, PRIX).attr, modOf(6, PRIX).attr], [{ maxHp: 90, atk: 43 }, { maxHp: 145, atk: 87 }]); +}); + +test('a 自选 pick: 真言 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(MANTRA)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MANTRA), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: MANTRA, skillIndex: 2, uniEquipId: PRIX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: MANTRA, skillIndex: 2, uniEquipId: PRIX } } }); +}); + +test('S1 共鸣溃缩 (AUTO, attack SP 3, data DEFAULT): every 4th attack deals 230 % / 275 % ATK arts, then 15 % / 20 % of it as 神经 损伤, then — on a target in its 神经 burst — 150 % / 160 % ATK 元素伤害', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType, u.skill.spCost, sk.bb.atk_scale, sk.bb.ep_damage_ratio, sk.bb.element_atk_scale], + ['DEFAULT', 'instant', 'attack', 3, elite ? 2.75 : 2.3, elite ? 0.2 : 0.15, elite ? 1.6 : 1.5], `T${tier}`); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.run(16 * 1.6 + 0.5); + const hits = atkHits(h, u).filter((c) => c.target === e); + const skillHits = hits.filter((c) => c.dmg.isSkill); + assert.ok(hits.length >= 15, `T${tier}: ${hits.length} attacks`); + hits.forEach((c, i) => assert.equal(c.dmg.isSkill, i % 4 === 3, `T${tier}: attack ${i + 1}`)); + const fills = h.hooksOf('damaged').filter((c) => c.source === u && c.type === 'element'); + for (const c of skillHits) { + const fill = fills.find((x) => Math.abs(x.t - c.t) < 1e-9); + if (fill) approx(fill.amount, c.amount * sk.bb.ep_damage_ratio, `T${tier}: 神经 = ${sk.bb.ep_damage_ratio * 100} % of the damage`); + } + approx(skillHits[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); + assert.ok(fills.length >= 1 && fills.every((x) => x.dmg.element === 'neural')); + // in its 神经 burst: + element_atk_scale × ATK 元素伤害 after the 神经 fill (a fresh target one point short of it) + h.b.kill(e, null); + const e2 = h.spawn('enemy_dummy', { pos: [10, 7] }); + e2.elem.neural = e2.gaugeMax - 1; + const n0 = h.hooksOf('damaged').length; + h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === u && c.dmg?.isSkill && c.dmg?.isAttack), 8); + h.step(); + const after = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.type === 'elemental' && (c.dmg.tags || []).includes('mantra')); + assert.ok(h.hooksOf('elementBurst').some((c) => c.target === e2 && c.element === 'neural'), `T${tier}: her 神经 burst it`); + assert.equal(after.length, 1, `T${tier}: the burst made by its own 神经 counts`); + approx(after[0].amount, u.s.atk * sk.bb.element_atk_scale, `T${tier}: ${sk.bb.element_atk_scale * 100} % ATK 元素伤害`); + done(h); + } +}); + +test('S2 意识联协 (MANUAL, data DEFAULT): 25 s, attack interval 1.6 − 0.6 / 0.7 s; 170 % / 210 % ATK on the target, then 2 / 3 jumps at 3.2 tiles/s to the nearest new enemy within 2.0 tiles for 100 % / 110 %; 神经 riders on every hit', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const b = sk.bb; + const { h, u } = field({ tier, elite, skill: 1 }); + assert.deepEqual([u.skill.rule, sk.duration, b.base_attack_time, b['attack@atk_scale'], b['attack@chain.atk_scale'], b.chain_times, b['attack@ep_damage_ratio'], b['attack@element_atk_scale']], + ['DEFAULT', 25, elite ? -0.7 : -0.6, elite ? 2.1 : 1.7, elite ? 1.1 : 1, elite ? 3 : 2, elite ? 0.15 : 0.13, elite ? 0.2 : 0.18], `T${tier}`); + // a line of enemies 1.5 tiles apart (the 4th is 2.1 from the 3rd: out of the jump radius at tier 6) + const main = h.spawn('enemy_dummy', { pos: [10, 7] }); + const j1 = h.spawn('enemy_dummy', { pos: [10, 8.5] }); + const j2 = h.spawn('enemy_fly', { pos: [10, 10] }); + const j3 = h.spawn('enemy_dummy', { pos: [11.5, 10] }); + const out = h.spawn('enemy_dummy', { pos: [11.5, 12.1] }); + cast(u); + approx(u.s.interval, 1.6 + b.base_attack_time, `T${tier}: interval`); + const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length; + h.runUntil(() => h.hooksOf('attack').filter((c) => c.attacker === u).length > a0, 2); + const at = h.hooksOf('attack').filter((c) => c.attacker === u).slice(-1)[0]; + assert.equal(at.targets[0], main, `T${tier}: the first target`); + h.run(3); + const id = atkHits(h, u).find((c) => c.target === main && c.t >= at.t).dmg.attackId; + const chainHits = atkHits(h, u).filter((c) => c.dmg.attackId === id); + const order = chainHits.map((c) => c.target); + assert.deepEqual(order, [main, j1, j2, j3].slice(0, 1 + b.chain_times), `T${tier}: the nearest new enemy each time`); + assert.ok(!order.includes(out), `T${tier}: 2.1 tiles: out of reach`); + approx(chainHits[0].amount, u.s.atk * b['attack@atk_scale'], `T${tier}: main`); + for (const c of chainHits.slice(1)) { + approx(c.amount, u.s.atk * b['attack@chain.atk_scale'], `T${tier}: jump`); + assert.ok(c.dmg.tags.includes('chain') && c.dmg.isSkill); + } + approx(chainHits[2].t - chainHits[1].t, 1.5 / 3.2, `T${tier}: 3.2 tiles/s`, 0.1); + for (const [i, c] of chainHits.entries()) { + const fill = h.hooksOf('damaged').find((x) => x.source === u && x.type === 'element' && x.target === c.target && Math.abs(x.t - c.t) < 1e-9); + assert.ok(fill, `T${tier}: a 神经 fill with hit ${i}`); + approx(fill.amount, c.amount * b['attack@ep_damage_ratio'], `T${tier}: 神经 ${i}`); + } + h.runUntil(() => !u.skill.active, 30); + approx(u.s.interval, 1.6, `T${tier}: interval back`); + done(h); + } +}); + +test('S3 无言为真 (MANUAL, data ACTIVE_RANGE on its 3-17): cast for an enemy outside her own range, 40 s, ATK +170 % / +210 %, 2 targets, the 3-17 while on; 隐匿 enemies on it are revealed', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const { h, u } = field({ tier, elite, skill: 2 }); + assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.max_trigger_cnt, sk.bb.per_active, sk.bb.interval_projectile_trigger, sk.bb.atk_scale], + ['ACTIVE_RANGE', '3-17', 40, elite ? 2.1 : 1.7, 2, 3, 2, 1.5, elite ? 1.45 : 1.1], `T${tier}`); + u.skill.gainSp(999); + h.run(1); + assert.equal(u.skill.activations, 0); + const far = h.spawn('enemy_dummy', { pos: [12, 6] }); // dRow +2: the 3-17 only + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`); + assert.ok(!formOf(tier, elite).rangeGrid.some(([r, c]) => r === far.y - 10 && c === far.x - 5), `T${tier}: the enemy is not on her own 3-1`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-17`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + const sneak = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.b.addBuff(sneak, { key: 'test:stealth', flags: { stealth: true } }); + h.step(); + assert.ok(sneak.s.flags.reveal, `T${tier}: revealed on her range`); + const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length; + h.run(4); + const atks = h.hooksOf('attack').filter((c) => c.attacker === u).slice(a0); + assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 2), `T${tier}: 2 targets`); + h.runUntil(() => !u.skill.active, 45); + h.run(0.3); + assert.ok(!sneak.s.flags.reveal, `T${tier}: hidden again once it ends`); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`); + done(h); + } +}); + +test('S3 无言为真 麻痹付与 and the cap 2: 0.1 s after the cast every enemy on her range gets 2 麻痹; another operator on her range starting a skill grants again (3 times per cast, a grant kept while nobody is there); stacks over 2 become 溢出麻痹 — each an 元素伤害 bounce of 110 % / 145 % ATK on the holder and the nearest other enemy within 2.0 tiles, one at once and one per 1.5 s', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }, { uid: 3, chessId: 'chess_char_1_08_a', row: 9, col: 2 }]; + const { h, u } = field({ tier, elite, skill: 2, others }); + const inside = h.unit(2), outside = h.unit(3); + for (const a of [inside, outside]) { a.skill.sp = 0; a.skill.charges = 0; } + cast(u); + h.run(0.05); + // nobody on her range yet: the grant at 0.1 s is kept + h.run(0.1); + assert.equal(u.mem.mantraS3.grants, 3, `T${tier}: kept`); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + const near = h.spawn('enemy_dummy', { pos: [10, 8] }); + h.step(); + // an operator outside her range starting a skill: nothing + outside.skill.gainSp(999); outside.skill.activate('test'); + h.step(); + assert.equal(palsy(e), 0, `T${tier}: an operator off her range`); + // the one on her range: the first grant (2 stacks, at the cap) + inside.skill.gainSp(999); inside.skill.activate('test'); + h.step(); + assert.deepEqual([palsy(e), palsy(near), u.mem.mantraS3.grants], [2, 2, 2], `T${tier}: 2 麻痹 each`); + assert.equal(tagged(h, u, 'mantraOverflow').length, 0, `T${tier}: no overflow yet`); + // the second: 2 over the cap each ⇒ 溢出麻痹 2 each, one bounce at once each + const n0 = h.hooksOf('damaged').length; + inside.skill.gainSp(999); inside.skill.activate('test'); + h.step(); + assert.deepEqual([palsy(e), palsy(near)], [2, 2], `T${tier}: capped at 2`); + let bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow')); + assert.deepEqual(bounces.map((c) => c.target.id).sort(), [e.id, near.id].sort(), `T${tier}: each holder hit at once`); + for (const c of bounces) { + approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); + assert.deepEqual([c.type, c.dmg.element], ['elemental', 'neural']); + } + h.run(0.5); // the jumps land (1 tile at 3.2 tiles/s) + bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow')); + assert.equal(bounces.length, 4, `T${tier}: holder + the nearest other, twice`); + h.run(1.1); // 1.5 s after: the second 溢出麻痹 of each + bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow')); + assert.ok(bounces.length >= 6, `T${tier}: the next one 1.5 s later (${bounces.length})`); + // the third grant, then no more + inside.skill.gainSp(999); inside.skill.activate('test'); + h.step(); + assert.equal(u.mem.mantraS3.grants, 0, `T${tier}: 3 grants used`); + const g = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'palsy').length; + inside.skill.gainSp(999); inside.skill.activate('test'); + h.step(); + assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'palsy').length, g, `T${tier}: no 4th grant`); + done(h); + } +}); + +test('S3 无言为真: an enemy holding 3 麻痹 entering her range keeps 2 and bounces the third; 溢出麻痹 left when it leaves the range or the skill ends all bounce at once; a 麻痹免疫 enemy counts the stacks she gives it', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 2 }); + cast(u); + h.run(0.2); // the grant of 0.1 s finds nobody (kept, never fired again here) + const e = h.spawn('enemy_dummy', { pos: [10, 12] }); // off her range + h.b.applyStatus(e, 'palsy', { value: 3 }); + h.step(); + assert.equal(palsy(e), 3, 'off her range: cap 3'); + e.x = 7; h.step(); + assert.equal(palsy(e), 2, 'on her range: 2'); + assert.equal(tagged(h, u, 'mantraOverflow').filter((c) => c.target === e).length, 1, 'the third bounced'); + // a neural burst's 3 麻痹 on a holder at the cap: 3 溢出麻痹 — one at once, two left; it leaves ⇒ both at once + const n0 = tagged(h, u, 'mantraOverflow').length; + h.b.applyStatus(e, 'palsy', { value: 3 }); + assert.equal(tagged(h, u, 'mantraOverflow').length - n0, 1, 'one at once'); + e.x = 13; h.step(); + assert.equal(tagged(h, u, 'mantraOverflow').length - n0, 3, 'the two left at once when it leaves'); + assert.equal(palsy(e), 2, 'its stacks stay (cap 3 again)'); + // the skill ends with 溢出麻痹 held: all of it bounces + const f = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.step(); + h.b.applyStatus(f, 'palsy', { value: 3 }); + h.b.applyStatus(f, 'palsy', { value: 3 }); + const n1 = tagged(h, u, 'mantraOverflow').filter((c) => c.target === f).length; + u.skill.end('test'); + assert.equal(tagged(h, u, 'mantraOverflow').filter((c) => c.target === f).length - n1, 2, 'the rest at the end'); + // 麻痹免疫: her grants count (ineffective stacks), so the second grant overflows + const { h: h2, u: u2 } = field({ tier: 6, elite: true, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }] }); + const imm = h2.spawn('enemy_immune', { pos: [10, 7] }); + cast(u2); + h2.run(0.2); + assert.equal(palsy(imm), 0, 'no 麻痹 buff (immune)'); + assert.equal(imm.mem.mantraPalsy, 2, 'counted'); + const ally = h2.unit(2); + ally.skill.gainSp(999); ally.skill.activate('test'); + h2.step(); + assert.equal(tagged(h2, u2, 'mantraOverflow').filter((c) => c.target === imm).length, 1, 'its 溢出麻痹 bounces'); + done(h); + done(h2); +}); + +test('T1 噤声限域: an enemy whose attack a 麻痹 stack interrupts takes 135 % (PRI-X stage 3: 160 %) of her ATK as 元素伤害 (tagged 持续伤害); 10 % / 15 % of the interrupts keep the stack; not for an enemy she cannot select (隐匿)', () => { + for (const f of [[5, false, null], [6, true, null], [5, true, PRIX], [6, true, PRIX]]) { + const [tier, elite, mod] = f; + const t = talentOf(tier, elite, mod, 0); + assert.deepEqual(t, elite && mod === PRIX && tier === 6 ? { atk_scale: 1.6, prob: 0.15 } : { atk_scale: 1.35, prob: 0.1 }, label(f)); + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 13 }); + const e = h.spawn('enemy_shooter', { pos: [10, 8] }); + h.b.applyStatus(u, 'disarm', { duration: 999, source: null }); // her attacks would kill nothing, but keep it clean + h.run(0.6); // the aura's 0.5 s check has marked it + let interrupts = 0, kept = 0; + h.b.on('palsyTrigger', (c) => { if (c.enemy === e) { interrupts++; } }); + for (let i = 0; i < 400; i++) { + h.b.applyStatus(e, 'palsy', { value: 1 }); + const before = interrupts; + h.runUntil(() => interrupts > before, 3); + h.step(); + if (palsy(e) > 0) { kept++; h.b.removeBuff(e, 'palsy'); } + } + const hush = tagged(h, u, 'mantraHush'); + assert.equal(hush.length, interrupts, `${label(f)}: one hit per interrupt`); + for (const c of hush.slice(0, 5)) { + approx(c.amount, u.s.atk * t.atk_scale, `${label(f)}: ${t.atk_scale * 100} % ATK`); + assert.deepEqual([c.type, c.dmg.element, c.dmg.tags.includes('dot')], ['elemental', 'neural', true]); + } + assert.ok(Math.abs(kept / interrupts - t.prob) < 0.05, `${label(f)}: kept ${kept} / ${interrupts} ≈ ${t.prob}`); + done(h); + } + // 隐匿: no mark, no hit; during S3 the check ignores 隐匿 + const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 1 }); + const e = h.spawn('enemy_shooter', { pos: [10, 2] }); // behind her: it shoots her, she cannot see it (and S3 does not reveal it) + h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } }); + let interrupts = 0; + h.b.on('palsyTrigger', (c) => { if (c.enemy === e) interrupts++; }); + h.run(0.6); + h.b.applyStatus(e, 'palsy', { value: 2 }); + h.run(2.2); + assert.equal(interrupts, 2, 'its attacks were interrupted'); + assert.equal(tagged(h, u, 'mantraHush').length, 0, 'a 隐匿 enemy: no effect'); + cast(u); + h.run(0.6); + assert.ok(!e.s.flags.reveal, 'not revealed (off her range)'); + h.b.applyStatus(e, 'palsy', { value: 2 }); + h.run(2.2); + assert.equal(tagged(h, u, 'mantraHush').length, 2, 'S3: the check ignores 隐匿'); + done(h); +}); + +test('T2 全局洞悉: the 侵入点 nearest to her (Manhattan) — every enemy that comes out of it gets 1 麻痹 0.1 s later (隐匿 too); the other gate\'s do not', () => { + for (const [row, gate, other] of [[10, 0, 1], [11, 1, 0]]) { // flat stage gates: route 0 starts at (9,10), route 1 at (12,10) + const { h, u } = field({ tier: 5, skill: 0, row, seed: 2 }); + assert.deepEqual(u.mem.mantraGate, gate === 0 ? [9, 10] : [12, 10], `row ${row}: the nearest gate`); + const a = h.spawn('enemy_walker', { routeIndex: gate }); + const b = h.spawn('enemy_walker', { routeIndex: other }); + h.b.addBuff(a, { key: 'test:stealth', flags: { stealth: true } }); + h.step(); + assert.equal(palsy(a), 0, 'not at once'); + h.run(0.1); + assert.deepEqual([palsy(a), palsy(b)], [1, 0], `row ${row}`); + done(h); + } + // a tie (two gates at the same distance) is drawn at random + const picks = new Set(); + for (let seed = 1; seed <= 12; seed++) { + const { h, u } = field({ tier: 5, skill: 0, row: 10, col: 10, seed, stage: { rows: { 11: '##hrrrrrrrSrrrrrrrf##' } } }); + picks.add(String(u.mem.mantraGate)); + done(h); + } + assert.deepEqual([...picks].sort(), ['11,10', '9,10'], 'both gates one tile away get drawn'); +}); + +test('PRI-X “中枢神经探测模块” (stages 1 and 3): damage to an enemy in an element burst ×1.1; none without it', () => { + for (const f of [[5, true, PRIX], [6, true, PRIX], [6, true, null], [5, false, null]]) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 8 }); + const plain = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.run(1.7); + const n0 = atkHits(h, u).length; + h.b.addBuff(plain, { key: 'burnBurst', duration: 99, flags: { burstLock: true } }); + h.run(1.7); + const a = atkHits(h, u)[0].amount, b = atkHits(h, u).slice(n0)[0].amount; + approx(b / a, elite && mod === PRIX ? 1.1 : 1, label(f)); + if (elite && mod === PRIX) assert.equal(modOf(tier, PRIX).traitOverride.bb.damage_scale, 1.1); + done(h); + } +}); diff --git a/test/content/op_veen.test.js b/test/content/op_veen.test.js new file mode 100644 index 00000000..f8e2a016 --- /dev/null +++ b/test/content/op_veen.test.js @@ -0,0 +1,269 @@ +// test/content/op_veen.test.js — the 自选 operator kit of 维伊 (char_4226_veen, 6★ 秘术师; kit +// server/sim/content/kits/ops/op-veen.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in +// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or MSC-Y +// “军械库” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that slot +// status); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_veen.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 VEEN = 'char_4226_veen'; +const FORMS = BACKUPS.units[VEEN].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const MSCY = 'uniequip_002_veen'; +const S1 = 'skchr_veen_1', S2 = 'skchr_veen_2', S3 = 'skchr_veen_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const talentOf = (tier, elite, mod, i) => { + const m = elite ? modOf(tier, mod) : null; + return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb; +}; +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, MSCY].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'attack', 'ammoUsed'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: VEEN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +const warTicks = (h, u, e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && (c.dmg?.tags || []).includes('veenWar')); +const stored = (u) => ({ s: u.trait.veen?.s ?? 0, t: u.trait.veen?.t ?? 0 }); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +/** Run until she holds `t` 转置能量 and `s` stored energies (no enemy on the field). */ +function storeUp(h, u, t, s) { + assert.ok(h.runUntil(() => stored(u).t === t && stored(u).s === s, 60), `stores up to ${t}/${s}`); +} + +test('维伊 in every 自选 form: her operator kit (all three skills authored), stats + MSC-Y attributes, 3-14 range, ranged arts every 3 s that hits air units, targetable by ground enemies, 空 bond, no 特质', () => { + assert.equal(OPERATOR_KITS[VEEN], KITS[VEEN]); + 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], [VEEN, 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], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, u.base.cost], [1, 'ranged', 'arts', true, 3, 27], `${label(f)}: 秘术师`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-14`); + 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); + } + } + assert.deepEqual([FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk, FORMS['2/60/7/1'].trait.bb], [1142, 1320, { times: 9, merge_cnt: 3 }]); + assert.deepEqual([modOf(5, MSCY).attr, modOf(6, MSCY).attr], [{ maxHp: 80, atk: 85 }, { maxHp: 190, atk: 120 }]); +}); + +test('a 自选 pick: 维伊 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(VEEN)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(VEEN), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } } }); +}); + +test('trait + T1: with no target her attack check stores one energy (an attack action: the interval restarts — PRTS 分支特性信息), 3 merge into a 转置能量, at most 3 of those (then she stops); ATK +10 % while she holds one, ASPD +15 while she holds none; MSC-Y: ASPD +30 while she holds any energy', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const t0 = talentOf(tier, elite, mod, 0); + assert.deepEqual(t0, { atk: 0.1, attack_speed: 15 }, label(f)); + const msc = elite && mod === MSCY ? 30 : 0; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + u.skill.sp = 0; + assert.deepEqual(stored(u), { s: 1, t: 0 }, `${label(f)}: her first attack check (at deployment) stores at once`); + approx(u.s.aspd, 100 + 15 + msc, `${label(f)}: no 转置能量: ASPD +15 (MSC-Y +30 while holding)`); + approx(u.s.atk, u.base.atk, `${label(f)}: no ATK bonus`); + const t1 = h.b.time; + h.runUntil(() => stored(u).s === 2, 10); + approx(h.b.time - t1, 3 * 100 / 115, `${label(f)}: the next one an attack interval later (set at the store, before MSC-Y)`, 0.03); + const t2 = h.b.time; + h.runUntil(() => stored(u).t === 1, 10); + approx(h.b.time - t2, 3 * 100 / (115 + msc), `${label(f)}: then at the interval of ASPD ${115 + msc}`, 0.03); + storeUp(h, u, 1, 0); + approx(u.s.aspd, 100 + msc, `${label(f)}: a 转置能量: the ASPD +15 goes`); + approx(u.s.atk, u.base.atk * 1.1, `${label(f)}: ATK +10 %`); + storeUp(h, u, 3, 0); + h.run(30); + assert.deepEqual(stored(u), { s: 0, t: 3 }, `${label(f)}: no more storing at 3 转置能量`); + done(h); + } +}); + +test('the volley: 转置能量 first (300 % ATK each), the main attack (100 %), then the stored energies (100 %) — one target, all landing together, each with the ATK it left with (+10 % while a 转置能量 was held)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const { h, u } = field({ tier, elite, skill: 1 }); + u.skill.sp = 0; + storeUp(h, u, 1, 2); + const atk = u.s.atk; + approx(atk, u.base.atk * 1.1, `T${tier}: holding a 转置能量`); + const e = h.spawn('enemy_fly', { pos: [10, 7] }); + const a0 = atkHits(h, u).length; + h.runUntil(() => atkHits(h, u).length > a0, 4); + h.step(); + const hits = atkHits(h, u).slice(a0); + assert.deepEqual(hits.map((c) => Math.round(c.amount / atk * 100) / 100), [3, 1, 1, 1], `T${tier}: 300 % + 100 % + 2 × 100 % at the cached ATK`); + assert.ok(hits.every((c) => c.target === e && c.t === hits[0].t && c.dmg.attackId === hits[0].dmg.attackId && c.type === 'arts'), `T${tier}: one attack, one target (a flyer), together`); + assert.deepEqual(stored(u), { s: 0, t: 0 }, `T${tier}: spent`); + h.run(0.2); + approx(u.s.atk, u.base.atk, `T${tier}: the ATK bonus gone after the volley`); + done(h); + } +}); + +test('T2 战争技艺: each hit adds a stack (a 转置能量 3) of 90 arts / s for 5 s, at most 3 (MSC-Y stage 3: 120 / s, at most 4); she never targets a marked enemy — with only marked ones in range she stores energy', () => { + for (const f of [[5, false, null], [5, true, MSCY], [6, true, MSCY]]) { + const [tier, elite, mod] = f; + const t1 = talentOf(tier, elite, mod, 1); + const s3m = elite && mod === MSCY && tier === 6; + assert.deepEqual(t1, s3m ? { 'attack@duration': 5, 'attack@value': 120, 'attack@max_stack_cnt': 4 } : { 'attack@duration': 5, 'attack@value': 90, 'attack@max_stack_cnt': 3 }, label(f)); + const { h, u } = field({ tier, elite, mod, skill: 0 }); + u.skill.sp = 0; + h.run(3.1); + u.trait.veen = null; // (start the check with nothing stored) + const a = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.runUntil(() => atkHits(h, u).length > 0, 4); + h.step(); + const war = a.findBuff(`veen:war:${u.id}`); + assert.ok(war && war.stacks === 1, `${label(f)}: 1 stack`); + h.run(1.02); + const ticks = warTicks(h, u, a); + assert.equal(ticks.length, 1, `${label(f)}: a tick per second`); + approx(ticks[0].amount, t1['attack@value'], `${label(f)}: ${t1['attack@value']} arts`); + // marked: not targeted — she stores instead while it is her only enemy in range + const n0 = atkHits(h, u).length; + h.run(2.9); + assert.equal(atkHits(h, u).length, n0, `${label(f)}: no attack on a marked enemy`); + assert.ok(stored(u).s >= 1, `${label(f)}: her next attack check stored an energy instead`); + // another enemy: targeted at once; the mark lapses 5 s after the last stack + const b = h.spawn('enemy_dummy', { pos: [9, 7] }); + h.runUntil(() => atkHits(h, u).length > n0, 4); + assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === b), `${label(f)}: the unmarked one`); + assert.ok(h.runUntil(() => !a.findBuff(`veen:war:${u.id}`), 6), `${label(f)}: 5 s`); + assert.equal(warTicks(h, u, a).length, 5, `${label(f)}: 5 ticks`); + done(h); + } + // a 转置能量 gives 3 stacks; the cap + for (const [tier, mod, cap] of [[6, null, 3], [6, MSCY, 4]]) { + const { h, u } = field({ tier, elite: true, mod, skill: 0 }); + u.skill.sp = 0; + storeUp(h, u, 2, 0); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.runUntil(() => atkHits(h, u).length >= 3, 4); + h.step(); + assert.equal(e.findBuff(`veen:war:${u.id}`).stacks, cap, `T6 ${mod ?? 'none'}: 2 × 3 + 1 stacks capped at ${cap}`); + h.run(1.02); + approx(warTicks(h, u, e)[0].amount, talentOf(6, true, mod, 1)['attack@value'] * cap, `${cap} stacks`); + done(h); + } +}); + +test('S1 “自呼号生发” (AUTO): ASPD +60 / +75 for 18 s, cast as soon as its SP is full (31 / 29 from 7 / 9) — no enemy needed', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.attack_speed], ['SP_FULL', elite ? 29 : 31, elite ? 9 : 7, 18, elite ? 75 : 60], `T${tier}`); + h.run(sk.spCost - sk.initSp - 0.5); + assert.equal(u.skill.activations, 0); + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: at full SP, no enemy`); + const base = u.s.aspd - sk.bb.attack_speed; + assert.ok(base === 115 || base === 100, `T${tier}: +${sk.bb.attack_speed} on top of ${base}`); + approx(u.skill.timeLeft, 18, `T${tier}: 18 s`, 0.01); + done(h); + } +}); + +test('S2 “以鲜血洗去” (MANUAL, data DEFAULT): 28 s, attack interval 3.0 − 0.5 s; each released shot +9 % / +11 % ATK and +6 / +7 ASPD (a 转置能量 3 stacks), at most 8 / 9 — later shots of a volley carry the stacks of the earlier ones', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const b = sk.bb; + const per = b['attack@veen_s_2_buff[stack].atk'], perAs = b['attack@veen_s_2_buff[stack].attack_speed'], max = b['attack@veen_s_2_buff[stack].max_stack_cnt']; + assert.deepEqual([sk.duration, b.base_attack_time, per, perAs, max], [28, -0.5, elite ? 0.11 : 0.09, elite ? 7 : 6, elite ? 9 : 8], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 1 }); + assert.equal(u.skill.rule, 'DEFAULT'); + u.skill.sp = 0; + storeUp(h, u, 1, 1); + const base = u.base.atk; + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`); + h.step(2); + approx(u.s.interval, 2.5 * 100 / u.s.aspd, `T${tier}: base interval 2.5 s`); + const hits = atkHits(h, u); + h.runUntil(() => atkHits(h, u).length >= 3, 4); + const v = atkHits(h, u).slice(0, 3); + // 转置能量 (0 stacks, +10 %), main (3 stacks), stored (4 stacks) — every one +10 % (a 转置能量 was held) + approx(v[0].amount, base * (1 + 0.1) * 3, `T${tier}: 转置能量 before any stack`); + approx(v[1].amount, base * (1 + 0.1 + 3 * per), `T${tier}: the main shot after 3`); + approx(v[2].amount, base * (1 + 0.1 + 4 * per), `T${tier}: the stored one after 4`); + assert.ok(hits.length <= 3); + approx(u.s.aspd, 100 + 15 + 5 * perAs, `T${tier}: 5 stacks of ASPD (and T1's +15 again)`); + h.run(25); + assert.equal(u.findBuff('veen:s2stack').mods.atkPct, per * max, `T${tier}: at most ${max}`); + h.runUntil(() => !u.skill.active, 10); + assert.equal(u.findBuff('veen:s2stack'), null, `T${tier}: gone with the skill`); + void e; + done(h); + } +}); + +test('S3 “用赤铁铭记” (MANUAL, data DEFAULT): 18 / 21 bullets, ASPD +110 / +140; an attack spends 1 bullet per stored energy and 3 per 转置能量 (none without), its stored shots deal nothing and its main hit bounces that many times — every 0.5 s within 1.7 tiles, a new enemy first — for 130 % / 140 % of the cached ATK, each with a 战争技艺 stack', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const b = sk.bb; + assert.deepEqual([b['attack@trigger_time'], b.attack_speed, b['attack@bounce_atk_scale'], b['attack@base_atk_scale']], [elite ? 21 : 18, elite ? 140 : 110, elite ? 1.4 : 1.3, 1], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 2 }); + assert.equal(u.skill.rule, 'DEFAULT'); + u.skill.sp = 0; + storeUp(h, u, 1, 1); + const atk = u.s.atk; + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + const near = h.spawn('enemy_fly', { pos: [10, 8.2] }); + const far = h.spawn('enemy_dummy', { pos: [10, 12] }); // out of her range and 3.8 from the target + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`); + assert.deepEqual([u.skill.kind, u.skill.ammoMax], ['ammo', b['attack@trigger_time']], `T${tier}`); + approx(u.s.aspd, 100 + b.attack_speed + 15, `T${tier}: ASPD (+ T1's 15: the volley spent the 转置能量)`, 1e-9); + h.run(2.2); + // the first attack: 1 + 3 = 4 bullets, the main hit then 4 bounces (e ↔ near: a new enemy first, then back) + assert.equal(u.skill.ammoLeft, b['attack@trigger_time'] - 4, `T${tier}: 4 bullets`); + assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 4); + const hits = atkHits(h, u); + const id = hits[0].dmg.attackId; + const of = hits.filter((c) => c.dmg.attackId === id); + assert.equal(of.length, 5, `T${tier}: main + 4 bounces, no stored-shot damage`); + approx(of[0].amount, atk * b['attack@base_atk_scale'], `T${tier}: the main hit`); + assert.deepEqual(of.map((c) => c.target.id), [e.id, near.id, e.id, near.id, e.id], `T${tier}: a new enemy first, then between them`); + for (const c of of.slice(1)) approx(c.amount, atk * b['attack@bounce_atk_scale'], `T${tier}: bounce at the cached ATK`); + for (let i = 2; i < of.length; i++) approx(of[i].t - of[i - 1].t, 0.5, `T${tier}: 0.5 s apart`, 0.08); + assert.ok(!of.some((c) => c.target === far), `T${tier}: 1.7 tiles at most`); + assert.equal(near.findBuff(`veen:war:${u.id}`).stacks, 2, `T${tier}: a 战争技艺 stack per bounce`); + // attacks without stored energy spend nothing; the skill ends when the bullets run out + const left = u.skill.ammoLeft; + h.run(1.5); + assert.ok(u.skill.ammoLeft <= left); + h.runUntil(() => !u.skill.active, 300); + assert.equal(u.skill.ammoLeft, 0, `T${tier}: ended out of bullets`); + assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u)[0].reason, 'ammo'); + done(h); + } +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index d842b158..f33901ea 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -90,130 +90,130 @@ "snaps": "d7b739d1 f5e170c4 bc463f9c 6ebd80ed 93f8e1ae ea1947a7 9541a28f e8dbac5b b0b3c196 14ae4a64 46640828 01c55ea9 0f261678 f447848b 0aad3542 db5850a3 631cc5ca 455c1dbc 68ad7134 24c71ebb 34a3ee93 37b05130 7a58a87e" }, "diy-003": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", - "fast": true, + "about": "5_diy1_a=logos/S1 5_diy2_a=logos/S2 5_diy1_a=logos/S3 5_diy2_b=logos/S1 5_diy1_b=logos/S2 5_diy2_b=logos/S3 5_diy1_b=logos/S1/002_logos 5_diy2_b=logos/S2/002_logos 5_diy1_b=logos/S3/002_logos 5_diy2_b=logos/S1/003_logos 5_diy1_b=logos/S2/003_logos 5_diy2_b=logos/S3/003_logos", + "fast": false, "kind": "normal", "stageId": "act1autochess_m03", "modeId": "mode_single_normal", "round": 12, "seed": 3865246633, "waveId": "act1autochess_h05", - "time": 196.5, - "ticks": 5895, + "time": 190.93, + "ticks": 5728, "reason": "cleared", - "killed": 8, + "killed": 48, "total": 93, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 0, + "rngDraws": 1341, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":8,"total":93,"perfect":false,"leaks":85,"leaksUncounted":0,"coins":0,"dmg":306215,"bossDmg":0,"heal":22436,"deaths":11,"layerGains":{}}}, + "players": {"p1":{"killed":48,"total":93,"perfect":false,"leaks":45,"leaksUncounted":0,"coins":0,"dmg":1069259,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,114,0,690,6384,0,13,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,98,1,7246,10191,0,56,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,116,1,16020,8139,0,28,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,74,3,34176,12589,0,37,2], - ["p1",5,"op","chess_char_5_diy1_a",2,null,871,1,8,1,22261,400,0,129,3], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,12,0,32187,200,0,108,3], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,14,0,30588,200,0,77,2], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1160,1,4,1,71148,200,0,154,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,600,18186,48,3], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,115,0,0,8077,4251,12,2], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,1,40672,2000,0,123,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,15,0,51226,800,0,132,3], + ["p1",1,"op","chess_char_5_diy1_a",0,null,109.9,1,6,5,62747,1187,0,57,1], + ["p1",2,"op","chess_char_5_diy2_a",1,null,497,1,11,3,113917,800,0,153,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,103.45,1,14,4,201036,1194,0,92,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,4,13,120976,1540,0,72,1], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",21.85,1,5,5,149641,1518,0,184,4], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,79,6,79630,3080,0,36,1], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_logos",840,1,98,2,17590,2440,0,21,1], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_logos",1064.79,1,97,4,118610,2215,0,123,2], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_logos",0,0,102,1,54161,3280,0,33,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_logos",1540,1,117,0,10028,3080,0,12,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_logos",0,0,110,2,11902,4620,0,14,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_logos",0,0,106,3,129022,3080,0,36,1], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1267_nhpbr_2":[36,8,28,17650,131613],"enemy_1270_nhstlk_2":[27,0,27,15235,63417],"enemy_9006_actoxi":[30,0,30,16896,111185]}, - "hooks": [1,116,5895,1089,1089,1215,1215,60,19,104,26,24,0,28385,254,28,93,85,1,19,0,0,0], - "events": {"atk":1089,"deploy":20,"die":19,"dmg":1215,"fx":8,"heal":60,"leak":85,"skill":50,"spawn":108,"status":504}, - "fx": {"zone":8}, - "snaps": "b1b2dda9 6ac577d6 84811e80 eb6305ea 52a34c95 c2fd4731 45525f0b dbcc3ddb ddfcbc11 0874e859 206cd671 4c83f44b f4f5331a 194da7d5 1fe13dbf 6b7331b4 4cf19629 7bae5e13 5b7078fc 65a8a1f5" + "enemies": {"enemy_1267_nhpbr_2":[36,24,12,10932,248626],"enemy_1270_nhstlk_2":[27,7,20,10537,335315],"enemy_9006_actoxi":[30,17,13,1667,485318]}, + "hooks": [1,117,5728,857,857,1769,1998,0,62,107,17,15,0,28148,378,13,93,45,1,62,0,2,0], + "events": {"atk":950,"deploy":21,"die":62,"dmg":1998,"fx":443,"leak":45,"skill":32,"spawn":108,"status":558}, + "fx": {"bulletClear":1,"burst":2,"logosExecute":17,"logosLexicon":383,"zone":40}, + "snaps": "3a2c07e3 153672a5 64dc4ff4 f81ee59e 851974b4 b8cdc7be 1ceca8aa 0be18612 fcf0db76 e05d0afe 9ecedd8c 63398fe7 1ed36bdd ea26c8b3 3396266d 9f9c6b54 72dc877c e18b35f8 c5b3af37" }, "diy-004": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "about": "6_diy1_a=logos/S1 6_diy2_a=logos/S2 6_diy1_a=logos/S3 6_diy2_b=logos/S1 6_diy1_b=logos/S2 6_diy2_b=logos/S3 6_diy1_b=logos/S1/002_logos 6_diy2_b=logos/S2/002_logos 6_diy1_b=logos/S3/002_logos 6_diy2_b=logos/S1/003_logos 6_diy1_b=logos/S2/003_logos 6_diy2_b=logos/S3/003_logos", "fast": false, "kind": "normal", "stageId": "act1autochess_m04", "modeId": "mode_multi_abyss", - "round": 12, + "round": 13, "seed": 1210847140, - "waveId": "act1autochess_h05", - "time": 224.77, - "ticks": 6743, + "waveId": "act1autochess_h06", + "time": 226, + "ticks": 6780, "reason": "cleared", - "killed": 21, - "total": 117, + "killed": 1, + "total": 111, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 350, + "rngDraws": 1226, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":21,"total":117,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":374057,"bossDmg":0,"heal":25487,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":1,"total":111,"perfect":false,"leaks":110,"leaksUncounted":0,"coins":0,"dmg":644387,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,116,0,0,5208,0,0,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",100.37,1,143,0,0,6642,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1060.28,1,145,0,4912,3527,0,0,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,98,5,85077,5533,0,0,3], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,104,0,0,8356,0,0,0], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,100,0,0,20296,0,0,0], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,132,0,16051,2544,0,16,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,135,5,91286,3174,0,37,1], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,77,0,6653,2744,0,3,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,7,11,170077,1749,0,21,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,144,0,0,5333,10172,24,1], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,94,0,0,8375,15315,21,1], + ["p1",1,"op","chess_char_6_diy1_a",0,null,1297,1,4,1,89738,0,0,126,1], + ["p1",2,"op","chess_char_6_diy2_a",1,null,1297,1,5,0,125176,0,0,239,4], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1297,1,15,0,189341,0,0,125,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",1540,1,137,0,14058,3080,0,21,0], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,78,0,46473,3080,0,65,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,129,0,34558,4620,0,42,0], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_logos",0,0,110,0,9075,3420,0,12,0], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_logos",1710,1,136,0,58098,3420,0,61,1], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_logos",0,0,120,0,17086,3420,0,12,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_logos",0,0,9,0,13718,1540,0,13,0], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_logos",0,0,74,0,27216,3080,0,21,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_logos",0,0,76,0,19849,3080,0,15,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_1005_yokai":[51,16,35,0,83756],"enemy_1005_yokai_2":[54,5,49,24107,111017],"enemy_1005_yokai_3":[12,0,12,42273,179284]}, - "hooks": [1,145,6743,501,501,975,936,86,44,140,9,9,0,14181,0,0,117,96,1,44,0,0,39], - "events": {"atk":594,"deploy":25,"die":44,"dmg":936,"fx":87,"heal":78,"leak":96,"skill":18,"spawn":132,"status":8}, - "fx": {"aoe":1,"dodge":39,"dp":9,"slash":32,"spGain":6}, - "snaps": "6b632c98 e087bd9f f5b9da13 f7038fad bb4d7811 5178d072 0c0de684 872f1892 81773c41 3d296a6a 66f1329a 3a99c8b1 aa011a13 319c8ec5 26231cc7 f808b9be 73dd7977 75857859 817f18b6 d1cdd533 cd927f27 64106a51 89f62784" + "enemies": {"enemy_1010_demon":[54,0,54,14960,289683],"enemy_1320_wdrrl_2":[18,0,18,0,186054],"enemy_1422_lrsldr_2":[39,1,38,8008,266859]}, + "hooks": [1,137,6780,787,787,2710,2845,0,19,129,14,13,0,20355,349,16,111,110,1,19,0,1,0], + "events": {"atk":859,"deploy":23,"die":19,"dmg":2845,"fx":351,"leak":110,"skill":27,"spawn":126,"status":559}, + "fx": {"burst":1,"logosExecute":1,"logosLexicon":349}, + "snaps": "0a3df1df e0f60909 28bc9de6 1340056f 8360e4e4 8c604771 122e133f 9100d8b8 c188959b 1e5a87f4 8d4297ae cbd287d7 7a7c2a5a a89b21e3 72d8035e e4097eb1 6dcbc866 12d78ae2 3d8e1181 f02682c3 ee3de652 73a8161c 47723d5c" }, "diy-005": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "5_diy1_a=mantra/S1 5_diy2_a=mantra/S2 5_diy1_a=mantra/S3 5_diy2_b=mantra/S1 5_diy1_b=mantra/S2 5_diy2_b=mantra/S3 5_diy1_b=mantra/S1/002_mantra 5_diy2_b=mantra/S2/002_mantra 5_diy1_b=mantra/S3/002_mantra 6_diy1_a=mantra/S1 6_diy2_a=mantra/S2 6_diy1_a=mantra/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_multi_funny", - "round": 13, + "round": 12, "seed": 823748262, - "waveId": "act1autochess_h06", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 63, - "total": 123, + "waveId": "act1autochess_h05", + "time": 183.17, + "ticks": 5495, + "reason": "cleared", + "killed": 60, + "total": 60, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 117, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":63,"total":123,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":274732,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, + "players": {"p1":{"killed":60,"total":60,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":729113,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,1834,1,132,5,19446,3374,0,80,2], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2142.36,1,141,5,29488,4720,0,101,2], - ["p1",3,"op","chess_char_5_diy1_a",0,null,1865,1,154,3,27639,3730,0,42,1], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,152,11,43106,4748,0,39,1], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,135,3,18986,4587,0,18,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1808.52,1,143,18,92036,3903,0,40,4], - ["p1",7,"op","chess_char_5_diy1_a",2,null,117.2,1,130,12,39744,6807,0,115,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,1,0,0,0,0,0,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,3,0,0,0,0,0,0], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,5,0,0,0,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,153,0,1743,2320,0,7,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1120.4,1,145,0,2545,3170,0,7,1] + ["p1",1,"op","chess_char_5_diy1_a",0,null,1071,1,10,0,39411,0,0,53,13], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1071,1,11,7,76522,0,0,73,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1071,1,1,0,0,0,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1308,1,3,0,0,0,0,0,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1308,1,5,1,20416,0,0,6,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1308,1,7,16,215442,0,0,68,2], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_mantra",1398,1,9,0,0,0,0,0,0], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_mantra",1398,1,12,15,189164,0,0,118,4], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_mantra",1398,1,2,0,0,0,0,0,0], + ["p1",10,"op","chess_char_6_diy1_a",0,null,1071,1,4,3,2825,0,0,5,1], + ["p1",11,"op","chess_char_6_diy2_a",1,null,1071,1,6,8,45779,0,0,42,3], + ["p1",12,"op","chess_char_6_diy1_a",2,null,1071,1,8,10,139554,0,0,77,2] ], - "enemies": {"enemy_1019_jshoot_2":[57,37,20,9953,112024],"enemy_1299_ymkilr_2":[27,6,20,25821,123649],"enemy_1422_lrsldr_2":[39,20,19,1584,123193]}, - "hooks": [1,154,6900,835,835,3335,3375,0,83,142,12,15,0,16427,38,83,123,59,1,83,0,0,0], - "events": {"atk":851,"deploy":31,"die":83,"dmg":3367,"fx":150,"leak":59,"skill":22,"spawn":135,"status":47}, - "fx": {"aoe":83,"displace":3,"dp":26,"slash":38}, - "snaps": "aa4b712f 51a2360b 59868653 c815c7eb 7dd47d06 3f3f03f4 89e883c3 60e1e7f6 2355716a 5d734980 75cde05f c0dd657c 4e5bd257 0f8a040d b1aafe05 c04cbb36 e6d37890 8457998a a19143e5 d709bc2b 4283543b 0d9edf54 6ef9963d" + "enemies": {"enemy_1045_hammer_2":[12,12,0,0,230400],"enemy_1061_zomshd":[48,48,0,0,500673]}, + "hooks": [1,72,5495,442,442,703,940,5983,60,60,31,29,0,18687,467,0,60,0,1,60,0,14,0], + "events": {"atk":458,"deploy":12,"die":60,"dmg":940,"fx":110,"skill":60,"spawn":72,"status":74}, + "fx": {"burst":14,"mantraArc":75,"mantraGate":12,"mantraGrant":9}, + "snaps": "b67b4f48 0047e442 0c853a14 c854a1e6 cf8a2ac8 f7dd7663 a74e5b74 d5941c6f 337deba4 9d2b8228 41dc5c58 efeef019 22102f33 5a89ffea 151ce0fe 6cba1522 d3942365 e9969b05 3f7dff34" }, "diy-006": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "6_diy1_b=mantra/S1 6_diy2_b=mantra/S2 6_diy1_b=mantra/S3 6_diy2_b=mantra/S1/002_mantra 6_diy1_b=mantra/S2/002_mantra 6_diy2_b=mantra/S3/002_mantra 5_diy1_a=veen/S1 5_diy2_a=veen/S2 5_diy1_a=veen/S3 5_diy2_b=veen/S1 5_diy1_b=veen/S2 5_diy2_b=veen/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m06", @@ -221,35 +221,239 @@ "round": 13, "seed": 2464539579, "waveId": "act1autochess_h06", - "time": 210.23, - "ticks": 6307, + "time": 209.03, + "ticks": 6271, "reason": "cleared", - "killed": 5, + "killed": 49, "total": 81, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 123, + "rngDraws": 55, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":5,"total":81,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":185202,"bossDmg":0,"heal":4048,"deaths":30,"layerGains":{}}}, + "players": {"p1":{"killed":49,"total":81,"perfect":false,"leaks":32,"leaksUncounted":0,"coins":0,"dmg":1091843,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,3,0,11325,1865,0,7,1], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,6,3,52957,2494,0,16,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,112,0,15444,8178,0,22,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,109,2,51553,12663,0,57,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,113,0,4256,2544,0,15,0], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,105,0,10180,3286,0,19,0], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,5,0,3060,1372,0,2,0], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,2,0,30589,1819,0,4,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,8,0,0,2028,2464,7,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,61,0,0,3890,1584,6,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,58,0,2490,3291,0,7,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,60,0,3347,3320,0,8,0] + ["p1",1,"op","chess_char_6_diy1_b",0,"none",0,0,1,5,81957,1308,0,22,5], + ["p1",2,"op","chess_char_6_diy2_b",1,"none",1308,1,102,3,136636,1308,0,39,2], + ["p1",3,"op","chess_char_6_diy1_b",2,"none",1308,1,101,9,171708,1308,0,43,2], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_mantra",0,0,2,4,86672,1453,0,19,4], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_mantra",0,0,89,9,181623,2906,0,52,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_mantra",0,0,85,11,221444,2906,0,44,1], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,95,0,5309,3645,0,3,0], + ["p1",8,"op","chess_char_5_diy2_a",1,null,1115,1,92,2,64967,1315,0,33,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,62,1,21015,2430,0,13,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,59,2,22358,2808,0,10,2], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,4,0,68437,1404,0,16,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,6,3,29718,1404,0,20,1] ], - "enemies": {"enemy_1267_nhpbr_2":[27,5,22,7765,89733],"enemy_1270_nhstlk_2":[36,0,36,14278,68347],"enemy_1272_nhtank_2":[18,0,18,24706,27121]}, - "hooks": [1,113,6307,262,262,449,476,16,35,111,6,21,0,5828,86,24,81,76,1,35,0,0,0], - "events": {"atk":283,"deploy":32,"die":35,"dmg":441,"fx":58,"heal":15,"leak":76,"skill":12,"spawn":93,"status":142}, - "fx": {"aoe":7,"displace":21,"spGain":6,"zone":24}, - "snaps": "1dcf4706 8bd55683 d93e4d02 ab832ad5 132699ab f37b039d f12175ca fe30225b 6b2b77c0 2caefa3a 1018f9e1 a60a2364 f81df3c0 d0f2780e 0e940f53 372c90ef 46af3a81 a6b2adf1 63234976 3b8b7c59 b62aa8d4" + "enemies": {"enemy_1267_nhpbr_2":[27,16,11,8879,160626],"enemy_1270_nhstlk_2":[36,21,15,4866,617756],"enemy_1272_nhtank_2":[18,12,6,10450,313461]}, + "hooks": [1,102,6271,333,333,1386,1588,0,67,99,21,21,21,15703,151,39,81,32,1,67,0,8,0], + "events": {"atk":356,"deploy":21,"die":67,"dmg":1588,"fx":180,"leak":32,"skill":42,"spawn":93,"status":98}, + "fx": {"burst":8,"mantraArc":40,"mantraGate":10,"mantraGrant":6,"palsy":56,"veenBounce":2,"veenVolley":35,"zone":23}, + "snaps": "46bd7f51 16ab37a3 93abeb1d 6ce895db 02f54e35 f60aecd3 ae34b4c1 2a2173d4 bab077bf 7618b6ce 079b7db3 c2d7a6b1 951a58f4 bacb44b1 bdff04b7 e19c2c02 5bbe4c03 30c4e789 12a014c5 aedc3e36 6025cdec" + }, + "diy-007": { + "about": "5_diy1_b=veen/S1/002_veen 5_diy2_b=veen/S2/002_veen 5_diy1_b=veen/S3/002_veen 6_diy1_a=veen/S1 6_diy2_a=veen/S2 6_diy1_a=veen/S3 6_diy2_b=veen/S1 6_diy1_b=veen/S2 6_diy2_b=veen/S3 6_diy1_b=veen/S1/002_veen 6_diy2_b=veen/S2/002_veen 6_diy1_b=veen/S3/002_veen", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m07", + "modeId": "mode_multi_normal", + "round": 13, + "seed": 1837403644, + "waveId": "act1autochess_h06", + "time": 211.87, + "ticks": 6356, + "reason": "cleared", + "killed": 16, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":16,"total":123,"perfect":false,"leaks":107,"leaksUncounted":0,"coins":0,"dmg":1333925,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_veen",1484,1,5,3,60051,0,0,11,5], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_veen",1484,1,7,2,102939,0,0,14,2], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_veen",1484,1,9,3,94024,0,0,17,2], + ["p1",4,"op","chess_char_6_diy1_a",0,null,1215,1,11,1,62501,0,0,18,4], + ["p1",5,"op","chess_char_6_diy2_a",1,null,1215,1,13,0,80617,0,0,14,2], + ["p1",6,"op","chess_char_6_diy1_a",2,null,1215,1,6,3,175418,0,0,101,1], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",1404,1,8,3,60933,0,0,9,5], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",1404,1,10,0,194533,0,0,87,3], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",1404,1,12,0,221599,0,0,90,2], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_veen",1594,1,14,0,166836,0,0,78,5], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_veen",0,0,98,1,43791,3188,0,7,2], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_veen",0,0,89,0,70685,3188,0,9,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1006_shield_3":[21,1,20,6376,478193],"enemy_1043_zomsbr":[66,9,57,0,294925],"enemy_1402_tgshd_2":[36,6,30,0,560808]}, + "hooks": [1,139,6356,463,463,2924,2924,26359,20,127,35,30,94,36784,0,4,123,107,1,20,0,0,0], + "events": {"atk":463,"deploy":14,"die":20,"dmg":2924,"fx":1248,"leak":107,"skill":65,"spawn":137,"status":88}, + "fx": {"expose":1027,"veenBounce":91,"veenVolley":130}, + "snaps": "2f9dbcc6 b9a13075 264dbb9a 35ac9ad8 1382c5c2 5359e7dc 7f431d9d 332ef30e eae888b9 b1db163d 0f0e4359 582fb0d0 bc3e5e5b 96d9d14c 1bc31f2a 70ae2ce8 5c68e1c1 34c5f4e5 60bf0331 45e47166 76194924 4736df7f" + }, + "diy-008": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m01", + "modeId": "mode_multi_hard", + "round": 12, + "seed": 3478524561, + "waveId": "act1autochess_h05", + "time": 217.67, + "ticks": 6530, + "reason": "cleared", + "killed": 1, + "total": 72, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":1,"total":72,"perfect":false,"leaks":71,"leaksUncounted":0,"coins":0,"dmg":17882,"bossDmg":0,"heal":3247,"deaths":25,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,6,0,0,2128,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,60,0,0,5816,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2032.51,1,98,0,0,5659,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,62,0,0,6668,0,0,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,97,1,3255,3813,0,12,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,96,0,6721,4530,0,14,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,4,0,934,1133,0,2,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,54,0,4584,3283,0,9,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,58,0,0,3089,1584,5,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,92,0,0,6153,1664,3,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,316.51,1,99,0,1738,2479,0,7,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,84,0,650,2262,0,3,1] + ], + "enemies": {"enemy_1005_yokai_2":[18,1,17,11008,8194],"enemy_1041_lazerd_2":[24,0,24,36007,9343],"enemy_1042_frostd":[30,0,30,0,345]}, + "hooks": [1,99,6530,191,191,137,137,8,26,97,7,7,0,9345,10,0,72,71,1,26,0,0,0], + "events": {"atk":191,"deploy":27,"die":26,"dmg":137,"heal":8,"leak":71,"skill":14,"spawn":84,"status":57}, + "fx": {}, + "snaps": "3205bb38 df41a38d e4ed6b85 15b93ab5 b5a2a86a 82dace40 cd435db1 d54cae09 b35fb408 234c9b8b cf2aacd1 2cac6ab1 6b3f4280 f4a5a83d f67340be b7de51b1 67429ac7 709803f4 a5a9ebf9 d2e1c81c b2f2760b 876f1f28" + }, + "diy-009": { + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m02", + "modeId": "mode_single_normal", + "round": 12, + "seed": 2612428600, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 22, + "total": 42, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 447, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":22,"total":42,"perfect":false,"leaks":20,"leaksUncounted":0,"coins":0,"dmg":256335,"bossDmg":0,"heal":30253,"deaths":21,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,3,0,0,2604,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,5,0,0,3371,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,64,0,9825,3058,0,0,2], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,65,1,29732,4228,0,0,4], + ["p1",5,"op","chess_char_5_diy1_a",2,null,2638,1,66,0,0,3058,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,62,0,0,26725,0,0,0], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,58,0,34698,2877,0,26,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",538.17,1,67,3,108789,6206,0,69,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,61,1,22721,6227,0,9,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,4,2,50570,1749,0,17,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,60,0,0,2868,17258,31,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,53,0,0,7492,12995,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] + ], + "enemies": {"enemy_1005_yokai_2":[18,18,0,605,7232],"enemy_1005_yokai_3":[12,2,9,37895,146145],"enemy_9009_acfort":[12,2,10,31962,102958]}, + "hooks": [1,67,6900,579,579,829,789,89,43,62,12,12,0,15743,0,0,42,19,1,28,0,0,40], + "events": {"atk":661,"deploy":23,"die":43,"dmg":789,"fx":154,"heal":83,"leak":19,"skill":24,"spawn":56,"status":40}, + "fx": {"aoe":3,"beam":55,"dodge":40,"dp":7,"slash":43,"spGain":6}, + "snaps": "6fb2e54e 9260f517 90aa9572 c02efbc4 d9060d1e 1879a796 77157ff4 21795d6f 2332dcd5 cdf709a0 dd9cca4e e77bfe06 f8d474b8 bfc92126 3e30f0c5 1fbfd6a8 4d19d517 16c79df1 d2393600 0343b99e 4dfe80f2 d3c6822f 7a760fab" + }, + "diy-010": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m03", + "modeId": "mode_multi_abyss", + "round": 13, + "seed": 4252812069, + "waveId": "act1autochess_h06", + "time": 216.23, + "ticks": 6487, + "reason": "cleared", + "killed": 1, + "total": 165, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 170, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":1,"total":165,"perfect":false,"leaks":164,"leaksUncounted":0,"coins":0,"dmg":283386,"bossDmg":0,"heal":0,"deaths":30,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,4,0,297,2604,0,2,0], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,6,0,3091,3431,0,8,0], + ["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,11,0,688,1865,0,8,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,110,0,700,4748,0,7,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,173,0,113,4587,0,5,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,195,0,27513,5712,0,8,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1154,1,202,0,28780,10386,0,41,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,201,0,30999,11584,0,41,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,5,0,24354,2638,0,17,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,7,1,46247,3531,0,20,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,8,0,50949,0,0,138,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,10,0,69655,0,0,146,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_1249_lysdb_2":[60,0,60,13256,125694],"enemy_1251_lysyta":[51,1,50,9466,109479],"enemy_1254_lypa_2":[54,0,54,28363,48214]}, + "hooks": [1,202,6487,557,557,722,677,0,31,195,8,24,0,13012,10133,43,165,164,1,32,0,0,4], + "events": {"atk":557,"deploy":34,"die":31,"dmg":612,"fx":92,"leak":164,"skill":15,"spawn":180,"status":1070}, + "fx": {"displace":27,"dodge":9,"dp":1,"shieldBreak":47,"slash":8}, + "snaps": "b20205df 3b2a4656 2a5a4633 688ddb34 d62698af e145f22a 015038e9 0d4c905b 4697e668 5647a05b abb98e27 4c93de3b bda262ee 3cc808dc 3749f269 edf26ee6 077c097b 879bf27a 9bf87751 5bf702d0 ba41dce5 7afc5289" + }, + "diy-011": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_funny", + "round": 13, + "seed": 538212341, + "waveId": "act1autochess_h06", + "time": 217.77, + "ticks": 6533, + "reason": "cleared", + "killed": 61, + "total": 144, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 500, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":61,"total":144,"perfect":false,"leaks":83,"leaksUncounted":0,"coins":0,"dmg":499270,"bossDmg":0,"heal":17608,"deaths":29,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,7,10,55296,3151,0,57,2], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,8,29,207965,7904,0,75,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,175,2,16124,6957,0,27,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1283,1,176,4,27017,15948,0,20,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,3,4,51612,1272,0,78,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,4,8,111658,2483,0,83,1], + ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,11,4,8558,1718,0,9,1], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,94,0,13468,5647,0,8,1], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,5,0,0,1224,12885,26,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,103,0,0,5758,4722,10,1], + ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,98,0,4435,2655,0,13,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,152,0,3138,4994,0,9,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_2021_syfish":[99,47,52,1381,218506],"enemy_2025_syufo":[45,14,31,58330,296328]}, + "hooks": [1,176,6533,721,721,1471,1763,61,90,173,11,25,0,12078,226,30,144,83,1,90,0,26,0], + "events": {"atk":902,"deploy":30,"die":90,"dmg":1736,"fx":60,"heal":52,"leak":83,"skill":22,"spawn":158,"status":218}, + "fx": {"aoe":2,"burst":26,"displace":15,"dodge":6,"spGain":11}, + "snaps": "e5c06ac2 9f8bef6f e4754291 af71684f e5a9c1a5 0a241b73 46470406 5a05a3ea d6cf7f80 c9155cb6 b34ff449 09ec0182 14ba3c5c c5a27185 d471100b 83bf9b56 c9fcc48a 9d0f861c a5f837bd 68d04290 d85378cf d3215f8e" } } } diff --git a/test/sim/palsy-trigger.test.js b/test/sim/palsy-trigger.test.js new file mode 100644 index 00000000..60f2c558 --- /dev/null +++ b/test/sim/palsy-trigger.test.js @@ -0,0 +1,62 @@ +// test/sim/palsy-trigger.test.js — the engine hook `palsyTrigger` (server/sim/ai.js enemyAttack): fired when a 麻痹 stack +// interrupts an enemy's normal attack (PRTS 真言 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时"), once per interrupt; a +// handler may keep the stack (`keep`) or kill the enemy. 真言's 噤声限域 (kits/ops/op-mantra.js) is its user. +// Run: node --test test/sim/palsy-trigger.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const guard = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null }); +const shooter = enemyRec({ key: 'enemy_shooter', hp: 1e6, atk: 100, bat: 1, range: 3, speed: 0, mass: 0 }); + +function field(setup) { + const h = makeBattle({ + defs: { chess: { t_guard: guard }, enemies: { enemy_shooter: shooter } }, + units: [{ chessId: 't_guard', row: 10, col: 5 }], content: 'none', autoFinish: false, setup, + }); + h.step(); + return h; +} + +test('palsyTrigger: one event per interrupted attack, before the stack is used; no event without 麻痹', () => { + const seen = []; + const h = field((b) => b.on('palsyTrigger', (c) => seen.push({ t: b.time, stacks: c.buff.stacks, enemy: c.enemy }))); + const e = h.spawn('enemy_shooter', { pos: [10, 7] }); + h.run(3.05); + assert.equal(seen.length, 0, 'no 麻痹, no event'); + const a0 = e.stats.attacks; + h.b.applyStatus(e, 'palsy', { value: 2 }); + h.run(2.05); + assert.deepEqual(seen.map((x) => [x.enemy, x.stacks]), [[e, 2], [e, 1]], 'each interrupt, the stack count before it is used'); + assert.equal(e.stats.attacks, a0, 'both attacks interrupted'); + assert.equal(e.findBuff('palsy'), null); + h.run(1.1); + assert.ok(e.stats.attacks > a0, 'attacks again'); + checkInvariants(h.b); +}); + +test('palsyTrigger: `keep` leaves the stack; a handler that kills the enemy ends its turn', () => { + let keepNext = true; + const h = field((b) => b.on('palsyTrigger', (c) => { if (keepNext) { c.keep = true; keepNext = false; } })); + const e = h.spawn('enemy_shooter', { pos: [10, 7] }); + h.step(); + h.b.applyStatus(e, 'palsy', { value: 1 }); + const a0 = e.stats.attacks; + h.run(1.1); + assert.equal(e.findBuff('palsy')?.stacks, 1, 'kept'); + assert.equal(e.stats.attacks, a0, 'still interrupted'); + h.run(1.05); + assert.equal(e.findBuff('palsy'), null, 'used by the next interrupt'); + assert.equal(e.stats.attacks, a0); + // a lethal handler + const h2 = field((b) => b.on('palsyTrigger', (c) => b.kill(c.enemy, null))); + const e2 = h2.spawn('enemy_shooter', { pos: [10, 7] }); + h2.step(); + h2.b.applyStatus(e2, 'palsy', { value: 3 }); + h2.run(1.2); + assert.ok(!e2.alive); + assert.equal(h2.b.errors.length, 0, JSON.stringify(h2.b.errors[0])); + checkInvariants(h.b); + checkInvariants(h2.b); +});