diff --git a/docs/SIM.md b/docs/SIM.md index 2d477bf5..786a915e 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -588,7 +588,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 | – | @@ -751,6 +751,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 | | `boomerangCaught` | `{ unit, attackId, isSkill, x, y }` | a 回环射手 boomerang came back to its thrower (`ai.js throwBoomerang`; `isSkill` = thrown by a skill attack): 娜仁图亚 LPS-Y "每回收5次回旋投射物", S3 "投射物全部回收时" | | `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) | diff --git a/server/sim/ai.js b/server/sim/ai.js index 5e061be6..149469b9 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -676,10 +676,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 bb38c4e9..20708c75 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -92,6 +92,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([ 'op-poca.js', 'op-typhon.js', 'op-narant.js', + 'op-logos.js', + 'op-mantra.js', + 'op-veen.js', ]); /** 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 7bacf967..d4690cb2 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -1107,126 +1107,126 @@ "snaps": "00fbd370 8c991923 96dc2bea 8d706ab9 43830855 d2a46ee0 09d5fe80 fd5bcc83 a88f6aca 1077c610 42023519 3f71e919 f0900b6b 05d99ec6 f7f79850 b612552c 23d34fc4 0835ffb5 8536e543 3091e242" }, "diy-028": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=narant/S1/002_narant 6_diy2_b=narant/S2/002_narant 6_diy1_b=narant/S3/002_narant 6_diy2_b=narant/S1/003_narant 6_diy1_b=narant/S2/003_narant 6_diy2_b=narant/S3/003_narant 5_diy1_a=csnipe 5_diy2_b=csnipe", - "fast": true, + "about": "6_diy1_b=narant/S1/002_narant 6_diy2_b=narant/S2/002_narant 6_diy1_b=narant/S3/002_narant 6_diy2_b=narant/S1/003_narant 6_diy1_b=narant/S2/003_narant 6_diy2_b=narant/S3/003_narant 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", + "fast": false, "kind": "normal", "stageId": "act1autochess_m06", "modeId": "mode_multi_abyss", "round": 13, "seed": 2789905475, "waveId": "act1autochess_h06", - "time": 209.47, - "ticks": 6284, + "time": 216.83, + "ticks": 6505, "reason": "cleared", - "killed": 9, + "killed": 24, "total": 147, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 121, + "rngDraws": 233, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":9,"total":147,"perfect":false,"leaks":138,"leaksUncounted":0,"coins":0,"dmg":730326,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":24,"total":147,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":1244770,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,97,0,654,4256,0,4,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",2908,1,6,0,0,0,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,9,0,0,0,0,0,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,169,0,14365,10002,0,18,0], - ["p1",5,"op","chess_char_6_diy1_b",0,"uniequip_002_narant",2331,1,173,1,67234,4662,0,20,2], - ["p1",6,"op","chess_char_6_diy2_b",1,"uniequip_002_narant",0,0,108,1,110299,4662,0,26,1], - ["p1",7,"op","chess_char_6_diy1_b",2,"uniequip_002_narant",0,0,127,3,174608,4662,0,43,2], - ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_003_narant",0,0,94,1,122987,5262,0,29,2], - ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_003_narant",0,0,88,0,83429,5262,0,28,2], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_003_narant",0,0,143,3,147500,5262,0,39,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,159,0,5253,3813,0,18,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,153,0,3998,4530,0,14,0] + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_narant",2331,1,170,2,83624,4662,0,30,2], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_narant",2331,1,171,2,135821,4662,0,46,1], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_narant",1576.39,1,140,6,346892,3086,0,94,2], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_narant",0,0,152,2,116728,5262,0,21,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_narant",0,0,162,7,416081,5262,0,81,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_narant",0,0,10,4,109274,2631,0,27,1], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,78,0,3165,2594,0,6,0], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,94,0,6652,2594,0,14,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,84,0,5176,2594,0,9,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,83,0,3742,3080,0,7,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,4,1,10707,1540,0,21,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,89,0,6908,3080,0,12,0] ], - "enemies": {"enemy_1010_demon_2":[36,1,35,22806,317498],"enemy_1071_dftman":[51,6,45,1753,194180],"enemy_1072_dlancer":[60,2,58,27814,218648]}, - "hooks": [1,173,6284,330,330,634,617,0,32,170,10,9,0,7331,124,30,147,138,1,32,0,0,17], - "events": {"atk":330,"deploy":26,"die":32,"dmg":617,"fx":166,"leak":138,"skill":19,"spawn":159,"status":199}, - "fx": {"aoe":34,"bounce":94,"dodge":38}, - "snaps": "46ac6de1 2112c33e 837a4ca6 d3ee52bf c1ba00c7 a9527591 0ab35d3e a6d4d348 ef74a88f 9464be05 0c0cf77b 9fe08ceb d1faeac8 d57e45f0 3f5a7950 d5f0437e 97116a6c f3594fa0 3e97535d e3b4a06c 09cf44e6" + "enemies": {"enemy_1010_demon_2":[36,1,35,14202,495297],"enemy_1071_dftman":[51,14,37,5132,306556],"enemy_1072_dlancer":[60,9,51,21712,442917]}, + "hooks": [1,171,6505,468,468,1005,993,0,45,168,11,10,0,6647,224,37,147,123,1,45,0,0,12], + "events": {"atk":468,"deploy":24,"die":45,"dmg":993,"fx":205,"leak":123,"skill":21,"spawn":159,"status":332}, + "fx": {"aoe":46,"bounce":89,"dodge":43,"logosLexicon":27}, + "snaps": "908a1f91 4582d7f2 dd208a1a 134a98fb 67558ccf 5bad995e b3ce5027 65152964 2f0c174b dc8e2347 0a686aab cca266d7 2dfb1ca0 47547715 c67debba 8c8f23d2 b5a4d136 ca6db6ee 8d8f8837 eaa96e1d 7c2c78bb 4dcf0b88" }, "diy-029": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "about": "5_diy1_b=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 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", "fast": false, "kind": "normal", "stageId": "act1autochess_m07", "modeId": "mode_multi_funny", - "round": 12, + "round": 13, "seed": 2642056114, - "waveId": "act1autochess_h05", - "time": 203.93, - "ticks": 6118, + "waveId": "act1autochess_h06", + "time": 195.3, + "ticks": 5859, "reason": "cleared", - "killed": 35, - "total": 114, + "killed": 70, + "total": 78, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 61, + "rngDraws": 1022, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":35,"total":114,"perfect":false,"leaks":79,"leaksUncounted":0,"coins":0,"dmg":351370,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}}, + "players": {"p1":{"killed":70,"total":78,"perfect":false,"leaks":8,"leaksUncounted":0,"coins":0,"dmg":941257,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,115,1,5012,5208,0,65,1], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,107,0,6422,6742,0,32,2], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,96,7,33528,3058,0,33,4], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,91,20,167275,3808,0,92,5], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,97,3,27895,5276,0,49,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,92,4,65144,6822,0,60,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,4,0,13450,0,0,96,3], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,7,0,15482,0,0,94,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,9,0,0,0,0,0,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,11,0,0,0,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,0,8532,0,0,72,2], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,110,0,8630,2262,0,62,2], + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_logos",1640,1,5,0,0,0,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_logos",1640,1,7,1,233,0,0,1,0], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_logos",1640,1,9,8,121206,0,0,35,3], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_logos",1540,1,11,8,56333,0,0,34,1], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_logos",1540,1,13,2,84853,0,0,92,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_logos",1540,1,6,19,136661,0,0,38,3], + ["p1",7,"op","chess_char_6_diy1_a",0,null,1297,1,8,0,0,0,0,0,0], + ["p1",8,"op","chess_char_6_diy2_a",1,null,1297,1,10,2,117732,0,0,161,3], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1297,1,12,8,239570,0,0,118,3], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",1540,1,14,22,184669,0,0,119,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",1540,1,3,0,0,0,0,0,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",1540,1,4,0,0,0,0,0,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] ], - "enemies": {"enemy_1166_dusbr_2":[45,21,24,2758,102053],"enemy_1169_duphlx_2":[51,12,39,4945,126678],"enemy_1172_dugago":[18,2,16,25472,122640]}, - "hooks": [1,135,6118,1009,1009,1517,1496,0,49,128,26,26,0,26499,134,64,114,79,1,64,0,0,21], - "events": {"atk":1009,"deploy":19,"die":49,"dmg":1496,"fx":188,"leak":79,"skill":52,"spawn":128,"status":414}, - "fx": {"aoe":46,"dodge":21,"dp":36,"revive":8,"slash":69,"stone":8}, - "snaps": "d294bf74 f726af71 b04a5d86 cc738f7b ae57a036 3981ef1a b09e1223 1d3de17a f52ea4c9 0c6bf8b5 dd94b32b 41e1f44b 7b8565f0 5f461c30 c6321a2a b5ad3265 5be21e97 4ddfcf65 c9263b7b a7a973da 60dd319e" + "enemies": {"enemy_1045_hammer_2":[18,17,1,0,335969],"enemy_1061_zomshd":[60,53,7,0,605288]}, + "hooks": [1,92,5859,598,598,1005,1065,11667,70,78,17,13,0,27522,283,0,78,8,1,70,0,0,0], + "events": {"atk":694,"deploy":12,"die":70,"dmg":1065,"fx":318,"leak":8,"skill":30,"spawn":92,"status":376}, + "fx": {"logosExecute":28,"logosLexicon":290}, + "snaps": "d77c72cd 6367c5ba ea565f8f 5f5df428 169ff00e b82e9767 e24922a1 68f5d155 8ca60410 956071bb 9e055166 b0ff6b34 e4adebd6 233ce084 49d5f3a4 73fcf051 acd5e35f 3539ff89 a8c65166 61f6bc02" }, "diy-030": { - "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "6_diy1_b=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 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", "fast": false, "kind": "normal", "stageId": "act2autochess_m01", "modeId": "mode_single_hard", - "round": 12, + "round": 13, "seed": 4282451111, - "waveId": "act1autochess_h05", + "waveId": "act1autochess_h06", "time": 230, "ticks": 6900, "reason": "timeout", - "killed": 24, - "total": 42, + "killed": 95, + "total": 135, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 360, + "rngDraws": 1919, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":24,"total":42,"perfect":false,"leaks":18,"leaksUncounted":0,"coins":0,"dmg":300294,"bossDmg":0,"heal":10910,"deaths":31,"layerGains":{}}}, + "players": {"p1":{"killed":95,"total":135,"perfect":false,"leaks":40,"leaksUncounted":0,"coins":0,"dmg":1891121,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,6,3,41257,1865,0,30,1], - ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,58,4,47553,4748,0,34,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,74,0,0,11540,0,0,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",638.46,1,75,0,0,13203,0,0,0], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,2,0,64600,1272,0,50,1], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,3,2,91461,1889,0,46,1], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,4,0,7285,1372,0,5,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,48,1,34810,7510,0,11,1], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,8,0,0,3647,6959,14,1], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,43,0,0,6191,3951,6,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,1,0,6208,1160,0,10,1], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,5,0,7121,1430,0,9,1] + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_logos",1710,1,2,8,105758,0,0,91,1], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_logos",1710,1,3,7,82090,0,0,182,3], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_logos",1710,1,4,12,212399,0,0,128,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_logos",0,0,147,5,39603,3080,0,46,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_logos",0,0,143,1,98006,3080,0,154,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_logos",0,0,142,5,125493,3080,0,72,3], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,153,3,27386,2142,0,38,9], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,124,8,36511,2142,0,65,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,8,13,460179,1071,0,95,2], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,114,2,23725,2616,0,55,13], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,1,14,102616,1308,0,75,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,5,17,577355,1308,0,95,2] ], - "enemies": {"enemy_1005_yokai_2":[18,18,0,222,8571],"enemy_1005_yokai_3":[12,3,6,25395,152682],"enemy_9009_acfort":[12,3,9,30211,139041]}, - "hooks": [1,75,6900,410,410,661,694,35,55,70,10,28,0,9568,225,0,42,15,1,41,0,0,0], - "events": {"atk":520,"deploy":33,"die":55,"dmg":694,"fx":57,"heal":31,"leak":15,"skill":20,"spawn":54,"status":74}, - "fx": {"aoe":5,"beam":46,"dodge":3,"spGain":3}, - "snaps": "d299cac5 9e5fecae 9fef873f 0041e6d5 bf4aa79e e9d81067 2b840ee7 5f2b2a5c 73fc1c64 a0d69be3 19bf79bd 438e2070 74bb8728 42487a28 de9f9caf 88600853 8c90dabd 84058f3d af1ad7d9 010e3167 6db40d66 e9645c1d 284f3878" + "enemies": {"enemy_1288_duskls":[27,22,5,2249,245694],"enemy_1288_duskls_2":[57,38,18,3884,586159],"enemy_1292_duskld":[51,35,15,13695,1059268]}, + "hooks": [1,153,6900,1188,1188,3029,4105,0,110,148,44,43,0,25246,17814,125,135,38,1,247,0,3,0], + "events": {"atk":1438,"deploy":18,"die":110,"dmg":4105,"fx":1191,"leak":38,"skill":87,"spawn":147,"status":1475}, + "fx": {"burst":3,"ember":137,"logosExecute":71,"logosLexicon":577,"mantraArc":300,"mantraGate":9,"mantraGrant":12,"palsy":47,"revive":35}, + "snaps": "ccefd180 74e321db 643097c1 db0e6e62 93c71528 cc5c27dc e099974a 9b647244 f1270822 4be2b7c9 98c43f6a 31a51556 caaffcdd 92411235 a7a659a0 b284a518 4614387d 684ed5e1 e41ab6bb d32ae971 855c5d18 4444222e 1b7941fe" }, "diy-031": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc", + "about": "5_diy1_b=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 6_diy2_b=mantra/S1 6_diy1_b=mantra/S2 6_diy2_b=mantra/S3 6_diy1_b=mantra/S1/002_mantra 6_diy2_b=mantra/S2/002_mantra 6_diy1_b=mantra/S3/002_mantra", "fast": false, "kind": "normal", "stageId": "act2autochess_m02", @@ -1234,73 +1234,286 @@ "round": 13, "seed": 1788669353, "waveId": "act1autochess_h06", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 12, + "time": 197.87, + "ticks": 5936, + "reason": "cleared", + "killed": 36, "total": 36, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 211, + "rngDraws": 89, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":12,"total":36,"perfect":false,"leaks":24,"leaksUncounted":0,"coins":0,"dmg":191861,"bossDmg":0,"heal":28856,"deaths":25,"layerGains":{}}}, + "players": {"p1":{"killed":36,"total":36,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1905321,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,61,0,0,5208,0,0,0], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,5,0,0,3431,0,0,0], - ["p1",3,"op","chess_char_6_diy1_a",2,null,63.15,1,64,0,7369,5241,0,0,1], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,63,0,47963,8259,0,0,3], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,50,0,0,10086,0,0,0], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,44,0,0,21845,0,0,0], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,59,0,573,3816,0,1,0], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,62,0,71082,5806,0,45,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,36,1,9537,5112,0,6,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,4,0,55338,1819,0,10,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,53,0,0,3280,16322,22,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,47,0,0,4850,12534,19,1], + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_mantra",1398,1,6,8,154570,0,0,43,10], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_mantra",0,0,7,3,260388,1398,0,56,3], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_mantra",0,0,55,1,34956,4194,0,19,1], + ["p1",4,"op","chess_char_6_diy1_a",0,null,1071,1,4,1,75122,0,0,0,0], + ["p1",5,"op","chess_char_6_diy2_a",1,null,1071,1,5,3,136924,0,0,39,3], + ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,8,4,294265,1071,0,73,2], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",401.08,1,52,0,43930,3523,0,21,4], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",1308,1,9,0,186745,0,0,41,4], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",866.05,1,46,6,248758,1750,0,33,2], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_mantra",0,0,10,3,128223,1453,0,27,6], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_mantra",0,0,11,2,233002,1453,0,53,3], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_mantra",561.67,1,3,3,108437,891,0,0,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_1005_yokai_2":[12,12,0,570,4443],"enemy_1005_yokai_3":[12,0,11,40977,83772],"enemy_9009_acfort":[12,0,12,37205,103646]}, - "hooks": [1,64,6900,320,320,443,409,74,37,60,9,9,0,11179,0,0,36,23,1,26,0,0,34], - "events": {"atk":353,"deploy":26,"die":37,"dmg":409,"fx":120,"heal":68,"leak":23,"skill":18,"spawn":50,"status":28}, - "fx": {"aoe":3,"beam":37,"dodge":34,"dp":4,"slash":32,"spGain":10}, - "snaps": "8bb89486 af5a3103 edf9cdf6 9c8aa642 4004d4b6 398064e7 b94ae3bd e4f347a5 8cf52583 63ae2ad4 2d11279d 74b6b79f dbb9f658 f3eeb437 731e600c 26663686 39a333c1 595094aa 1bf50db7 39b3ef7d eee313e8 326f4d94 97a0c299" + "enemies": {"enemy_1005_yokai_2":[12,12,0,1534,47800],"enemy_1005_yokai_3":[12,12,0,6840,1031956],"enemy_9009_acfort":[12,12,0,7359,825565]}, + "hooks": [1,55,5936,433,433,1812,2023,0,46,46,38,36,0,16252,187,0,36,0,1,44,0,24,0], + "events": {"atk":458,"deploy":17,"die":46,"dmg":2023,"fx":206,"skill":74,"spawn":50,"status":97}, + "fx": {"beam":11,"burst":24,"mantraArc":59,"mantraGate":17,"mantraGrant":7,"palsy":88}, + "snaps": "51e27048 9cef1698 b61a98fa e92e9fb7 bc012497 8cfcc3ad 4bded9b4 c1266494 ed60128f 74df4566 d31c137a 998f967d 857e9ead 02820043 73d9434c e05a067e e0d63b9d c1e5b283 57132f29 b039284f" }, "diy-032": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "5_diy1_a=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 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", "fast": false, "kind": "normal", "stageId": "act2autochess_m03", "modeId": "mode_multi_hard", - "round": 13, + "round": 12, "seed": 148338108, - "waveId": "act1autochess_h06", - "time": 216.33, - "ticks": 6490, + "waveId": "act1autochess_h05", + "time": 202.7, + "ticks": 6081, "reason": "cleared", - "killed": 2, - "total": 117, + "killed": 23, + "total": 114, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 69, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":2,"total":117,"perfect":false,"leaks":115,"leaksUncounted":0,"coins":0,"dmg":73158,"bossDmg":0,"heal":0,"deaths":25,"layerGains":{}}}, + "players": {"p1":{"killed":23,"total":114,"perfect":false,"leaks":91,"leaksUncounted":0,"coins":0,"dmg":859805,"bossDmg":0,"heal":0,"deaths":12,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,112,0,14601,3730,0,11,1], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,5,2,33453,2494,0,9,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,146,0,14003,8078,0,53,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,141,0,7814,13356,0,8,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,131,0,2241,3480,0,6,0], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,145,0,1046,4350,0,2,0], + ["p1",1,"op","chess_char_5_diy1_a",0,null,1215,1,8,3,86362,0,0,80,4], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1215,1,11,3,108771,0,0,83,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1215,1,13,1,120461,0,0,79,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1404,1,15,0,111835,0,0,67,4], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1404,1,4,7,146742,0,0,89,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1404,1,9,6,200718,0,0,121,1], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_veen",0,0,111,1,11132,2968,0,4,1], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_veen",0,0,87,0,25030,2968,0,6,0], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_veen",0,0,88,1,15957,2968,0,8,0], + ["p1",10,"op","chess_char_6_diy1_a",0,null,1215,1,136,1,10296,2430,0,7,0], + ["p1",11,"op","chess_char_6_diy2_a",1,null,0,0,80,0,7685,2430,0,5,0], + ["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,125,0,14816,2430,0,11,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_10031_cnvsld":[39,2,37,3710,53063],"enemy_1019_jshoot_2":[57,0,57,17616,17501],"enemy_9008_acbunn":[21,0,21,14162,2594]}, - "hooks": [1,146,6490,226,226,213,228,0,27,142,2,18,0,2389,10,23,117,115,1,27,0,0,0], - "events": {"atk":238,"deploy":26,"die":27,"dmg":226,"fx":1,"leak":115,"skill":4,"spawn":126,"status":16}, - "fx": {"displace":1}, - "snaps": "038e9c71 b94393f2 b660dbec 471efee3 ea2c5cd8 67a95602 be0382a8 ed05227c 9d9b8d44 97b9ccc2 cebb0260 f89cd753 07c1d2ac a9ae2748 9b26f9da 162fa605 4b05eaa4 0287acaf ec32f6a6 40f84535 dad58c31 43eb8c7a" + "enemies": {"enemy_10124_uashld_2":[12,0,12,1215,103016],"enemy_1043_zomsbr":[72,22,50,4183,401369],"enemy_1061_zomshd":[30,1,29,10796,355420]}, + "hooks": [1,136,6081,591,591,3365,3365,45973,35,126,19,18,39,24319,0,14,114,91,1,35,0,0,0], + "events": {"atk":591,"deploy":19,"die":35,"dmg":3365,"fx":96,"leak":91,"skill":37,"spawn":129}, + "fx": {"veenBounce":42,"veenVolley":54}, + "snaps": "10719944 a54473e5 586c12a4 e8572e94 cd658a5e 022bbca2 6875176a 88f39323 fe22255b b8659b38 b3436607 2bff9df5 448c5d3c bae393b0 7569d588 d5f887b5 1075cb4d 45d53303 6b53b401 8529d78b ee4d40ec" + }, + "diy-033": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=veen/S1 6_diy2_b=veen/S2 6_diy1_b=veen/S3 6_diy2_b=veen/S1/002_veen 6_diy1_b=veen/S2/002_veen 6_diy2_b=veen/S3/002_veen 5_diy1_a=csnipe 5_diy2_b=csnipe", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_single_normal", + "round": 13, + "seed": 2802554051, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 59, + "total": 96, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":59,"total":96,"perfect":false,"leaks":37,"leaksUncounted":0,"coins":0,"dmg":853050,"bossDmg":0,"heal":0,"deaths":11,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,108,0,912,6384,0,21,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,115,0,2232,8724,0,24,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,118,1,22685,5128,0,31,1], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,117,3,27195,10002,0,35,1], + ["p1",5,"op","chess_char_6_diy1_b",0,"none",1404,1,3,11,86649,0,0,79,5], + ["p1",6,"op","chess_char_6_diy2_b",1,"none",1404,1,4,20,114352,0,0,94,3], + ["p1",7,"op","chess_char_6_diy1_b",2,"none",1404,1,11,2,84750,0,0,27,2], + ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_002_veen",1594,1,13,2,65860,0,0,18,5], + ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_002_veen",1594,1,5,13,145332,0,0,99,4], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_veen",1594,1,10,5,200360,0,0,138,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,12,0,40062,0,0,192,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,14,0,62659,0,0,190,3], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1229_darmy":[27,19,8,1082,175575],"enemy_1229_darmy_2":[57,40,17,9636,509947],"enemy_9007_acelem":[12,0,11,19520,190361]}, + "hooks": [1,118,6900,975,975,3619,3644,0,70,106,29,27,55,34880,0,28,96,36,1,70,0,0,0], + "events": {"atk":975,"deploy":20,"die":70,"dmg":3644,"fx":138,"leak":36,"skill":56,"spawn":110,"status":18}, + "fx": {"beam":10,"veenBounce":55,"veenVolley":73}, + "snaps": "a07829f2 3cf158b0 479435c7 27a9529c 96a9ad65 9d17fa2c 50b24bed 0fb108d6 9d492c99 ec0861cb a829d34a c0dd4419 990b0b71 cace0625 3408f7ff 4e21a576 c8ddbd12 63514ded f63b31be e6fa5bcd 041f97db ca21f4f6 983c1aa5" + }, + "diy-034": { + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m01", + "modeId": "mode_multi_abyss", + "round": 12, + "seed": 1162087510, + "waveId": "act1autochess_h05", + "time": 206.8, + "ticks": 6204, + "reason": "cleared", + "killed": 7, + "total": 135, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 73, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":7,"total":135,"perfect":false,"leaks":128,"leaksUncounted":0,"coins":0,"dmg":532657,"bossDmg":0,"heal":31513,"deaths":17,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,107,1,4789,5208,0,27,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,124,0,12640,6742,0,39,2], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,151,0,38897,6450,0,23,1], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,100,1,230450,12913,0,47,4], + ["p1",5,"op","chess_char_5_diy1_a",2,null,1707.81,1,127,2,58554,13912,0,65,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,129,2,53451,6822,0,31,2], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,4,1,31054,1133,0,78,2], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,158,0,40022,1360,0,85,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,11,0,0,4772,14398,36,3], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",1021.64,1,13,0,0,4000,17115,38,3], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,116,0,17420,1864,0,52,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,14,0,45379,3000,0,120,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_1148_dssbr":[63,4,59,20340,257178],"enemy_1148_dssbr_2":[60,3,57,21428,223440],"enemy_1439_dslntf":[12,0,12,26407,52039]}, + "hooks": [1,159,6204,888,888,1168,2159,74,24,152,26,25,0,27105,210,67,135,128,1,24,0,39,21], + "events": {"atk":888,"deploy":21,"die":24,"dmg":2159,"fx":119,"heal":74,"leak":128,"skill":51,"spawn":150,"status":516}, + "fx": {"aoe":13,"burst":39,"dodge":21,"dp":6,"slash":40}, + "snaps": "8c950172 84292247 66bb7644 57694e16 77300d65 0485e386 b1644dc3 9fd8e152 ae38532d acf85cfd 6416e99b 150194af 384bffbd da563864 d3c6a232 e79c9e9f 752e3a91 4b57e564 3328cd07 f5dbb08c 0d910b50" + }, + "diy-035": { + "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_multi_funny", + "round": 12, + "seed": 4056308894, + "waveId": "act1autochess_h05", + "time": 202.73, + "ticks": 6082, + "reason": "cleared", + "killed": 87, + "total": 99, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 1043, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":87,"total":99,"perfect":false,"leaks":12,"leaksUncounted":0,"coins":0,"dmg":702906,"bossDmg":0,"heal":55399,"deaths":3,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,535.6,1,3,20,91921,1329,0,125,3], + ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,1,6,64041,0,0,60,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,311,1,4,1,2114,843,0,24,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1218,1,75,1,8828,2132,0,35,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,5,11,85301,0,0,201,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,8,23,178332,0,0,186,2], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,6,0,18491,0,0,17,2], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,9,21,192348,0,0,77,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,10,0,0,16500,27423,53,2], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1021,1,11,0,0,15939,27976,69,3], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,12,4,30977,25283,0,109,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,2,0,30553,0,0,54,2] + ], + "enemies": {"enemy_1010_demon":[42,41,1,54506,296286],"enemy_1320_wdrrl_2":[12,2,10,0,173583],"enemy_1422_lrsldr_2":[45,44,1,7520,233038]}, + "hooks": [1,112,6082,1234,1234,1797,1860,205,90,102,24,23,0,34456,6724,33,99,12,1,90,0,0,0], + "events": {"atk":1488,"deploy":13,"die":90,"dmg":1859,"fx":32,"heal":158,"leak":12,"skill":46,"spawn":111,"status":624}, + "fx": {"aoe":14,"displace":1,"dodge":16,"spGain":1}, + "snaps": "9b12b5a2 9008ef38 d758917c a78c501f 8e5be89d 9d1491a1 be80716d ba16422b 24993631 17f367e0 5eb39615 1d861ce1 e35e2e74 f401a47c 00eb08a9 77c341e2 708eb8a2 328fa66d 2cc731ce c77d68c6 d21e342c" + }, + "diy-036": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m03", + "modeId": "mode_single_hard", + "round": 13, + "seed": 2415735691, + "waveId": "act1autochess_h06", + "time": 202.87, + "ticks": 6086, + "reason": "cleared", + "killed": 192, + "total": 266, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 239, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":192,"total":266,"perfect":false,"leaks":74,"leaksUncounted":0,"coins":0,"dmg":435875,"bossDmg":0,"heal":48672,"deaths":10,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,227,4,3127,5208,0,22,1], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",3431,1,246,8,15036,12560,0,71,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1529,1,234,24,18577,4001,0,25,3], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1904,1,239,25,18823,4924,0,33,3], + ["p1",5,"op","chess_char_6_diy1_a",2,null,2638,1,163,13,46706,20174,0,77,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,228,14,103845,18923,0,84,3], + ["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,8,6,3432,0,0,27,1], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,12,18,30557,0,0,64,2], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,14,0,17500,0,0,23,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,4,80,178273,0,0,72,2], + ["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,6,0,0,0,25511,56,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,154,0,0,6020,23161,35,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": { + "enemy_1195_sfyin": [39,25,14,2609,109226], + "enemy_1196_msfyin": [50,39,11,0,85], + "enemy_1197_sfshu": [48,37,11,8745,174749], + "enemy_1198_msfshu": [111,85,26,0,287], + "enemy_1207_sfji": [12,6,6,60456,151454], + "enemy_1208_msfji": [6,0,6,0,74] + }, + "hooks": [1,290,6086,856,856,1375,1336,160,202,276,25,25,0,31178,0,63,266,74,1,207,0,0,39], + "events": {"atk":922,"deploy":21,"die":202,"dmg":1336,"fx":264,"heal":141,"leak":74,"skill":50,"spawn":281,"status":32}, + "fx": {"aoe":53,"dodge":39,"dp":55,"slash":40,"spGain":9,"summon":68}, + "snaps": "74c08ab0 d7fae2c7 e5fc0721 098ca8da 386839f8 1014e7e2 5b3f511c cae02b78 6c9c4314 3ef337b7 19e2c676 b3eebb59 5c2a3715 354e546e 6ca7ab30 6153fdd2 dc92916e 069961c0 9b27edc4 4c63bb80 c4485363" + }, + "diy-037": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m04", + "modeId": "mode_multi_normal", + "round": 13, + "seed": 775009396, + "waveId": "act1autochess_h06", + "time": 213.43, + "ticks": 6403, + "reason": "cleared", + "killed": 8, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 217, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":8,"total":114,"perfect":false,"leaks":106,"leaksUncounted":0,"coins":0,"dmg":372337,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,140,0,29075,5595,0,27,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,92,4,105718,4988,0,41,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,139,0,26275,10386,0,34,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,141,0,56947,11872,0,72,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,4,3,62257,0,0,139,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,5,1,92065,0,0,150,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_1381_winman":[27,5,22,5454,142346],"enemy_1381_winman_2":[48,3,45,5428,226363],"enemy_1387_winshd":[39,0,39,21959,171306]}, + "hooks": [1,141,6403,576,576,3039,3101,0,30,136,7,23,0,10542,12614,31,114,106,1,30,0,0,0], + "events": {"atk":611,"deploy":24,"die":30,"dmg":2992,"fx":37,"leak":106,"skill":13,"spawn":123,"status":800}, + "fx": {"displace":27,"dodge":10}, + "snaps": "e840ece4 d3471b55 e3a6dada 611a7964 e06281b6 bd53a023 022502fe eddd554d 18dcff13 eb023c12 11b5046a b199b58b 0db4afa9 bc8a6002 65d5bf21 87a25c0c eed04c57 554a7c10 fdd40ba4 9fbc3909 521a5144 3a7d6770" } } } 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); +});