From 5888f153ac88d51a7f1d158268877e59211b8cf0 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 01:33:46 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feedback5=20O16:=20two=20additive=20engine?= =?UTF-8?q?=20rules=20for=20the=20=E8=87=AA=E9=80=89=20kits=20of=20?= =?UTF-8?q?=E6=9C=BA=E6=A2=B0=E5=B8=88=20and=20=E5=87=AF=E5=B0=94=E5=B8=8C?= =?UTF-8?q?=20=E2=80=94=20a=20=E5=B1=8F=E9=9A=9C=20limited=20to=20a=20list?= =?UTF-8?q?=20of=20damage=20types,=20a=20healer=20that=20heals=20one=20all?= =?UTF-8?q?y=20through=20its=20=E7=A6=81=E7=96=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buffs.js makeBuff / damage.js absorbShields: `shieldType` may be a list of damage types — the shield absorbs those only (机械师's 屏障 absorb physical and arts damage, not true: buff_template_data mcnist_t_2 / mcnist_s_2_shield BlockDamage PHYSICAL_AND_MAGICAL); a single type and an untyped shield work as before. - damage.js heal / Battle.injuredAlliesInKeys: a healer profile's `healThrough(healer, ally)` names allies its heals reach through their 禁疗 (the `noHeal` flag of a summon's 禁疗, the `healFree` flag; a profile `noHeal` — 无法被友方治疗 — still stops it), and its heal selection takes them (凯尔希 on her Mon3tr: PRTS Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希… 无视)"; her charpack attack `_ignoreHealFree`); every other healer still skips them. - docs/SIM.md §3 / §4 / §8, professions.js profile field list. No content uses either yet: golden unchanged. Test: test/sim/feedback5-o16-engine.test.js (2). --- docs/SIM.md | 9 ++-- server/sim/battle/queries.js | 9 +++- server/sim/buffs.js | 5 +- server/sim/damage.js | 16 ++++-- server/sim/professions.js | 2 + test/sim/feedback5-o16-engine.test.js | 72 +++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 test/sim/feedback5-o16-engine.test.js diff --git a/docs/SIM.md b/docs/SIM.md index b2c94271..35ae990c 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -516,7 +516,8 @@ mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, - `onTick({battle, unit, buff, dt})` every tick, or every `interval` s. `persist: true` survives death/redeploy. - `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated; `shieldType` ('phys' | 'arts' | 'true' | 'elemental') = a 屏障 that absorbs that damage type only (夜莺 S2 法术护盾 "能吸收… - 法术伤害"); none = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Shields are spent oldest first. + 法术伤害"), or a list of them = those types only (机械师's 屏障 `['phys', 'arts']`: BlockDamage PHYSICAL_AND_MAGICAL — true + damage passes); none = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Shields are spent oldest first. - `visible: true` emits `['status', id, key, 1/0]` client events. `battle.removeBuff(unit, key|buff)`. **Mod keys** — additive: `atkFlat atkPct defFlat defPct hpFlat hpPct resFlat aspd batPct blockCnt rangeExtend @@ -688,7 +689,9 @@ unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `d `battle.heal(source, target, amount, { overheal=false, self, silent, regen, ignoreHealFree })`: no-op on `noHeal` targets (unless self — 禁疗 / 孤立 summons carry the flag, §3) and on `healFree` ones, self included (史尔特尔's 余烬), unless `regen` -(an HP-regen attribute tick) or `ignoreHealFree` (a heal that "无视禁疗"); +(an HP-regen attribute tick) or `ignoreHealFree` (a heal that "无视禁疗"); a healer whose profile names the target in +`healThrough(healer, target)` heals it through the `noHeal` flag and `healFree` (not a profile's `noHeal`), and its heal +selection (`injuredAlliesInKeys`) takes it — 凯尔希 on her Mon3tr (PRTS "持有禁疗(可被凯尔希…无视)"); × source `healingDealtMul` × target `healingTakenMul`; **`heal`** hook (mutable amount); capped at max HP; a `regen` tick — the unit's own 生命回复速度 (`s.hpRegen`, applied in the buffs phase) — is no 治疗 (PRTS 调香师 / 瑕光 / 铃兰 / 锡人 备注 "不受治疗加成和禁疗影响"): no multiplier, and the hook sees it (`opts.regen`) but cannot change its amount. Effects PRTS @@ -1151,7 +1154,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 `afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait "优先攻击重量最重的敌人", the highest current 重量等级 first: 早露 / 提丰), `blockFly`, `noHeal`, `skipEnemy(e)` (an enemy the unit never selects — its attacks, the enemies it blocks and its skill-trigger targets: targeting.js canTargetEnemy; 嵯峨 "不攻击重伤 -单位"), `boomerang` (the projectile stays +单位"), `healThrough(healer, ally)` (a healer that selects and heals that ally through its 禁疗 — §4; 凯尔希's Mon3tr), `boomerang` (the projectile stays `'boomerang'` whatever the data's generic ranged projectile says), `boomerangOnward(ctx)` (a boomerang's flight after its first hit belongs to content: ctx `hit(target, x, y)` / `comeBack(x, y)` — 娜仁图亚 S1's bounces, S2's dash; the loopshooter row), `rangeAoe` (applied after every override: sets `allInRange` and, on a ranged profile, the instant `'beam'`) (see the diff --git a/server/sim/battle/queries.js b/server/sim/battle/queries.js index cf0ba4ba..2c3dcd7a 100644 --- a/server/sim/battle/queries.js +++ b/server/sim/battle/queries.js @@ -54,15 +54,20 @@ export class BattleQueries { return false; } - /** Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. */ + /** + * Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. A `noHeal` (禁疗) ally + * is left out unless the healer's profile heals it through its 禁疗 (`healThrough(healer, ally)`: 凯尔希's Mon3tr — + * damage.js heal). + */ injuredAlliesInKeys(keys, healer, includeElement = false) { const out = []; if (!keys) return out; const set = keys instanceof Set ? keys : (healer && healer.rangeKeys === keys && healer.rangeKeySet ? healer.rangeKeySet : new Set(keys)); + const through = healer && healer.profile && typeof healer.profile.healThrough === 'function' ? healer.profile.healThrough : null; for (const a of this.allyUnits) { if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') continue; if (!set.has(a.tileR * COLS + a.tileC)) continue; - if (a !== healer && (a.s.flags.noHeal || (a.profile && a.profile.noHeal))) continue; + if (a !== healer && ((a.s.flags.noHeal && !(through && through(healer, a))) || (a.profile && a.profile.noHeal))) continue; const injured = a.hp < a.s.maxHp - 1e-6; const elem = includeElement && (a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion) > 0; if (injured || elem) out.push(a); diff --git a/server/sim/buffs.js b/server/sim/buffs.js index abc890d3..d65c7d2a 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -155,8 +155,9 @@ export function makeBuff(b) { tags: b.tags ?? [], shield: b.shield ?? (b.mods && b.mods.shield) ?? 0, shieldHits: b.shieldHits ?? 0, - // a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental'; null = - // every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") — 夜莺 S2 法术护盾 "能吸收…法术伤害" + // a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental', or a list + // of them (机械师's 屏障: ['phys', 'arts']); null = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") — + // 夜莺 S2 法术护盾 "能吸收…法术伤害" shieldType: b.shieldType ?? null, persist: !!b.persist, status: b.status ?? null, diff --git a/server/sim/damage.js b/server/sim/damage.js index 31944a58..73efad2d 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -184,14 +184,16 @@ export function leaderHitCancelled(battle, target, amount) { /** * Absorb damage with shields on `target`. Returns the remaining amount. `type` = the damage type: a shield buff with a - * `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害"); one without absorbs every type (PRTS 术语释义 屏障 - * "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order: "优先消耗先生成的屏障"). + * `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害") — or, a list of types, only those (机械师's 屏障: + * BlockDamage PHYSICAL_AND_MAGICAL, buff_template_data mcnist_t_2 / mcnist_s_2_shield: ['phys', 'arts']); one without + * absorbs every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order: + * "优先消耗先生成的屏障"). */ export function absorbShields(battle, target, amount, type = null) { if (amount <= 0) return 0; let changed = false; let rest = amount; - const absorbs = (b) => !b.shieldType || b.shieldType === type; + const absorbs = (b) => !b.shieldType || (Array.isArray(b.shieldType) ? b.shieldType.includes(type) : b.shieldType === type); for (let i = 0; i < target.buffs.length && rest > 0; i++) { const b = target.buffs[i]; if (b.shieldHits > 0 && absorbs(b)) { @@ -526,13 +528,17 @@ export function reduceElement(target, amount, el = null) { * with an hpRegen buff, PRTS 备注 "不受治疗加成和禁疗影响") is no 治疗: no 治疗加成 scales it — neither the healing * multipliers nor a `heal` handler (handlers still see it, with `opts.regen`; a change they make to its amount is * ignored). + * A healer whose profile names the target in `healThrough(healer, target)` heals it through its 禁疗 — the `noHeal` flag + * a summon's 禁疗 sets and the flag `healFree` (not a profile's `noHeal`, 无法被友方治疗): 凯尔希 on her Mon3tr (PRTS + * Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…无视)"; her heal selection takes it too, Battle.injuredAlliesInKeys). */ export function heal(battle, source, target, amount, opts = {}) { if (!target || !target.alive || target.removed || !target.deployed || target.bossPool) return 0; const self = source === target || !!opts.self; - if (!self && (target.s.flags.noHeal || (target.profile && target.profile.noHeal))) return 0; + const through = !!(source && source.profile && typeof source.profile.healThrough === 'function' && source.profile.healThrough(source, target)); + if (!self && ((target.s.flags.noHeal && !through) || (target.profile && target.profile.noHeal))) return 0; const regen = !!opts.regen; - if (target.s.flags.healFree && !regen && !opts.ignoreHealFree) return 0; + if (target.s.flags.healFree && !regen && !opts.ignoreHealFree && !through) return 0; let amt = regen ? amount : amount * (source && source.s ? source.s.healingDealtMul : 1) * target.s.healingTakenMul; if (!(amt > 0) || !Number.isFinite(amt)) return 0; if (battle._hooks.heal) { diff --git a/server/sim/professions.js b/server/sim/professions.js index 2337df4c..8a8f0fae 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -22,6 +22,8 @@ // dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y}) // canAttack(battle, unit) → bool afterAttack(battle, unit, targets) // skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位") +// healThrough(healer, ally) → bool (a healer that selects and heals that ally through its 禁疗 — Battle +// injuredAlliesInKeys, damage.js heal; 凯尔希 on her Mon3tr) // hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup // dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait) // tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites) diff --git a/test/sim/feedback5-o16-engine.test.js b/test/sim/feedback5-o16-engine.test.js new file mode 100644 index 00000000..6f666d42 --- /dev/null +++ b/test/sim/feedback5-o16-engine.test.js @@ -0,0 +1,72 @@ +// test/sim/feedback5-o16-engine.test.js — two additive engine rules the 0.2.0 自选 kits of 机械师 and 凯尔希 need: +// - a 屏障 limited to several damage types (buff `shieldType` as a list, damage.js absorbShields): 机械师's 屏障 absorb +// physical and arts damage only (buff_template_data mcnist_t_2 / mcnist_s_2_shield: BlockDamage PHYSICAL_AND_MAGICAL); +// - a healer that heals an ally through its 禁疗 (profile `healThrough(healer, ally)`, damage.js heal and +// Battle.injuredAlliesInKeys): 凯尔希 on her Mon3tr (PRTS Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…无视)") — her heal +// selection takes it and her heal reaches it; any other healer still skips it, a profile's `noHeal` (无法被友方治疗) +// still stops her. +// Run: node --test test/sim/feedback5-o16-engine.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js'; + +const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) }; +const CHESS = { + test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 10000, atk: 300, def: 0, res: 0 } }), + test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProf: 'physician', stats: { maxHp: 2000, atk: 100, def: 0, res: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [-1, 0], [-1, 1]] }), +}; + +test('a 屏障 of several types (shieldType [\'phys\', \'arts\']) absorbs those, not true damage; older shields first as ever', () => { + const h = makeBattle({ defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, units: [{ uid: 1, chessId: 'test_guard_a', row: 10, col: 5 }] }); + h.step(); + const u = h.unit(1); + h.b.addBuff(u, { key: 'test:pa', shield: 100, shieldType: ['phys', 'arts'], duration: 30 }); + let hp = u.hp; + for (const type of ['true', 'elemental']) { + h.b.dealDamage(null, u, { amount: 10, type, canDodge: false }); + hp -= 10; + assert.equal(u.hp, hp, `${type} passes it`); + } + assert.equal(u.findBuff('test:pa').shield, 100); + h.b.dealDamage(null, u, { amount: 30, type: 'phys', canDodge: false }); + h.b.dealDamage(null, u, { amount: 30, type: 'arts', canDodge: false }); + assert.deepEqual([u.hp, u.findBuff('test:pa').shield], [hp, 40], 'physical and arts absorbed'); + h.b.dealDamage(null, u, { amount: 50, type: 'arts', canDodge: false }); + assert.deepEqual([u.hp, u.findBuff('test:pa')], [hp - 10, null], 'spent: the rest reaches HP'); + // a single type still works as before + h.b.addBuff(u, { key: 'test:a', shield: 20, shieldType: 'arts', duration: 30 }); + hp = u.hp; + h.b.dealDamage(null, u, { amount: 10, type: 'phys', canDodge: false }); + assert.equal(u.hp, hp - 10); + checkInvariants(h.b); +}); + +test('profile healThrough: the healer selects and heals the ally it names through its 禁疗 (noHeal / healFree); others do not, and a profile noHeal still stops it', () => { + const marked = new Set(); + const kits = { test_medic_a: () => ({ trait: { healThrough: (healer, a) => marked.has(a) } }) }; + const h = makeBattle({ + defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, kits, hooks: ['heal'], captureNoisy: true, + units: [{ uid: 1, chessId: 'test_medic_a', row: 10, col: 3 }, { uid: 2, chessId: 'test_guard_a', row: 10, col: 4 }, { uid: 3, chessId: 'test_guard_a', row: 11, col: 4 }], + }); + h.step(); + const medic = h.unit(1), a = h.unit(2), b = h.unit(3); + h.b.addBuff(a, { key: 'test:healFree', flags: { noHeal: true }, persist: true }); + h.b.addBuff(b, { key: 'test:healFree', flags: { noHeal: true }, persist: true }); + a.hp = 1000; b.hp = 1000; + assert.deepEqual(h.b.injuredAlliesInKeys(medic.rangeKeys, medic), [], '禁疗: no heal target'); + assert.equal(h.b.heal(medic, a, 100), 0, 'no heal'); + marked.add(a); + assert.deepEqual(h.b.injuredAlliesInKeys(medic.rangeKeys, medic), [a], 'the named ally is a heal target'); + assert.equal(h.b.heal(medic, a, 100), 100, 'and is healed'); + assert.equal(h.b.heal(medic, b, 100), 0, 'the other 禁疗 ally is not'); + assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === medic && c.target === a), 5), 'its attack heals it'); + assert.ok(!h.hooksOf('heal').some((c) => c.source === medic && c.target === b), 'never the other'); + // another unit's heal is still stopped; healFree too passes for the named one; a profile noHeal does not + assert.equal(h.b.heal(b, a, 100), 0, 'another healer: 禁疗 holds'); + h.b.addBuff(a, { key: 'test:hf2', flags: { healFree: true }, persist: true }); + assert.equal(h.b.heal(medic, a, 50), 50, 'healFree passes for it'); + a.profile.noHeal = true; + assert.equal(h.b.heal(medic, a, 50), 0, 'a profile noHeal (无法被友方治疗) stops it'); + checkInvariants(h.b); +}); From d7a1d92d4062781ef7847e3cfbea104c4ee50347 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 01:34:10 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feedback5=20O16:=20=E8=87=AA=E9=80=89=20ope?= =?UTF-8?q?rator=20kits=20=E6=B6=A4=E7=81=AB=E6=9D=B0=E8=A5=BF=E5=8D=A1=20?= =?UTF-8?q?(+=20her=20=E6=9C=BA=E5=8A=A8=E7=9B=BE=E7=89=8C),=20=E6=9C=BA?= =?UTF-8?q?=E6=A2=B0=E5=B8=88=20(+=20her=20=E7=BB=93=E6=9E=84=E6=80=A7?= =?UTF-8?q?=E5=8E=9F=E7=90=86)=20and=20=E5=87=AF=E5=B0=94=E5=B8=8C=20(+=20?= =?UTF-8?q?her=20Mon3tr)=20=E2=80=94=20every=20skill,=20talent,=20trait=20?= =?UTF-8?q?and=20module=20(stage=201=20/=203)=20at=20every=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 涤火杰西卡 (ops/op-jesca2.js): the shield piece lasts 50 s (SPT-X stage 3: 60 s), holds 禁疗 and taunt 1, she turns to face it and back when it leaves, she and the unit behind her DEF +15 % (孤立 too), it falls with her and comes back 30 s later for 5 DP; T2 +1 SP per shield hit at 50 % (SPT-Y stage 3: 60 % and its redeploy −1 s per success, ≤ 14 s); S1 SP_FULL toggle ATK / DEF +40 / +55 % and the shield's DEF and +20 s life; S2 ACTIVE_RANGE 2-5 (not grown by 攻击距离), 0.3 s, 75 % dodge; S3 ACTIVE_RANGE 20 bullets, +1 range, 1.8 s, the shell (5 tiles / s, 1.7 burst, 250 % ATK, 5 s stun) while the shield stands next to her; SPT-X reveal, SPT-Y +1 range. - 机械师 (ops/op-mcnist.js): 结构性原理 30 s, 禁疗, back 40 s later for 10 DP; T2 30 % 屏障 (physical / arts, 超额防护); S1 3 bullets of 5 strikes (1.1, air too); S2 10 % 屏障 whose break bursts (200 % arts, 战栗 2 s) and buys a new one per bullet; S3 the 0.8 s cross (x-6, 240 % arts), the token's charge (300 % physical, redeployed on the stop tile) and its decaying weaken aura; SO-A: attributes only (its trait / talent are 集成战略-only). - 凯尔希 (ops/op-kalts.js): heal order herself / Mon3tr first (with no module the operator Mon3tr too), her heals through Mon3tr's 禁疗 (profile healThrough); Mon3tr DEF ×0 off her range (PHY-Y stage 3: ASPD +20 / DEF +20 % inside), withdrawn with her, back 25 s later for 10 DP; T2 1200 true + 3 s stun on its 3×3 when knocked out (PHY-X stage 3: 1500 / 3.5 s and the first time it falls to half HP); S1 DEF + 物理格挡; S2 / S3 bound to Mon3tr (绑定): Mon3tr hits every blocked enemy / true damage with an ATK bonus decaying every second and the 50 % 流失 without a kill; PHY-X / PHY-Y heal ×1.15; ISW-A attributes only. - Triggers from the data: the 重装 exception (S2 / S3 ACTIVE_RANGE for 涤火杰西卡, DEFAULT for 机械师 — rawRule TAKE_DAMAGE), 凯尔希's MANUAL skills DEFAULT (heal skills); 涤火杰西卡 S1 (AUTO, self / shield) SP_FULL by the owner's AUTO rule. - OPERATOR_KIT_FILES: op-jesca2.js, op-mcnist.js, op-kalts.js. Golden: only diy moved — diy-049 … diy-052 take the new pieces into their twelve, diy-053 … diy-058 are new (58 scenarios); roster 49, bonds 46, fields 22, matches 17, standins 10 unchanged. Tests: test/content/op_jesca2.test.js (10), op_mcnist.test.js (9), op_kalts.test.js (9). --- server/sim/content/kits/index.js | 3 + server/sim/content/kits/ops/op-jesca2.js | 322 +++++++++++++++ server/sim/content/kits/ops/op-kalts.js | 266 +++++++++++++ server/sim/content/kits/ops/op-mcnist.js | 368 ++++++++++++++++++ test/content/op_jesca2.test.js | 349 +++++++++++++++++ test/content/op_kalts.test.js | 355 +++++++++++++++++ test/content/op_mcnist.test.js | 340 ++++++++++++++++ test/golden/diy.json | 474 +++++++++++++++++------ 8 files changed, 2360 insertions(+), 117 deletions(-) create mode 100644 server/sim/content/kits/ops/op-jesca2.js create mode 100644 server/sim/content/kits/ops/op-kalts.js create mode 100644 server/sim/content/kits/ops/op-mcnist.js create mode 100644 test/content/op_jesca2.test.js create mode 100644 test/content/op_kalts.test.js create mode 100644 test/content/op_mcnist.test.js diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index 99f513f5..c951f183 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -103,6 +103,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([ 'op-ifrit.js', 'op-aphris.js', 'op-lin.js', + 'op-jesca2.js', + 'op-mcnist.js', + 'op-kalts.js', ]); /** diff --git a/server/sim/content/kits/ops/op-jesca2.js b/server/sim/content/kits/ops/op-jesca2.js new file mode 100644 index 00000000..140f35e8 --- /dev/null +++ b/server/sim/content/kits/ops/op-jesca2.js @@ -0,0 +1,322 @@ +// server/sim/content/kits/ops/op-jesca2.js — 涤火杰西卡 (char_1034_jesca2) 自选 operator kit: 6★ 哨戒铁卫 (重装), an owned-6★ +// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (SPT-X “私家英雄”, SPT-Y +// 未曾风化) at every form, and the kit of her summon 机动盾牌 (token_10032_jesca2_jckshd). Kit contract and the 自选 rules: +// ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_1034_jesca2): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, +// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no +// account]. Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json); +// PRTS 涤火杰西卡 (灵活应变 备注 "转向行为不受异常效果影响,仅在机动盾牌持续时间内生效;机动盾牌退场后,涤火杰西卡转回原方向", +// "本天赋的防御增加效果无视孤立"; 蓄能释放 备注 "受到任何伤害时均可触发(包括无法触发受击回复的伤害)"; S2 备注 "此技能的攻击范围 +// 不受“攻击距离”属性影响"; S3 备注 "仅技能持续期间可触发发射炮弹的行为,技能期间多次部署机动盾牌可触发多次发射炮弹行为(同一时间内最多 +// 生效1次)", "发射炮弹行为视为不消耗弹药的普通攻击,受缴械类效果影响时该行为延迟至此类效果消失后进行", "炮弹飞行终点为自身当前攻击 +// 范围内正前方最远一格中点,碰撞半径0.5,爆炸半径1.7"; SPT-Y 修正 "攻击距离+1(部分技能不受此影响)"); PRTS 机动盾牌 (备注 "持有 +// 禁疗", "无视野范围", "退场时返还1个可部署的机动盾牌"; 嘲讽等级 1, 阻挡数 2); the client's battle data (charpack char_1034_jesca2: +// Talents/1/DefAura range b-1 ignoring 孤立, Talents/2 x-5, die_to_kill_token, charge_token[born]; token prefab +// token_10032_jesca2_jckshd: jckshd_t = 禁疗 (abnormal flag 7) + taunt for `duration` s, HostMark b-1, charge_token[finish]; +// skill prefabs skchr_jesca2_1 JckshdBuff / skchr_jesca2_3 JckshdBuffS3 (auras on her x-5); projectile +// projectile_chr_jesca2_s3_bomb (speed 5, first enemy it passes, ground and air) → _explosion (every enemy in range, air +// too); buff_template_data jesca2_jckshd_dir_set / dir_ctrl, jesca2_t_2[sp], jesca2_e_003[t_sp] / [extend], jesca2_s_3[bomb]). +// - Trait (哨戒铁卫) "能够阻挡三个敌人,可以进行远程攻击": the profession default — ranged physical, blocks 3, hits air units +// (PRTS 分支特性信息 哨戒铁卫 "可对空"), ground enemies target her normally. +// - Module SPT-X “私家英雄”: trait addition "攻击范围内敌人的隐匿效果失效" (jesca2_e_002_t: 隐匿 immunity on the enemies of her +// range): reveal (stages 1 and 3). Stage 3 changes T1: the shield lasts 60 s and is stronger (the token's module +// attributes and talent — data byModule). Stats: the module attributes. +// - Module SPT-Y 未曾风化: "攻击距离+1(部分技能不受此影响)" — a permanent forward range extension (jesca2_e_003[extend]); S2's +// 2-5 ignores it (its 备注), S3 grows on it. Stage 3 changes T2 (below). Stats: the module attributes. +// - T1 灵活应变: her 机动盾牌 is a hand piece the player places next to her ("只能放置于涤火杰西卡四周" — the prep's placement +// rule; a piece on any other tile gets no turn, mark or aura here) and deploys with the board. Each deployment: it lasts +// the token talent's `duration` (50 s; SPT-X stage 3: 60 s), holds 禁疗 and taunt level `taunt_level` (its hidden talent: +// "更容易受到攻击"), makes no attack and blocks 2 (its stats); she turns to face it (her range turns with her) and turns +// back to the direction she had when it came, once it leaves — whatever the reason, stunned or not (PRTS 备注; the +// client's dir_set / dir_ctrl[reset]); while it stands next to her, she and the unit on the tile behind her (range b-1, +// any ally unit, 孤立 ones too) DEF +def. It leaves when she leaves (die_to_kill_token: killed), and comes back on its tile +// its respawnTime (30 s) after it left, paying its cost (5 DP), only while she stands — her (re)deployment makes a waiting +// one ready at once (charge_token[born]) [ASSUMED: the 卫戍 auto redeploy of a placed summon, as 鸿雪's 打字机 / 谬因's 中继器]. +// - T2 蓄能释放: every damage instance the shield takes — any damage, 无来源 / DoT included, not a 流失 or an element 损伤 — +// while it stands on her x-5: `prob` to give her `sp` SP (none while a skill of hers runs: AK). SPT-Y stage 3: prob 0.6 and +// each success also cuts the shield's own next redeploy time by respawn_time (1 s), at most respawn_time_max (14 s) per +// shield deployment (jesca2_e_003[t_sp]: the card buff lasts until the next spawn; AlwaysNext — the cut comes with the +// roll even when no SP can be gained). +// - S1 坚守阵线 (AUTO, 持续时间无限 — `toggle`): ATK +atk, DEF +def; every shield on her x-5 DEF +def while it runs (JckshdBuff +// aura) and +duration s of life once per shield deployment (jckshd_t, the overriding lifetime buff of the aura). An AUTO +// skill acting on herself and her shield fires at full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule, kits/README.md +// checklist 5; skchr_jesca2_1 `_allowNoTarget`). +// - S2 掩蔽护卫 (MANUAL, 15 s, data ACTIVE_RANGE on its 2-5 — the 重装 exception of 742f478): range 2-5 (not grown by +// 攻击距离), ATK +atk, attack interval base_attack_time s (−0.9 on 1.2 s), 75 % physical and arts dodge (the `evade` +// template, prob). +// - S3 饱和迸射 (MANUAL, 20 发弹药, data ACTIVE_RANGE on its 2-2 + 1): 攻击距离 +ability_range_forward_extend, attack interval +// +base_attack_time (0.6 s), ATK +atk, DEF +jesca2_s_3[def].def; her shields on x-5 DEF +jesca2_s_3_token[def].def. While +// a shield stands next to her (the mark: at the cast, or a shield deployed during the skill — at most one pending), she +// fires a shell as soon as she is not stunned / frozen / asleep / disarmed: an attack that spends no bullet and restarts +// her attack timer [ASSUMED: the template clears and restarts the attack cooldown]; it flies at BOMB_SPEED from her +// position to the centre of the farthest tile straight ahead of her current range, hits the first enemy it touches +// (BOMB_HIT_RADIUS, ground or air) or the end point, and explodes: every selectable enemy within +// attack@extrabomb.projectile_range takes attack@extrabomb.atk_scale × her ATK at the launch, physical (普通伤害, dodgeable), +// and is stunned attack@extrabomb.stun s (not when it dodged the hit [ASSUMED]). The skill's end drops a pending shell. + +import { num, talentBb, traitBb, moduleOn, skillRec, batMod, up, giveSp, installReveal } from '../shared/tier1.js'; +import { offsetTile, dirFromDelta, dirVec } from '../../../dir.js'; +import { isHpLoss } from '../../../damage.js'; + +const S1 = 'skchr_jesca2_1'; +const S2 = 'skchr_jesca2_2'; +const S3 = 'skchr_jesca2_3'; +/** Her summon 机动盾牌 (talent 灵活应变). */ +export const SHIELD = 'token_10032_jesca2_jckshd'; +/** S3 shell (PRTS 备注 / projectile_chr_jesca2_s3_bomb): flight speed (tiles / s) and collision radius. */ +const BOMB_SPEED = 5; +const BOMB_HIT_RADIUS = 0.5; +/** The shell's explosion radius when the blackboard lacks it (PRTS "爆炸半径1.7"). */ +const BOMB_BLAST_FALLBACK = 1.7; +/** Aura refresh period and buff life (s): the kits' aura convention (a unit leaving the tile loses it within one refresh). */ +const AURA = 0.2; +const AURA_DUR = 0.25; +/** How often a waiting shield tries to come back once ready (her on the field, its tile free, DP paid). */ +const RETRY = 0.1; +const DEF_KEY = 'talent:jesca2:def'; +const SHIELD_SKILL_KEY = 'skill:jesca2:shield'; +const SHIELD_ABNORMAL_KEY = 'jesca2:shield:abnormal'; +const TAG_BOMB = 'jesca2:bomb'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** A 机动盾牌 of `unit`. */ +const isShieldOf = (t, unit) => !!t && t.kind === 'token' && t.defId === SHIELD && t.ownerUnit === unit; +const shieldsOf = (battle, unit) => battle.allyUnits.filter((t) => isShieldOf(t, unit)); +/** `t` stands on one of the four tiles next to `unit` (the placement rule "四周"; her x-5 auras reach it there). */ +const besideOf = (unit, t) => Math.abs(t.tileR - unit.tileR) + Math.abs(t.tileC - unit.tileC) === 1; +/** The shield of hers standing next to her (the mark jesca2_jckshd_s_3[mark]: its HostMark b-1 holds her), or null. */ +const markOf = (battle, unit) => shieldsOf(battle, unit).find((t) => up(t) && besideOf(unit, t)) ?? null; +const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id); + +/** Face `dir` (the range turns with her: Battle.refreshRange; a SkillRuntime trigger grid follows `unit.dir`). */ +function turnTo(battle, unit, dir) { + if (unit.dir === dir) return; + unit.dir = dir; + if (unit.alive && unit.deployed) battle.refreshRange(unit); +} + +/** The farthest local column straight ahead (local row 0) of a relative grid (her current range). */ +function farthestAhead(grid) { + let n = 0; + for (const p of grid ?? []) if (Array.isArray(p) && p[0] === 0 && p[1] > n) n = p[1]; + return n; +} + +/** + * 灵活应变's piece: the shield's kit (lifetime = its talent's `duration`, 禁疗 + taunt, her turn, its redeploy). `owner` = + * 涤火杰西卡, `s1Ext` = the life her S1 adds (bb.duration). + */ +function shieldKit(owner, { s1Ext }) { + return { + skill: null, + trait: { noAttack: true }, // "无视野范围" (and ATK 0) + talents: [], + install(battle, t) { + const tal = (t.def?.talents ?? []).find((x) => x && x.bb && x.bb.duration != null)?.bb ?? {}; + const life = num(tal.duration, 50); + const taunt = num(tal.taunt_level, 1); + // 持有禁疗 (PRTS; jckshd_t abnormal flag 7 — the data's `abnormal` lacks it [ASSUMED: given here]) and "更容易受到攻击" + battle.addBuff(t, { key: SHIELD_ABNORMAL_KEY, flags: { noHeal: true }, mods: { taunt }, persist: true, allowDead: true }); + battle.on('deploy', (c) => { + if (c.unit !== t) return; + t.mem.lifeEnd = battle.time + life; + t.mem.s1Done = false; + t.mem.respawnCut = 0; + // she turns to face it; it faces away from her (dir_set: AssignRelativeDirectionToBB, SetBodyDirection) + if (up(owner) && besideOf(owner, t)) { + const dir = dirFromDelta(t.tileR - owner.tileR, t.tileC - owner.tileC, owner.dir); + if (owner.mem.jescaOldDir == null) owner.mem.jescaOldDir = owner.dir; + t.dir = dir; + turnTo(battle, owner, dir); + battle.fx('buff', { x: owner.x, y: owner.y, id: owner.id, kind: 'turn' }); + // S3: a shield taking the field during the skill fires a shell (at most one pending) + if (skillOn(owner, S3)) owner.mem.jescaBomb = true; + } + }, { owner: t }); + battle.on('tick', () => { + if (!up(t)) return; + // S1's aura gives a shield on her x-5 +s1Ext s of life, once per deployment + if (!t.mem.s1Done && s1Ext > 0 && skillOn(owner, S1) && up(owner) && besideOf(owner, t)) { + t.mem.s1Done = true; + t.mem.lifeEnd += s1Ext; + } + if (battle.time + 1e-9 >= t.mem.lifeEnd) battle.retreat(t, { reason: 'expired', permanent: true }); + }, { owner: t }); + battle.on('death', (c) => { + if (c.unit !== t || battle.finished) return; + // dir_ctrl[reset]: she turns back to the direction she had when it came — stunned or not, on the field or not + if (owner.mem.jescaOldDir != null) { + turnTo(battle, owner, owner.mem.jescaOldDir); + owner.mem.jescaOldDir = null; + } + // the piece stays (退场时返还1个可部署的机动盾牌): back on its tile once ready, while she stands + t.removed = false; + t.mem.readyAt = battle.time + Math.max(0, num(t.base.respawnTime) - num(t.mem.respawnCut)); + t.mem.respawnCut = 0; + if (t.mem.retry) return; + t.mem.retry = battle.every(RETRY, (b, sched) => { + const stop = () => { sched.cancel(); t.mem.retry = null; }; + if (t.alive || t.removed || b.finished) { stop(); return; } + if (!up(owner) || b.time + 1e-9 < t.mem.readyAt) return; + if (b.redeploy(t, { free: false })) stop(); + }, { owner: t }); + }, { owner: t, priority: -10 }); + }, + }; +} + +/** + * S3's shell: from her tile straight ahead to the centre of the farthest tile of her current range, at BOMB_SPEED; the + * first selectable enemy within BOMB_HIT_RADIUS (ground or air) or the end point sets it off. + */ +function fireBomb(battle, unit, b3) { + const [fr, fc] = dirVec(unit.dir); + const k = farthestAhead(unit.liveRangeGrid); + const bomb = { + x: unit.x, y: unit.y, tx: unit.tileC + fc * k, ty: unit.tileR + fr * k, + atk: unit.s.atk, // the launch's ATK (_cachedAtkKey) + scale: num(b3['attack@extrabomb.atk_scale'], 2.5), + radius: num(b3['attack@extrabomb.projectile_range'], BOMB_BLAST_FALLBACK), + stun: num(b3['attack@extrabomb.stun'], 5), + }; + (unit.mem.jescaShells ??= []).push(bomb); + unit.atkCd = unit.s.interval; // the shell is her attack: the timer restarts [ASSUMED] + battle.fx('bombardShell', { x: bomb.tx, y: bomb.ty, id: unit.id, r: bomb.radius, t: Math.hypot(bomb.tx - bomb.x, bomb.ty - bomb.y) / BOMB_SPEED }); +} + +/** Move the shells one tick; a shell touching an enemy or reaching its end point explodes. */ +function flyShells(battle, unit, dt) { + const list = unit.mem.jescaShells; + if (!list || !list.length) return; + const keep = []; + for (const s of list) { + const dx = s.tx - s.x, dy = s.ty - s.y; + const d = Math.hypot(dx, dy); + const step = BOMB_SPEED * dt; + let hit = battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0; + if (!hit) { + if (d <= step) { s.x = s.tx; s.y = s.ty; hit = true; } else { s.x += (dx / d) * step; s.y += (dy / d) * step; } + if (!hit && battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0) hit = true; + } + if (hit) explode(battle, unit, s); else keep.push(s); + } + unit.mem.jescaShells = keep; +} + +function explode(battle, unit, s) { + battle.fx('bombard', { x: s.x, y: s.y, id: unit.id, r: s.radius }); + const dodged = (unit.mem.jescaDodged = new Set()); + for (const e of battle.foesInRadius(s.x, s.y, s.radius)) { + if (!e.alive) continue; + battle.dealDamage(unit, e, { amount: s.atk * s.scale, type: 'phys', isSkill: true, tags: ['skill', TAG_BOMB] }); + if (e.alive && !dodged.has(e)) battle.applyStatus(e, 'stun', { duration: s.stun, source: unit }); + } + unit.mem.jescaDodged = null; +} + +export default { + char_1034_jesca2: (bb, chess) => { + const t0 = talentBb(chess, 0); // 灵活应变: def (+15 %) + const t1 = talentBb(chess, 1); // 蓄能释放: sp, prob (SPT-Y stage 3: + respawn_time, respawn_time_max) + const tb = traitBb(chess); // SPT-Y: ability_range_forward_extend + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s2 = skillRec(chess, S2); + const mod = moduleOn(chess) ? chess.module.id : null; + const shieldDef = (unit) => (skillOn(unit, S1) ? num(b1.def) : 0) + (skillOn(unit, S3) ? num(b3['jesca2_s_3_token[def].def']) : 0); + return { + skills: { + [S1]: { kind: 'toggle', trigger: 'SP_FULL', mods: { atkPct: num(b1.atk), defPct: num(b1.def) } }, + [S2]: { + kind: 'duration', + // 2-5, never grown by 攻击距离 (PRTS 备注) + targeting: { rangeGrid: s2?.rangeGrid ?? null, noRangeExtend: true }, + mods: { atkPct: num(b2.atk), batPct: batMod(b2.base_attack_time, chess), dodgePhys: num(b2.prob), dodgeArts: num(b2.prob) }, + }, + [S3]: { + kind: 'ammo', ammo: num(b3['attack@trigger_time'], 20), + targeting: { rangeExtend: num(b3.ability_range_forward_extend, 1) }, + mods: { atkPct: num(b3.atk), defPct: num(b3['jesca2_s_3[def].def']), batPct: batMod(b3.base_attack_time, chess) }, + onStart({ battle, unit }) { unit.mem.jescaBomb = !!markOf(battle, unit); }, + onEnd({ unit }) { unit.mem.jescaBomb = false; }, + }, + }, + talents: [ + { install(battle, unit) { // 灵活应变: her shield pieces run the shield's kit (set up before the battle starts) + const kit = () => shieldKit(unit, { s1Ext: num(b1.duration) }); + for (const t of battle.allyUnits) { + if (!isShieldOf(t, unit) || t.alive || t.deployed) continue; + if (t.kit) battle.offOwner(t); // a piece set up before her: drop its generic kit's hooks + battle._setupUnit(t, kit()); + } + // die_to_kill_token: her shields fall when she leaves; charge_token[born]: her (re)deployment readies a waiting one + battle.on('death', (c) => { + if (c.unit !== unit) return; + for (const t of shieldsOf(battle, unit)) if (t.alive) battle.kill(t, null); + }, { owner: unit }); + battle.on('deploy', (c) => { + if (c.unit !== unit) return; + for (const t of shieldsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time; + }, { owner: unit }); + // she and the unit on the tile behind her (b-1, 孤立 ones too: ignoreAllyTargetFree) DEF +def while her shield + // stands next to her + const d = num(t0.def); + if (!(d > 0)) return; + const aura = () => { + if (!up(unit) || !markOf(battle, unit)) return; + const [br, bc] = offsetTile(unit.tileR, unit.tileC, 0, -1, unit.dir); + for (const a of [unit, battle.unitAt(br, bc)]) { + if (!a || a.side !== 'ally' || a.kind === 'device' || !a.alive) continue; + const cur = a.findBuff(DEF_KEY); + if (cur && cur.source !== unit && (cur.data?.v ?? 0) > d && cur.timeLeft > 0.05) continue; + battle.addBuff(a, { key: DEF_KEY, duration: AURA_DUR, mods: { defPct: d }, source: unit, data: { v: d }, tags: ['talent', 'aura'] }); + } + }; + battle.every(AURA, aura, { owner: unit, immediate: true }); + battle.on('deploy', (c) => { if (isShieldOf(c.unit, unit)) aura(); }, { owner: unit }); + } }, + { install(battle, unit) { // 蓄能释放: the shield hurt ⇒ prob: +sp SP to her (SPT-Y stage 3: its next redeploy −1 s, ≤ 14 s) + const prob = num(t1.prob), sp = num(t1.sp); + const cut = num(t1.respawn_time), cutMax = num(t1.respawn_time_max); + if (!(prob > 0)) return; + battle.on('damaged', (c) => { + const t = c.target; + if (!isShieldOf(t, unit) || !up(unit) || !besideOf(unit, t)) return; + if (!c.dmg || isHpLoss(c.dmg) || c.type === 'element') return; + if (!battle.rng.chance(prob)) return; + giveSp(unit, sp, 'talent'); + if (cut > 0 && cutMax > 0) t.mem.respawnCut = Math.min(cutMax, num(t.mem.respawnCut) + Math.abs(cut)); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // SPT-X “私家英雄”: 攻击范围内敌人的隐匿效果失效 + if (mod === 'uniequip_002_jesca2') installReveal(battle, unit); + // SPT-Y 未曾风化: 攻击距离+1 — a permanent extension (her initial range too); S2's 2-5 ignores it (noRangeExtend) + const ext = num(tb.ability_range_forward_extend); + if (ext > 0) battle.addBuff(unit, { key: 'module:jesca2:range', mods: { rangeExtend: ext }, persist: true, allowDead: true }); + // S1 / S3: her shields on x-5 DEF +def while the skill runs (JckshdBuff / JckshdBuffS3 auras) + battle.on('tick', (c) => { + for (const t of shieldsOf(battle, unit)) { + if (!t.alive) continue; + const want = up(unit) && besideOf(unit, t) ? shieldDef(unit) : 0; + const has = t.findBuff(SHIELD_SKILL_KEY); + if (want > 0 && (!has || has.mods?.defPct !== want)) { + battle.addBuff(t, { key: SHIELD_SKILL_KEY, mods: { defPct: want }, source: unit, tags: ['skill'] }); + } else if (!(want > 0) && has) battle.removeBuff(t, SHIELD_SKILL_KEY); + } + // S3's shell: fired as soon as she can attack (not stunned / frozen / asleep, not disarmed), shells in flight move + if (unit.mem.jescaBomb && skillOn(unit, S3) && unit.canAct && !unit.s.flags.disarm) { + unit.mem.jescaBomb = false; + fireBomb(battle, unit, b3); + } + flyShells(battle, unit, c.dt ?? battle.dt); + }, { owner: unit }); + // a shell's explosion that is dodged carries no stun [ASSUMED] + battle.on('dodge', (c) => { + if (c.source === unit && unit.mem.jescaDodged && c.dmg?.tags?.includes(TAG_BOMB)) unit.mem.jescaDodged.add(c.target); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-kalts.js b/server/sim/content/kits/ops/op-kalts.js new file mode 100644 index 00000000..b09a283b --- /dev/null +++ b/server/sim/content/kits/ops/op-kalts.js @@ -0,0 +1,266 @@ +// server/sim/content/kits/ops/op-kalts.js — 凯尔希 (char_003_kalts) 自选 operator kit: 6★ 医师 (医疗), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her modules (PHY-X Mon2tr, PHY-Y 医者, ISW-A 凯尔希特限 +// 证章) at every form, and the kit of her summon Mon3tr (token_10002_kalts_mon3tr) — its block, its attacks and her skills +// acting through it. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_003_kalts): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, +// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no +// account]. Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json); +// PRTS 凯尔希 (Mon3tr 备注 "仅在未装配模组时优先治疗效果为优先治疗自身、干员Mon3tr和凯尔希的召唤物", "可以强行选择…凯尔希的召唤物 +// 作为治疗目标,相同生命比例下优先治疗自身", "治疗效果无视禁疗,但对象选取受禁疗制约"; 不毁重构 备注 "可对空"; S3 备注 "技能效果流失 +// 的生命值可以击倒Mon3tr,并触发第二天赋", "攻击力加成每秒更新一次"); PRTS Mon3tr(凯尔希的召唤物) (备注 "持有禁疗(可被凯尔希…无视)", +// "凯尔希退场时强制撤退场上的Mon3tr"; 攻击范围 1-1, 阻挡数 3); gamedata_const ba.binding 绑定 "绑定对象不在场时技能强制结束,清空 +// 所有技力且无法回复技力"; the client's battle data (charpack char_003_kalts: its attack `_ignoreHealFree`, WithdrawTokens, +// Talents/1 the in-range aura kalts_t_1[token_def_down_finish] + charge_token[born], Talents/2; token prefab +// token_10002_kalts_mon3tr: mon3tr_c_healfree (abnormal flag 7 = 禁疗), kalts_t_1[token_def_down] (DEF FINAL_SCALER 0), +// kalts_token[finish_kill_mark], Talents/2 the death rattle (FixedValueDamage PURE `value`, SPLASH, stun `stun`, range x-4), +// modes S2 (`_limitedMaxTargetNumToBlockedCnt`) / S3 (damage type PURE); skill prefabs skchr_kalts_1 (kalts_s_1[evade] = +// damage_block[phy]), skchr_kalts_2 / _3 (`_onlyAvailableWhenTokenValid`, RecoverSpCond every 0.1 s); equips kalts_equip_1_* +// (heal_scale_up[hpratio][LE]; stage 2+ the toggle-once HP ≤ 50 % rattle), kalts_equip_2_* (heal_scale_up[lowland]), +// kalts_equip_3_* (`_checkRoguelikeMode`); buff_template_data kalts_s_2_3[sp_cond], kalts_t_withdraw_token, +// kalts_s_3[ratio_atk], kalts_token_death_rattle_projectile, kalts_e_002_t[token]). +// - Trait (医师) "恢复友方单位生命": the profession default (one heal per attack on an injured ally of her 3-10 range), with T1's +// order: when she, her Mon3tr or — with no module — an operator Mon3tr (char_4179_monstr) is injured, the one of them with +// the lowest HP ratio (ties: herself); else the lowest HP ratio. Her heals reach her Mon3tr through its 禁疗, and her heal +// selection takes it (profile `healThrough`, damage.js heal / Battle.injuredAlliesInKeys); other 禁疗 units stay out. +// - Module PHY-X Mon2tr: ×heal_scale (1.15) on a heal whose target is at or below hp_ratio (50 %) before it +// (heal_scale_up[hpratio][LE]); stage 3 changes T2 (below). PHY-Y 医者: ×heal_scale on a heal whose target stands on a 地面 +// tile (heal_scale_up[lowland]); stage 3 changes T1 (below). ISW-A 凯尔希特限证章: only its attributes act — its trait +// ("在集成战略中,同时治疗两个目标") and talent ("在集成战略中,Mon3tr不占用部署位,生命上限和攻击力+50%…") are 集成战略-only +// (`_checkRoguelikeMode`): N/A here, the trait blackboard it carries is not used. Stats: the module attributes. +// - T1 Mon3tr: her Mon3tr is a hand piece the player places; it deploys with the board, blocks 3 and strikes one ground +// enemy of its 1-1 in melee for physical damage (its data), holds 禁疗 (only her heals pass — the data's `abnormal` lacks it +// [ASSUMED: given here]); its DEF is ×0 while its tile is outside her attack range (kalts_t_1[token_def_down]; PHY-Y stage +// 3: inside it ASPD +attack_speed, DEF +def — kalts_t_1[token_def_down_finish]). It is withdrawn when she leaves (no +// rattle); it comes back on its tile its respawnTime (25 s) after it left, paying its cost (10 DP), only while she stands — +// her (re)deployment readies a waiting one at once (charge_token[born]) [ASSUMED: the 卫戍 auto redeploy of a placed summon]. +// - T2 不毁重构: Mon3tr knocked out (not withdrawn; S3's 流失 counts) ⇒ every selectable enemy of its 3×3 (the talent's x-4, +// air units too) takes `value` (1200) true damage (溅射, not dodgeable) and is stunned `stun` (3) s. PHY-X stage 3 (the +// token's module talent: 3.5 s, 1500, hp_ratio): also once per deployment when a hit leaves it at or below hp_ratio of +// its max HP (the equip's toggle-once `_maxHpRatio` 0.5) and it survives [ASSUMED]. +// - S1 指令:结构加固 (MANUAL, data DEFAULT — a heal skill: cast as she is about to heal): her DEF +def and Mon3tr's DEF +// +attack@def while it runs; 物理格挡 prob (damage_block[phy]: a physical damage instance she takes is blocked whole — +// not a dodge, nothing ignores it; a 流失 is no damage). +// - S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +attack_speed; Mon3tr ATK +attack@atk and each of its attacks +// strikes every enemy it blocks (one target when it blocks none [ASSUMED]). +// - S3 指令:熔毁 (MANUAL, data DEFAULT, 绑定 Mon3tr, 18 s): Mon3tr DEF +attack@def, its attacks deal true damage, its ATK +// +attack@atk × the remaining share of the skill, updated every second from the cast (+190 % … +10.6 %); unless it killed +// an enemy meanwhile, at the skill's normal end it loses attack@hp_ratio × its max HP (a 流失 that may knock it out). +// - 绑定 (S2 / S3): cast only while her Mon3tr is on the field; every 0.1 s without one, a running S2 / S3 ends, her SP is +// emptied and recovers no more (阻回 flag `noSp`) until it is back (kalts_s_2_3[sp_cond]). + +import { num, talentBb, traitBb, moduleOn, skillRec, up } from '../shared/tier1.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_kalts_1'; +const S2 = 'skchr_kalts_2'; +const S3 = 'skchr_kalts_3'; +/** Her summon Mon3tr (talent Mon3tr). */ +export const MON3TR = 'token_10002_kalts_mon3tr'; +/** The operator Mon3tr (an owned 自选 pick): in T1's heal order when she wears no module (PRTS 备注). */ +const OP_MON3TR = 'char_4179_monstr'; +const PHY_X = 'uniequip_002_kalts'; +const PHY_Y = 'uniequip_003_kalts'; +/** 不毁重构's area when the token talent lacks it (x-4: its tile and the 8 around it). */ +const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]); +/** 绑定's check period (RecoverSpCond triggerInterval 0.1). */ +const BIND_IV = 0.1; +/** How often a waiting Mon3tr tries to come back once ready. */ +const RETRY = 0.1; +const BIND_KEY = 'kalts:binding'; +const TAG_RATTLE = 'kalts:rattle'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const isMon3trOf = (t, unit) => !!t && t.kind === 'token' && t.defId === MON3TR && t.ownerUnit === unit; +const mon3trsOf = (battle, unit) => battle.allyUnits.filter((t) => isMon3trOf(t, unit)); +const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id); +/** Keep buff `key` with `mods` on `u` while `want`; replace it when the mods changed. */ +function holdBuff(battle, u, key, want, mods) { + const has = u.findBuff(key); + if (!want) { if (has) battle.removeBuff(u, key); return; } + if (has && Object.keys(mods).every((k) => has.mods?.[k] === mods[k])) return; + battle.addBuff(u, { key, mods, tags: ['skill'] }); +} + +/** 不毁重构's blast from Mon3tr's tile: `value` true damage (溅射) + `stun` s on every selectable enemy of its 3×3. */ +function rattle(battle, m, { value, stun, grid }) { + battle.fx('aoe', { x: m.x, y: m.y, radius: 1.5, id: m.id, skill: TAG_RATTLE }); + const keys = absoluteRangeKeys(grid, m.tileR, m.tileC, m.dir, 0); + for (const e of battle.enemiesInKeys(keys, m, { canHitFly: true })) { + if (!e.alive) continue; + battle.dealDamage(m, e, { amount: value, type: 'true', isSplash: true, canDodge: false, tags: [TAG_RATTLE] }); + if (e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: m }); + } +} + +/** + * Mon3tr's kit: 禁疗, DEF ×0 outside her range (PHY-Y stage 3 bonus inside), her S1 / S2 / S3 acting through it, 不毁重构 + * (and PHY-X stage 3's), its return. `owner` = 凯尔希. + */ +function mon3trKit(owner, { t0, b1, b2, b3 }) { + return { + skill: null, + talents: [], + install(battle, m) { + const rt = (m.def?.talents ?? []).find((x) => x && x.bb && x.bb.value != null); + const rb = rt?.bb ?? {}; + const blast = { value: num(rb.value, 1200), stun: num(rb.stun, 3), grid: rt?.rangeGrid?.length ? rt.rangeGrid : X4 }; + const halfAt = num(rb.hp_ratio); // PHY-X stage 3: 生命值首次低于50% + battle.addBuff(m, { key: 'kalts:mon3tr:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗 + battle.on('deploy', (c) => { + if (c.unit !== m) return; + m.mem.halfDone = false; + m.mem.s3NoKill = false; + }, { owner: m }); + // her range, her skills: buffs held every tick + battle.on('tick', () => { + if (!up(m)) return; + const inRange = up(owner) && !!owner.rangeKeySet && owner.rangeKeySet.has(m.tileR * COLS + m.tileC); + holdBuff(battle, m, 'kalts:mon3tr:defZero', !inRange, { defMul: 0 }); + holdBuff(battle, m, 'kalts:mon3tr:near', inRange && (num(t0.attack_speed) > 0 || num(t0.def) > 0), { aspd: num(t0.attack_speed), defPct: num(t0.def) }); + holdBuff(battle, m, 'kalts:s1:mon3tr', skillOn(owner, S1), { defPct: num(b1['attack@def']) }); + holdBuff(battle, m, 'kalts:s2:mon3tr', skillOn(owner, S2), { atkPct: num(b2['attack@atk']) }); + let s3Atk = 0; + if (skillOn(owner, S3)) { + const sk = owner.skill; + const dur = sk.duration > 0 ? sk.duration : 1; + const steps = Math.floor(battle.time - sk.lastStart + 1e-9); // "攻击力加成每秒更新一次", from the cast + s3Atk = num(b3['attack@atk']) * Math.max(0, 1 - steps / dur); + } + holdBuff(battle, m, 'kalts:s3:mon3tr', skillOn(owner, S3), { atkPct: s3Atk, defPct: num(b3['attack@def']) }); + }, { owner: m }); + // S2: each attack strikes every enemy it blocks + battle.on('beforeAttack', (c) => { + if (c.attacker !== m || !skillOn(owner, S2)) return; + const blocked = battle.blockedTargets(m, c.profile); + if (blocked.length) c.targets = blocked; + }, { owner: m }); + // S3: true damage; a kill keeps its HP + battle.on('hit', (c) => { + if (c.source === m && c.dmg && c.dmg.isAttack && skillOn(owner, S3)) c.dmg.type = 'true'; + }, { owner: m, priority: 50 }); + battle.on('kill', (c) => { if (c.killer === m && c.victim && c.victim.side === 'enemy') m.mem.s3NoKill = false; }, { owner: m }); + // PHY-X stage 3: once per deployment, a hit that leaves it at or below half + if (halfAt > 0) { + battle.on('damaged', (c) => { + if (c.target !== m || m.mem.halfDone || !m.alive || !(m.hp > 0) || m.hpRatio > halfAt + 1e-9) return; + m.mem.halfDone = true; + rattle(battle, m, blast); + }, { owner: m }); + } + battle.on('death', (c) => { + if (c.unit !== m || battle.finished) return; + if (c.reason === 'killed') rattle(battle, m, blast); // 被击倒后(不包括撤退) + // the piece stays (退场时返还1个可部署的Mon3tr): back on its tile once ready, while she stands + m.removed = false; + m.mem.readyAt = battle.time + Math.max(0, num(m.base.respawnTime)); + if (m.mem.retry) return; + m.mem.retry = battle.every(RETRY, (b, sched) => { + const stop = () => { sched.cancel(); m.mem.retry = null; }; + if (m.alive || m.removed || b.finished) { stop(); return; } + if (!up(owner) || b.time + 1e-9 < m.mem.readyAt) return; + if (b.redeploy(m, { free: false })) stop(); + }, { owner: m }); + }, { owner: m, priority: -10 }); + }, + }; +} + +export default { + char_003_kalts: (bb, chess) => { + const t0 = talentBb(chess, 0); // Mon3tr: def, attack_speed (PHY-Y stage 3: 0.2 / 20 inside her range) + const tb = traitBb(chess); // PHY-X: heal_scale, hp_ratio; PHY-Y: heal_scale (ISW-A's: 集成战略-only, unused) + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const mod = moduleOn(chess) ? chess.module.id : null; + const picked = chess?.skill?.skillId ?? null; + return { + // her heals pass her Mon3tr's 禁疗 and her heal selection takes it (PRTS: "持有禁疗(可被凯尔希…无视)") + trait: { healThrough: (healer, a) => isMon3trOf(a, healer) }, + skills: { + [S1]: { kind: 'duration', mods: { defPct: num(b1.def) } }, + [S2]: { + kind: 'duration', mods: { aspd: num(b2.attack_speed) }, + onStart({ battle, unit, skill }) { if (!mon3trsOf(battle, unit).some(up)) skill.end('binding'); }, // 绑定 + }, + [S3]: { + kind: 'duration', + onStart({ battle, unit, skill }) { + const m = mon3trsOf(battle, unit).find(up); + if (!m) { skill.end('binding'); return; } + m.mem.s3NoKill = true; // kalts_s_3[no_kill_mark] + }, + onEnd({ battle, unit, reason }) { + if (reason !== 'duration') return; // ended by 绑定 or her leaving: no loss + for (const m of mon3trsOf(battle, unit)) { + if (!up(m) || !m.mem.s3NoKill) continue; + m.mem.s3NoKill = false; + battle.loseHp(m, m.s.maxHp * num(b3['attack@hp_ratio'], 0.5), { source: m, tags: ['kalts:meltdown'] }); + } + }, + }, + }, + talents: [ + { install(battle, unit) { // Mon3tr: her pieces run Mon3tr's kit; withdrawn with her, readied by her deployment + for (const t of battle.allyUnits) { + if (!isMon3trOf(t, unit) || t.alive || t.deployed) continue; + if (t.kit) battle.offOwner(t); + battle._setupUnit(t, mon3trKit(unit, { t0, b1, b2, b3 })); + } + battle.on('death', (c) => { + if (c.unit !== unit) return; + for (const t of mon3trsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'retreat', permanent: true }); + }, { owner: unit }); + battle.on('deploy', (c) => { + if (c.unit !== unit) return; + for (const t of mon3trsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time; + }, { owner: unit }); + // the heal order: herself, her Mon3tr (and, with no module, an operator Mon3tr) first when injured + const noModule = !moduleOn(chess); + const first = (a) => a === unit || isMon3trOf(a, unit) || (noModule && a.kind === 'op' && a.def?.charId === OP_MON3TR); + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !c.profile || c.profile.dmgType !== 'heal') return; + const pri = battle.injuredAlliesInKeys(unit.rangeKeys, unit).filter(first); + if (!pri.length) return; + pri.sort((a, b) => a.hpRatio - b.hpRatio || (a === unit ? -1 : b === unit ? 1 : 0) || a.deploySeq - b.deploySeq); + c.targets = [pri[0]]; + }, { owner: unit, priority: 10 }); + } }, + // 不毁重构 lives in Mon3tr's kit (its knock-out, PHY-X stage 3's half-HP blast) + ], + install(battle, unit) { + // PHY-X / PHY-Y: ×heal_scale on the heals she outputs to a target at or below hp_ratio / on a 地面 tile + const hs = num(tb.heal_scale, 1); + const boosted = mod === PHY_X ? (t) => t.hpRatio <= num(tb.hp_ratio, 0.5) + 1e-9 : mod === PHY_Y ? (t) => !!t.ground : null; + if (boosted && hs !== 1) { + battle.on('heal', (c) => { + if (c.source !== unit || c.opts?.regen || !c.target || !boosted(c.target)) return; + c.amount *= hs; + }, { owner: unit }); + } + // S1 物理格挡: a physical damage instance blocked whole with prob + if (picked === S1) { + const p = num(b1.prob); + battle.on('hit', (c) => { + if (c.target !== unit || !skillOn(unit, S1) || !c.dmg || c.dmg.cancel || c.dmg.type !== 'phys' || !(p > 0)) return; + if (!battle.rng.chance(p)) return; + c.dmg.cancel = true; + battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit, priority: -500 }); + } + // 绑定 (S2 / S3): no Mon3tr on the field ⇒ the skill ends, the SP is emptied and recovers no more + if (picked === S2 || picked === S3) { + battle.every(BIND_IV, () => { + if (!unit.alive || !unit.deployed || !unit.skill) return; + if (mon3trsOf(battle, unit).some(up)) { if (unit.findBuff(BIND_KEY)) battle.removeBuff(unit, BIND_KEY); return; } + const sk = unit.skill; + if (sk.active) sk.end('binding'); + sk.sp = 0; + sk.charges = 0; + if (!unit.findBuff(BIND_KEY)) battle.addBuff(unit, { key: BIND_KEY, flags: { noSp: true }, tags: ['skill'] }); + }, { owner: unit, immediate: true }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-mcnist.js b/server/sim/content/kits/ops/op-mcnist.js new file mode 100644 index 00000000..cf3bf962 --- /dev/null +++ b/server/sim/content/kits/ops/op-mcnist.js @@ -0,0 +1,368 @@ +// server/sim/content/kits/ops/op-mcnist.js — 机械师 (char_4230_mcnist) 自选 operator kit: 6★ 哨戒铁卫 (重装), an owned-6★ pick +// of the tier-5 and tier-6 自选 slots — not the 补位 stand-in Mechanist (char_610_acfend, standin-acfend.js); every skill, +// both talents, the trait and her module SO-A 机械师特勤证章 at every form, and the kit of her summon 结构性原理 +// (token_10069_mcnist_mcgraf). Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4230_mcnist): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, +// the module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json); PRTS 机械师 +// (生命方程 备注: the 【超额防护】 of her own 屏障 — "当此屏障吸收部分伤害…后,若此时“持有的所有同来源的屏障”的屏障值总和等于0 +// 时,将本次伤害直接视为抵挡"; S1 备注 "弹道溅射半径1.1(碰撞判定),可对空"; S2 备注 "屏障“被摧毁时...”的效果,其溅射半径1.5(中点 +// 判定),可对空;该效果存在极短的触发冷却…且冷却期间强制格挡受到的所有伤害", "屏障持续时间无限,但技能结束后屏障不再具备“被摧毁时...” +// 的效果"; S3 备注 "每次攻击变为向攻击目标所在地块中心投掷十字星弹道,弹道延迟0.8秒后落地,伤害范围x-6(碰撞判定),可对空", +// "结构性原理的“冲锋”效果为向其部署方向发射弹道,弹道终点位于其正前方尽可能远的可通行、可部署地面地块(无法越过存在敌人的地块以及 +// 高台地块)", "弹道停止时,以该地块为中心对半径1.5的敌方单位产生伤害(碰撞判定);那之后,场上的结构性原理立刻撤退并在停止地块上 +// 重新部署(非移动),随后其获得半径1.5(中点判定)的【虚弱光环】…初始为50%,每秒减少2.5%,降至0%后虚弱光环结束", "技能开启时若场上 +// 不存在结构性原理,则冲锋会保留至下次部署结构性原理后再触发"); PRTS 结构性原理 (备注 "持有禁疗", 天赋 备注 "此天赋提供的屏障视为 +// 来源于自身的持有者"); the client's battle data (charpack char_4230_mcnist: Talents/1 charge_token[born] + die_to_kill_token, +// Talents/2 mcnist_t_2, the 精通 abilities tagged rogue_6; token prefab token_10069_mcnist_mcgraf: mcgraf_s_3_passive +// (abnormal flag 7 = 禁疗), charge_token[finish], Talents/2 mcnist_t_2, Talents/-1 the weaken aura; skill prefabs +// skchr_mcnist_2 (ammo counted by CharSkillManualTriggerCountEvent, not by attacks) / skchr_mcnist_3; projectiles +// projectile_chr_mcnist_s1 / _s3 / _s3_token_*; buff_template_data mcnist_t_2, mcnist_s_2_mode, mcnist_s_2_shield (BlockDamage +// PHYSICAL_AND_MAGICAL), mcnist_s_3_projectile*, mcgraf_s_3_trigger / _aura / _mark, mcgraf_t_suicide). +// - Trait (哨戒铁卫): the profession default — ranged physical, blocks 3, hits air units; ground enemies target her. +// - Module SO-A 机械师特勤证章: only its attributes (ATK / DEF, in the stats) act here. Its trait ("在【沉沦者的黑流树海】中,攻击 +// 被自身或召唤物阻挡的敌人时攻击力提升至150%") and its stage-2 / 3 talent ("在【沉沦者的黑流树海】中,结构性原理的持续时间无限, +// 5秒内未受到伤害后每秒获得4%最大生命的屏障") name a 集成战略 theme (沉沦者的黑流树海 = rogue_6, PRTS 精通 "希望", "临时招募"): +// N/A outside it (the brief's rule) — the token's merged module talents (skill@duration −1, the barrier regen) are ignored. +// - T1 结构性原理: her 结构性原理 is a hand piece the player places; it deploys with the board, lasts the token talent's +// skill@duration (30 s; the base variant — the SO-A override is IS-only) from each deployment, blocks 3 and strikes one +// ground enemy in melee for physical damage (its data), holds 禁疗 (PRTS; mcgraf_s_3_passive flag 7 — the data's +// `abnormal` lacks it [ASSUMED: given here]). 拥有独立的再部署时间: back on its tile its respawnTime (40 s) after it left, +// paying its cost (10 DP), only while she stands; her (re)deployment readies a waiting one at once (charge_token[born]); +// it falls when she leaves (die_to_kill_token) [ASSUMED: the 卫戍 auto redeploy of a placed summon, as 鸿雪's 打字机]. +// - T2 生命方程: she and her 结构性原理, at each deployment, get a 屏障 of hp_ratio × their own max HP (infinite until spent). +// Every 屏障 that comes from her (this one, S2's, the token's — "视为来源于自身的持有者") absorbs physical and arts damage +// only (BlockDamage PHYSICAL_AND_MAGICAL: buff `shieldType` ['phys', 'arts']) and has 超额防护: the hit that breaks it is +// cancelled whole when no 屏障 of hers is left on that unit afterwards (the `hpDamage` hook — what passed the shields). +// - S1 聚类分析 (AUTO, attack SP, data DEFAULT — an AUTO "next attacks" skill waits for her attack): 3 bullets (`ammo`); attack +// interval +base_attack_time (1.3 s); each attack lands where its target is and strikes 5 times (attack@times), every +// attack@interval s, every enemy within attack@projectile_range (1.1, air units too) for attack@atk_scale × ATK physical — +// the first strike on impact through the engine's splash, the next four from the impact point with the ATK of the first. +// - S2 协防术式 (MANUAL, data DEFAULT): ATK +atk; she and her 结构性原理 (one deployed later during the skill too) get a 屏障 of +// hp_ratio × their max HP (a re-cast replaces it). When one breaks while the skill runs: every enemy within range_radius +// (1.5, 中点判定, air units too) of its holder takes atk_scale × her ATK arts and 战栗 not_combat s (none on a dodged hit +// [ASSUMED]); meanwhile the holder takes no damage; then, with a bullet left (trigger_time bullets — attacks spend none), one +// is spent and the holder gets a new 屏障; the last bullet ends the skill and leaves that 屏障 (infinite, with no break effect). +// - S3 工程学十字星 (MANUAL, data DEFAULT, 40 s): ATK +atk, attack interval +base_attack_time (2.3 s); each attack throws a +// cross at the centre of its target's tile: X6_DELAY s later every selectable enemy on the x-6 around it (attack@ +// projectile_range "x-6"; air units too) takes attack@atk_scale × her ATK of the throw, arts; the enemies her 结构性原理 +// blocks are her targets too (setExtraRange: their tiles while the skill runs). The cast (or, without a 结构性原理 on the +// field, its next deployment during the skill — and every later one) makes it charge along its facing to the farthest +// passable, deployable low tile before an enemy's tile or high ground: CHARGE_DELAY s + the distance at CHARGE_SPEED, then +// every enemy within projectile_range (1.5) of the stop tile takes atk_scale × her ATK physical, and the 结构性原理 is +// deployed anew there (free; a new deployment: full HP, 生命方程 again — not a 移动). While the skill runs, each deployment of +// it carries a weaken aura of range_radius (1.5, 中点判定): 虚弱 atk (50 %) on the enemies inside, 2.5 % weaker every second +// (atk_interval / interval), gone at 0 or when the skill ends. + +import { num, skillRec, batMod, up } from '../shared/tier1.js'; +import { COLS } from '../../../constants.js'; +import { dirVec } from '../../../dir.js'; + +const S1 = 'skchr_mcnist_1'; +const S2 = 'skchr_mcnist_2'; +const S3 = 'skchr_mcnist_3'; +/** Her summon 结构性原理 (talent 结构性原理). */ +export const MCGRAF = 'token_10069_mcnist_mcgraf'; +/** Her 屏障 absorb physical and arts damage only (BlockDamage PHYSICAL_AND_MAGICAL). */ +const BARRIER_TYPES = Object.freeze(['phys', 'arts']); +const T2_KEY = 'mcnist:t2:barrier'; +const S2_KEY = 'mcnist:s2:barrier'; +/** S3 cross (range_table x-6: the plus of reach 2 around the landing tile) and its landing delay (PRTS 备注). */ +const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]); +const X6_DELAY = 0.8; +/** S3 charge (projectile_chr_mcnist_s3_token_*: `_delayToStart` 0.2, speed 20 tiles / s). */ +const CHARGE_DELAY = 0.2; +const CHARGE_SPEED = 20; +/** Weaken aura refresh / buff life (s) and its fallback numbers (mcgraf hidden talent: atk −0.5, +0.025 per 1 s). */ +const AURA = 0.2; +const AURA_DUR = 0.25; +/** 结构性原理's life when no source carries it (PRTS: 部署后持续30秒 at E2). */ +const LIFE_FALLBACK = 30; +/** How often a waiting 结构性原理 tries to come back once ready. */ +const RETRY = 0.1; +const TAG_CROSS = 'mcnist:cross'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const isGrafOf = (t, unit) => !!t && t.kind === 'token' && t.defId === MCGRAF && t.ownerUnit === unit; +const grafsOf = (battle, unit) => battle.allyUnits.filter((t) => isGrafOf(t, unit)); +const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id); +/** First talent of a normalised token def holding `key` (token talents keep no index). */ +const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null)?.bb ?? null; +/** Every 屏障 that comes from `mcn` on `holder` (hers, or her token's: "视为来源于自身的持有者"). */ +const barrierLeft = (holder, mcn) => { + let s = 0; + for (const b of holder.buffs) if (b.data && b.data.mcnist === mcn && b.shield > 0) s += b.shield; + return s; +}; + +/** A 屏障 from `mcn` on `holder`: `ratio` × the holder's max HP, physical and arts only, until spent. */ +function grantBarrier(battle, mcn, holder, key, ratio) { + if (!(ratio > 0) || !holder.alive) return null; + battle.removeBuff(holder, key); + const buff = battle.addBuff(holder, { + key, shield: holder.s.maxHp * ratio, shieldType: BARRIER_TYPES, visible: true, source: mcn, tags: ['barrier'], + data: { mcnist: mcn }, + // spent by a hit (damage.js absorbShields removes it at 0): S2's break effect waits for the hit to resolve (`damaged`) + onRemove: ({ buff: b }) => { if (key === S2_KEY && !(b.shield > 1e-9)) holder.mem.mcnistBroken = true; }, + }); + battle.fx('shield', { x: holder.x, y: holder.y, id: holder.id }); + return buff; +} + +/** + * Where the charge stops: from the token's tile along its facing, the farthest tile before a tile it cannot cross (outside + * the field, not ground-passable, high ground, an enemy on it); then, back towards the token, the first of those tiles it + * can be deployed on (free, standable for a melee unit, low). Its own tile when it cannot move. + */ +function chargeEnd(battle, t) { + const [fr, fc] = dirVec(t.dir); + const path = [[t.tileR, t.tileC]]; + for (let k = 1; k < 32; k++) { + const r = t.tileR + fr * k, c = t.tileC + fc * k; + if (!battle.grid.inRect(r, c) || !battle.grid.groundPassable(r, c) || !battle.grid.isLow(r, c)) break; + if (battle.enemies.some((e) => e.alive && !e.hidden && Math.round(e.y) === r && Math.round(e.x) === c)) break; + path.push([r, c]); + } + const stop = path[path.length - 1]; + for (let i = path.length - 1; i > 0; i--) { + const [r, c] = path[i]; + if (!battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: false })) return { stop, land: [r, c] }; + } + return { stop, land: path[0] }; +} + +/** + * A 结构性原理 that left the field comes back on its tile its respawnTime later (her (re)deployment: at once — + * charge_token[born] sets readyAt), paying its cost, only while she stands; retried until it deploys. + */ +function scheduleReturn(battle, owner, t) { + t.mem.readyAt = battle.time + Math.max(0, num(t.base.respawnTime)); + if (t.mem.retry) return; + t.mem.retry = battle.every(RETRY, (b, sched) => { + const stop = () => { sched.cancel(); t.mem.retry = null; }; + if (t.alive || t.removed || b.finished) { stop(); return; } + if (!up(owner) || b.time + 1e-9 < t.mem.readyAt) return; + if (b.redeploy(t, { free: false })) stop(); + }, { owner: t }); +} + +/** + * The 结构性原理's kit (lifetime, 禁疗, 生命方程, S2's 屏障, the S3 charge and weaken aura, its redeploy). `owner` = 机械师, + * `life` = its lifetime, `t2` = 生命方程's hp_ratio, `b2` / `b3` = her S2 / S3 blackboards. + */ +function grafKit(owner, { life, t2, b2, b3, aura }) { + return { + skill: null, + talents: [], + install(battle, t) { + battle.addBuff(t, { key: 'mcnist:graf:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗 + battle.on('deploy', (c) => { + if (c.unit !== t) return; + const respawned = !!t.mem.chargeLanding; + t.mem.chargeLanding = false; + const seq = t.deploySeq; + if (life > 0) { + battle.after(life, () => { if (t.alive && t.deploySeq === seq) battle.retreat(t, { reason: 'expired', permanent: true }); }, { owner: t }); + } + grantBarrier(battle, owner, t, T2_KEY, t2); // 生命方程 + if (skillOn(owner, S2)) grantBarrier(battle, owner, t, S2_KEY, num(b2.hp_ratio)); // a deployment during S2 + if (skillOn(owner, S3)) { + t.mem.weakenFrom = battle.time; // the weaken aura of this deployment + // a deployment during S3 (not the charge's own landing) charges — the cast's charge kept until then — once + // the deployment has resolved (the next tick: ON_OWNER_LOCATE) + if (!respawned) battle.after(0, () => { if (t.alive && t.deploySeq === seq && skillOn(owner, S3)) chargeGraf(battle, owner, t, b3); }, { owner: t }); + } else t.mem.weakenFrom = null; + }, { owner: t }); + // the weaken aura: 虚弱 on the enemies within its radius, 2.5 % weaker every second, gone at 0 or when S3 ends + battle.every(AURA, () => { + if (!up(t) || t.mem.weakenFrom == null || !skillOn(owner, S3)) return; + const steps = Math.floor((battle.time - t.mem.weakenFrom) / aura.interval + 1e-9); + const v = Math.max(0, aura.start - aura.step * steps); + if (!(v > 0)) return; + for (const e of battle.foesInRadius(t.x, t.y, aura.radius, true)) { + battle.applyStatus(e, 'weaken', { duration: AURA_DUR, value: v, source: t }); + } + }, { owner: t }); + battle.on('death', (c) => { + if (c.unit !== t || battle.finished) return; + t.removed = false; // the piece stays: back on its tile once ready (or on the charge's landing tile) + if (!t.mem.charging) scheduleReturn(battle, owner, t); + }, { owner: t, priority: -10 }); + }, + }; +} + +/** S3: the 结构性原理 charges along its facing, strikes around the stop tile and is deployed anew there. */ +function chargeGraf(battle, mcn, t, b3) { + if (!up(t)) return; + const { stop, land } = chargeEnd(battle, t); + const dist = Math.hypot(stop[0] - t.tileR, stop[1] - t.tileC); + t.mem.charging = true; + battle.fx('charge', { x: t.x, y: t.y, id: t.id, tx: stop[1], ty: stop[0] }); + battle.retreat(t, { reason: 'expired', permanent: true }); + t.mem.charging = false; + const atk = mcn.s.atk, scale = num(b3.atk_scale, 3), radius = num(b3.projectile_range, 1.5); + battle.after(CHARGE_DELAY + dist / CHARGE_SPEED, () => { + battle.fx('aoe', { x: stop[1], y: stop[0], radius, id: mcn.id, skill: 'mcnist:charge' }); + for (const e of battle.foesInRadius(stop[1], stop[0], radius)) { + if (e.alive) battle.dealDamage(mcn, e, { amount: atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'mcnist:charge'] }); + } + if (t.alive || t.removed || battle.finished) return; + // she fell meanwhile (die_to_kill_token): no landing, it waits for her like any piece [ASSUMED] + if (!up(mcn)) { scheduleReturn(battle, mcn, t); return; } + t.mem.chargeLanding = true; + if (!battle.redeploy(t, { free: true, tile: land }) && !battle.redeploy(t, { free: true })) { + t.mem.chargeLanding = false; + scheduleReturn(battle, mcn, t); // nowhere to land: an ordinary wait for its redeploy [ASSUMED] + } + }, { owner: mcn }); +} + +/** S3's cross: every selectable enemy on the x-6 around (r, c) takes `atk` × scale arts, X6_DELAY s after the throw. */ +function throwCross(battle, unit, r, c, atk, scale) { + battle.fx('aoe', { x: c, y: r, radius: 2, id: unit.id, skill: 'mcnist:cross', tiles: X6.map(([dr, dc]) => [r + dr, c + dc]) }); + battle.after(X6_DELAY, () => { + const keys = X6.map(([dr, dc]) => (r + dr) * COLS + (c + dc)).filter((k) => k >= 0); + for (const e of battle.enemiesInKeys(keys, unit, { canHitFly: true })) { + if (e.alive) battle.dealDamage(unit, e, { amount: atk * scale, type: 'arts', isAttack: true, isSkill: true, tags: ['skill', TAG_CROSS] }); + } + }, { owner: unit }); +} + +export default { + char_4230_mcnist: (bb, chess) => { + const t1 = (chess?.talents ?? []).find((t) => t && t.index === 1)?.bb ?? {}; // 生命方程: hp_ratio + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s1Scale = num(b1['attack@atk_scale'], 1), s1Times = Math.max(1, Math.floor(num(b1['attack@times'], 5))); + const s1Iv = num(b1['attack@interval'], 0.2), s1Radius = num(b1['attack@projectile_range'], 1.1); + return { + skills: { + [S1]: { + kind: 'ammo', ammo: num(b1['attack@trigger_time'], 3), + mods: { batPct: batMod(b1.base_attack_time, chess) }, + attack: { + atkScale: s1Scale, splashRadius: s1Radius, + // the four strikes after the impact's, from the impact point (the target dead meanwhile: where it was) + onHit({ battle, unit, x, y }) { + const atk = unit.s.atk; + for (let i = 1; i < s1Times; i++) { + battle.after(s1Iv * i, () => { + for (const e of battle.foesInRadius(x, y, s1Radius)) { + if (e.alive) battle.dealDamage(unit, e, { amount: atk * s1Scale, type: 'phys', isAttack: true, isSplash: true, isSkill: true, tags: ['skill', 'mcnist:cluster'] }); + } + }, { owner: unit }); + } + }, + }, + }, + [S2]: { + kind: 'ammo', ammo: num(b2.trigger_time, 6), + mods: { atkPct: num(b2.atk) }, + onAttack(ctx) { ctx.noAmmo = true; }, // her bullets go on 屏障 only (CharSkillManualTriggerCountEvent) + onStart({ battle, unit }) { + grantBarrier(battle, unit, unit, S2_KEY, num(b2.hp_ratio)); + for (const t of grafsOf(battle, unit)) if (up(t)) grantBarrier(battle, unit, t, S2_KEY, num(b2.hp_ratio)); + }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) }, + // the attack is the cross: no damage on the target itself (its 0.8 s later landing does it) + attack: { dmgType: 'arts', hitsFn: () => 0 }, + onAttack({ battle, unit, targets }) { + const tg = (targets || []).find((e) => e && e.side === 'enemy'); + if (tg) throwCross(battle, unit, Math.round(tg.y), Math.round(tg.x), unit.s.atk, num(b3['attack@atk_scale'], 1)); + }, + onStart({ battle, unit }) { + const t = grafsOf(battle, unit).find(up); + if (t) chargeGraf(battle, unit, t, b3); // without one: its next deployment during the skill charges + }, + onEnd({ battle, unit }) { battle.setExtraRange(unit, null); }, + }, + }, + talents: [ + { install(battle, unit) { // 结构性原理: her pieces run the token's kit (set up before the battle starts) + const raw = battle.data.rawToken?.(MCGRAF)?.variants?.[unit.def?.tokenOwner] ?? null; + for (const t of battle.allyUnits) { + if (!isGrafOf(t, unit) || t.alive || t.deployed) continue; + // its life: the base variant's (the SO-A stage-3 skill@duration −1 is 集成战略-only) + const base = num(tokTalent(raw ? { talents: raw.talents } : null, 'skill@duration')?.['skill@duration'], NaN); + const merged = num(tokTalent(t.def, 'skill@duration')?.['skill@duration'], NaN); + const life = base > 0 ? base : merged > 0 ? merged : LIFE_FALLBACK; + const w = tokTalent(t.def, 'atk_interval') ?? {}; + const aura = { start: Math.abs(num(w.atk, -0.5)), step: Math.abs(num(w.atk_interval, 0.025)), interval: num(w.interval, 1) || 1, radius: num(w.range_radius, 1.5) }; + const t2 = num(tokTalent(t.def, 'hp_ratio')?.hp_ratio, num(t1.hp_ratio)); + if (t.kit) battle.offOwner(t); + battle._setupUnit(t, grafKit(unit, { life, t2, b2, b3, aura })); + } + // die_to_kill_token: it falls when she leaves; charge_token[born]: her (re)deployment readies a waiting one + battle.on('death', (c) => { + if (c.unit !== unit) return; + for (const t of grafsOf(battle, unit)) if (t.alive) battle.kill(t, null); + }, { owner: unit }); + battle.on('deploy', (c) => { + if (c.unit !== unit) return; + for (const t of grafsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time; + }, { owner: unit }); + } }, + { install(battle, unit) { // 生命方程: her own 屏障 at each deployment (the token's: its kit) + battle.on('deploy', (c) => { if (c.unit === unit) grantBarrier(battle, unit, unit, T2_KEY, num(t1.hp_ratio)); }, { owner: unit }); + } }, + ], + install(battle, unit) { + const holds = (h) => h === unit || isGrafOf(h, unit); + // 超额防护: the hit that breaks the last 屏障 of hers on a unit is cancelled whole (PRTS 生命方程 备注) + const before = new WeakMap(); + battle.on('hit', (c) => { + const h = c.target; + if (!holds(h) || !c.dmg || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return; + before.set(c.dmg, barrierLeft(h, unit)); + }, { owner: unit, priority: -1000 }); + // S2's break effect is running: the holder takes nothing meanwhile ("冷却期间强制格挡受到的所有伤害") + battle.on('hit', (c) => { if (holds(c.target) && c.target.mem.mcnistLock && c.dmg) c.dmg.cancel = true; }, { owner: unit, priority: 1000 }); + battle.on('hpDamage', (c) => { + if (!holds(c.target) || !(before.get(c.dmg) > 0)) return; + if (barrierLeft(c.target, unit) <= 1e-9) c.amount = 0; + }, { owner: unit }); + // S2: a 屏障 broken while the skill runs — the burst around its holder, then a bullet buys a new one + battle.on('damaged', (c) => { + const h = c.target; + if (!holds(h) || !h.mem.mcnistBroken) return; + h.mem.mcnistBroken = false; + const sk = unit.skill; + if (!skillOn(unit, S2) || !h.alive) return; + h.mem.mcnistLock = true; + try { + const r = num(b2.range_radius, 1.5); + battle.fx('aoe', { x: h.x, y: h.y, radius: r, id: unit.id, skill: 'mcnist:shieldBurst' }); + const dodged = new Set(); + const dh = battle.on('dodge', (d) => { if (d.source === unit && d.dmg?.tags?.includes('mcnist:shieldBurst')) dodged.add(d.target); }); + for (const e of battle.foesInRadius(h.x, h.y, r, true)) { + if (!e.alive) continue; + battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 2), type: 'arts', isSkill: true, tags: ['skill', 'mcnist:shieldBurst'] }); + if (e.alive && !dodged.has(e)) battle.applyStatus(e, 'tremble', { duration: num(b2.not_combat, 2), source: unit }); + } + battle.off(dh); + } finally { h.mem.mcnistLock = false; } + if (!sk || !sk.active || sk.id !== S2 || !(sk.ammoLeft > 0) || !h.alive) return; + sk.ammoLeft--; + battle.emit('ammoUsed', { unit, left: sk.ammoLeft, skill: sk }); + if (sk.ammoLeft <= 0) sk.end('ammo'); + grantBarrier(battle, unit, h, S2_KEY, num(b2.hp_ratio)); // the last bullet's 屏障 outlives the skill + }, { owner: unit, priority: -50 }); + // S3: the enemies her 结构性原理 blocks are her targets too + battle.on('tick', () => { + if (!skillOn(unit, S3)) return; + const keys = []; + for (const t of grafsOf(battle, unit)) { + if (!up(t)) continue; + for (const e of t.blocking) if (e.alive) keys.push(Math.round(e.y) * COLS + Math.round(e.x)); + } + const cur = unit.extraRangeKeys ?? []; + if (keys.length !== cur.length || keys.some((k, i) => k !== cur[i])) battle.setExtraRange(unit, keys.length ? keys : null); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/test/content/op_jesca2.test.js b/test/content/op_jesca2.test.js new file mode 100644 index 00000000..f2c8f17c --- /dev/null +++ b/test/content/op_jesca2.test.js @@ -0,0 +1,349 @@ +// test/content/op_jesca2.test.js — the 自选 operator kit of 涤火杰西卡 (char_1034_jesca2, 6★ 哨戒铁卫; kit +// server/sim/content/kits/ops/op-jesca2.js) and her summon 机动盾牌 (token_10032_jesca2_jckshd), fielded the production way (a +// DIY slot + its `diy` pick; the shield as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, +// no module) and elite (E2 Lv60, rank 7) with no module, SPT-X “私家英雄” or SPT-Y 未曾风化 at stage 1 (tier 5) / 3 (tier 6). +// Every number is read back from data/backups.json (the form of that slot status, the token's variant); the fidelity +// checklist of kits/README.md item by item. +// Run: node --test test/content/op_jesca2.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 JESCA = 'char_1034_jesca2'; +const SHIELD = 'token_10032_jesca2_jckshd'; +const FORMS = BACKUPS.units[JESCA].forms; +const TOKEN = BACKUPS.tokens[SHIELD]; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const SPTX = 'uniequip_002_jesca2', SPTY = 'uniequip_003_jesca2'; +const S1 = 'skchr_jesca2_1', S2 = 'skchr_jesca2_2', S3 = 'skchr_jesca2_3'; +const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusKey(tier, elite)]; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const variantOf = (tier, elite) => TOKEN.variants[`${JESCA}@${statusKey(tier, elite)}`]; +const tokenStats = (tier, elite, mod) => (elite && mod && variantOf(tier, elite).byModule?.[mod]?.stats) || variantOf(tier, elite).stats; +const tokenTalent = (tier, elite, mod) => ((elite && mod && variantOf(tier, elite).byModule?.[mod]?.talents) || variantOf(tier, elite).talents).find((t) => t.bb.duration != null).bb; +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: 200, range: 2, bat: 0.5 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SPTX, SPTY].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; +const K = (r, c) => r * 21 + c; +const sortN = (a) => [...a].sort((x, y) => x - y); +/** Her shield above her (she turns UP), beside her (the tile right of her: in front, no turn), behind her (she turns LEFT). */ +const ABOVE = { row: 11, col: 4 }, FRONT = { row: 10, col: 5 }, BEHIND = { row: 10, col: 3 }; + +/** A battle with 涤火杰西卡 as uid 1 at (10, 4) facing RIGHT (`shield`: her piece as uid 2), plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, shield = ABOVE, others = [], seed = 5, dp = 99 } = {}) { + const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: JESCA, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 4 }]; + if (shield) units.push({ uid: 2, kind: 'token', tokenId: SHIELD, ownerUid: 1, row: shield.row, col: shield.col }); + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'spGain', 'ammoUsed'], captureNoisy: true, + units: [...units, ...others], + }); + h.step(); + return { h, u: h.unit(1), t: shield ? h.unit(2) : null }; +} +const bombHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('jesca2:bomb')); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('涤火杰西卡 in every 自选 form: her operator kit (all three skills authored), stats + module attributes, 2-2 (SPT-Y: +1), blocks 3, ranged physical, hits air, 维多利亚, no 特质; her 机动盾牌 piece: the form\'s token stats, 禁疗, taunt 1, no attack, blocks 2', () => { + assert.equal(OPERATOR_KITS[JESCA], KITS[JESCA]); + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + for (const skill of [0, 1, 2]) { + const { h, u, t } = field({ tier, elite, mod, skill, shield: FRONT }); + 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], [JESCA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [3, 'ranged', 'phys', true, 1.2], `${label(f)}: 哨戒铁卫`); + const ext = mod === SPTY ? 1 : 0; + assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: 2-2${ext ? ' + 1' : ''}`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [['victoriaShip'], [], [SHIELD]], `${label(f)}: bonds / 特质 / summon`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); + const ts = tokenStats(tier, elite, mod); + assert.deepEqual([t.base.maxHp, t.base.def, t.base.atk, t.s.blockCnt, t.base.cost, t.base.respawnTime], [ts.maxHp, ts.def, 0, 2, 5, 30], `${label(f)}: shield stats`); + assert.deepEqual([t.s.flags.noHeal, t.s.taunt, t.profile.noAttack, t.alive], [true, tokenTalent(tier, elite, mod).taunt_level, true, true], `${label(f)}: 禁疗 / taunt / no attack`); + done(h); + } + } + // E2 Lv1 2589 / 433 / 522, E2 Lv60 3265 / 492 / 651; SPT-X +180 / +50 / +25 → +250 / +65 / +37, SPT-Y +180 / +52 → +325 / +83; + // the shield 3041 / 619, 3545 / 709, SPT-X stage 3 3925 / 788 and 60 s + 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.def], [2589, 433, 3265, 651]); + assert.deepEqual([modOf(5, SPTX).attr, modOf(6, SPTX).attr, modOf(5, SPTY).attr, modOf(6, SPTY).attr], [{ maxHp: 180, atk: 50, def: 25 }, { maxHp: 250, atk: 65, def: 37 }, { maxHp: 180, atk: 52 }, { maxHp: 325, atk: 83 }]); + assert.deepEqual([tokenStats(5, false).maxHp, tokenStats(5, true).maxHp, tokenStats(6, true, SPTX).maxHp, tokenStats(6, true, SPTX).def, tokenTalent(6, true, SPTX).duration, tokenTalent(5, true, SPTX).duration], [3041, 3545, 3925, 788, 60, 50]); +}); + +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(JESCA)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(JESCA), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: JESCA, skillIndex: 2, uniEquipId: SPTY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: JESCA, skillIndex: 2, uniEquipId: SPTY } } }); +}); + +test('T1 灵活应变: she turns to face her shield (her range turns with her) and back once it leaves; while it stands next to her she and the unit on the tile behind her DEF +15 % (孤立 too), nobody else', () => { + for (const f of [[5, false, null], [6, true, SPTX], [6, true, SPTY]]) { + const [tier, elite, mod] = f; + // shield above (11, 4): she faces UP; behind her is (9, 4); 德克萨斯 at (10, 3) was behind her RIGHT facing only + const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 9, col: 4 }, { uid: 4, chessId: 'chess_char_1_08_a', row: 10, col: 3 }]; + const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE, others }); + const yak = h.unit(3), texas = h.unit(4); + const d = talentOf(tier, elite, mod, 0).def; + assert.equal(d, 0.15); + assert.deepEqual([u.dir, t.dir], ['UP', 'UP'], `${label(f)}: she faces her shield, it faces away from her`); + const ext = mod === SPTY ? 1 : 0; + assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10 + i, 4))), `${label(f)}: her range turned UP`); + assert.deepEqual(u.findBuff('talent:jesca2:def')?.mods, { defPct: d }, `${label(f)}: herself`); + assert.deepEqual(yak.findBuff('talent:jesca2:def')?.mods, { defPct: d }, `${label(f)}: 角峰 behind her`); + assert.equal(texas.findBuff('talent:jesca2:def'), null, `${label(f)}: 德克萨斯 beside her`); + approx(u.s.def, u.base.def * (1 + d), `${label(f)}: her DEF`); + // 孤立 does not keep it off (ignoreAllyTargetFree) + h.b.addBuff(yak, { key: 'test:isolated', flags: { isolated: true } }); + h.run(0.5); + assert.ok(yak.findBuff('talent:jesca2:def'), `${label(f)}: a 孤立 unit behind her`); + // the shield leaves: she turns back to RIGHT, the aura ends + h.b.retreat(t, { reason: 'expired', permanent: true }); + h.run(0.5); + assert.deepEqual([u.dir, !!u.findBuff('talent:jesca2:def'), !!yak.findBuff('talent:jesca2:def')], ['RIGHT', false, false], `${label(f)}: back to RIGHT`); + assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: her range back`); + done(h); + } + // behind her: she turns LEFT; in front: no turn; on a tile not next to her: no turn, no aura (the prep's placement rule) + const b = field({ shield: BEHIND }); + assert.equal(b.u.dir, 'LEFT'); + assert.deepEqual(sortN(b.u.rangeKeys), sortN([K(10, 4), K(10, 3), K(10, 2)]), 'her range faces LEFT'); + done(b.h); + const fr = field({ shield: FRONT }); + assert.deepEqual([fr.u.dir, !!fr.u.findBuff('talent:jesca2:def')], ['RIGHT', true]); + done(fr.h); + const far = field({ shield: { row: 12, col: 4 } }); + assert.deepEqual([far.u.dir, !!far.u.findBuff('talent:jesca2:def')], ['RIGHT', false], 'a shield two tiles away'); + done(far.h); +}); + +test('T1 灵活应变: the shield lasts 50 s (SPT-X stage 3: 60 s), comes back on its tile 30 s after it left paying 5 DP — only while she stands; it falls with her and her redeployment readies it at once', () => { + for (const f of [[5, false, null], [6, true, SPTX], [5, true, SPTX]]) { + const [tier, elite, mod] = f; + const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE }); + const life = tokenTalent(tier, elite, mod).duration; + assert.equal(life, tier === 6 && mod === SPTX ? 60 : 50, label(f)); + h.run(life - 0.6); + assert.ok(t.alive, `${label(f)}: still there before ${life} s`); + h.run(0.7); + assert.ok(!t.alive && !t.removed, `${label(f)}: gone at ${life} s (the piece stays)`); + assert.equal(u.dir, 'RIGHT'); + const dp0 = h.b.players[0].dp; + h.run(29.5); + assert.ok(!t.alive, `${label(f)}: not before 30 s`); + h.run(0.7); + assert.ok(t.alive, `${label(f)}: back after 30 s`); + assert.deepEqual([t.tileR, t.tileC, u.dir], [11, 4, 'UP'], `${label(f)}: on its tile, she faces it again`); + approx(h.b.players[0].dp, dp0 - 5, `${label(f)}: 5 DP`); + // she falls ⇒ it falls; waiting while she is down; her redeployment brings it at once + h.b.kill(u, null); + h.step(); + assert.ok(!t.alive && !t.removed, `${label(f)}: falls with her`); + assert.equal(u.dir, 'RIGHT', `${label(f)}: her facing restored for her redeployment`); + h.run(40); + assert.ok(!t.alive, `${label(f)}: no shield while she is down`); + assert.ok(h.runUntil(() => u.alive, 60), `${label(f)}: she redeploys`); + const back = h.b.time; + assert.ok(h.runUntil(() => t.alive, 0.2), `${label(f)}: the shield with her (charge_token[born])`); + assert.ok(h.b.time - back < 0.15, `${label(f)}: at once`); + assert.equal(u.dir, 'UP'); + done(h); + } +}); + +test('T2 蓄能释放: each damage instance on the shield (next to her) gives her 1 SP with 50 % (SPT-Y stage 3: 60 % and its next redeploy −1 s per success, ≤ 14 s); a 流失 does not count', () => { + for (const f of [[5, false, null], [6, true, SPTY], [5, true, SPTY]]) { + const [tier, elite, mod] = f; + const tb = talentOf(tier, elite, mod, 1); + const y3 = tier === 6 && mod === SPTY; + assert.deepEqual([tb.prob, tb.sp, tb.respawn_time ?? 0, tb.respawn_time_max ?? 0], y3 ? [0.6, 1, 1, 14] : [0.5, 1, 0, 0], label(f)); + const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE, seed: 17 }); + // a shooter beside the shield and out of her range: it picks the shield (taunt 1), she cannot reach it + h.spawn('enemy_shooter', { pos: [12, 5] }); + h.run(40); + const hits = h.hooksOf('damaged').filter((c) => c.target === t && c.amount > 0).length; + const gains = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent'); + assert.ok(hits > 60, `${label(f)}: ${hits} hits`); + assert.ok(gains.every((c) => c.amount === 1), `${label(f)}: 1 SP each`); + const share = gains.length / hits; + assert.ok(Math.abs(share - tb.prob) < 0.12, `${label(f)}: ${gains.length} / ${hits} ≈ ${tb.prob}`); + assert.equal(t.mem.respawnCut, y3 ? Math.min(14, gains.length) : 0, `${label(f)}: redeploy cut`); + const n = gains.length; + h.b.loseHp(t, 10, { source: null }); + h.step(); + assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent').length, n, `${label(f)}: a 流失 gives nothing`); + if (y3) { + h.b.retreat(t, { reason: 'expired', permanent: true }); + approx(t.mem.readyAt, h.b.time + 30 - 14, `${label(f)}: back 30 − 14 s later`, 1e-6); + } + done(h); + } +}); + +test('S1 坚守阵线 (AUTO, SP_FULL): at full SP with no enemy, 持续时间无限; ATK / DEF +40 % / +55 %; her shield DEF +40 % / +55 % and +20 s of life once per deployment', () => { + for (const f of [[5, false, null], [6, true, SPTX]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S1); + const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE }); + assert.deepEqual([u.skill.rule, sk.trigger.rule, u.skill.kind, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb.duration], ['SP_FULL', 'DEFAULT', 'toggle', elite ? 90 : 100, elite ? 0.55 : 0.4, elite ? 0.55 : 0.4, 20], label(f)); + const life = tokenTalent(tier, elite, mod).duration; + assert.ok(h.runUntil(() => u.skill.active, sk.spCost + 1), `${label(f)}: cast at ${sk.spCost} s with no enemy`); + approx(h.b.time, sk.spCost, `${label(f)}: at full SP`, 0.02); + // the first shield expired before (life < cost) and came back at life + 30: it gets the 20 s now + assert.ok(t.alive); + h.step(); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + approx(u.s.def, u.base.def * (1 + sk.bb.def + 0.15), `${label(f)}: DEF (+15 % T1)`); + approx(t.s.def, t.base.def * (1 + sk.bb.def), `${label(f)}: shield DEF`); + approx(t.mem.lifeEnd - t.deployedAt, life + 20, `${label(f)}: shield life +20 s`); + h.run(200); + assert.ok(u.skill.active, `${label(f)}: still on 200 s later`); + done(h); + } +}); + +test('S2 掩蔽护卫 (MANUAL, data ACTIVE_RANGE — rawRule TAKE_DAMAGE): casts with an enemy on its 2-5 that her 2-2 does not reach; 15 s, range 2-5 (SPT-Y does not grow it), ATK +45 % / +60 %, interval 0.3 s, 75 % physical and arts dodge', () => { + for (const f of [[5, false, null], [6, true, SPTY], [5, true, SPTY]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S2); + const { h, u } = field({ tier, elite, mod, skill: 1, shield: null }); + assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.prob], ['ACTIVE_RANGE', 'ACTIVE_RANGE', 'TAKE_DAMAGE', 15, elite ? 0.6 : 0.45, -0.9, 0.75], label(f)); + u.skill.gainSp(999); + h.run(1); + assert.equal(u.skill.activations, 0, `${label(f)}: no enemy, no cast`); + // (11, 6): on the 2-5 (local [1, 2]), not on her 2-2 (+1) + h.spawn('enemy_dummy', { pos: [11, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast for an enemy only the skill range reaches`); + approx(u.skill.timeLeft, 15, label(f), 0.01); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: 2-5 exactly`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + approx(u.s.interval, 0.3, `${label(f)}: 1.2 − 0.9 s`); + assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts], [0.75, 0.75], `${label(f)}: dodge`); + h.runUntil(() => !u.skill.active, 20); + const ext = mod === SPTY ? 1 : 0; + assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: back to her own range`); + assert.deepEqual([u.s.dodgePhys, u.s.interval], [0, 1.2], `${label(f)}: all back`); + done(h); + } +}); + +test('S3 饱和迸射 (MANUAL, data ACTIVE_RANGE on 2-2 + 1): 20 bullets, range +1 (SPT-Y: +2), interval 1.8 s, ATK +240 % / +270 %, DEF +50 % / +65 %, shield DEF +100 % / +135 %; casts with an enemy only the larger range reaches', () => { + for (const f of [[5, false, null], [6, true, SPTY], [6, true, SPTX]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S3); + const { h, u, t } = field({ tier, elite, mod, skill: 2, shield: BEHIND }); + assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, u.skill.kind, u.skill.ammo], ['ACTIVE_RANGE', 'ACTIVE_RANGE', 'TAKE_DAMAGE', 'ammo', 20], label(f)); + assert.equal(u.dir, 'LEFT'); + const ext = mod === SPTY ? 1 : 0; + u.skill.gainSp(999); + // facing LEFT from (10, 4): her own range reaches col 4 − 2 − ext; one more tile only with the skill + h.spawn('enemy_dummy', { pos: [10, 1 - ext] }); + assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast`); + assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(4 + ext).keys()].map((i) => K(10, 4 - i))), `${label(f)}: +1 tile (${ext ? 'and SPT-Y\'s' : 'no module'})`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + approx(u.s.def, u.base.def * (1 + sk.bb['jesca2_s_3[def].def'] + 0.15), `${label(f)}: DEF (+15 % T1)`); + approx(u.s.interval, 1.8, `${label(f)}: 1.2 + 0.6 s`); + approx(t.s.def, t.base.def * (1 + sk.bb['jesca2_s_3_token[def].def']), `${label(f)}: shield DEF`); + assert.deepEqual([sk.bb.atk, sk.bb['jesca2_s_3[def].def'], sk.bb['jesca2_s_3_token[def].def']], elite ? [2.7, 0.65, 1.35] : [2.4, 0.5, 1], label(f)); + h.runUntil(() => !u.skill.active, 60); + assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 20, `${label(f)}: 20 bullets`); + assert.equal(t.findBuff('skill:jesca2:shield'), null, `${label(f)}: shield DEF back`); + done(h); + } +}); + +test('S3 shell: with her shield next to her at the cast it flies ahead at 5 tiles / s, bursts on the first enemy it touches: every enemy within 1.7 (air units too) takes 250 % ATK physical and a 5 s stun; no bullet spent; none without a shield, one when a shield comes during the skill, held while she is disarmed', () => { + for (const f of [[5, false, null], [6, true, SPTY]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.bb['attack@extrabomb.atk_scale'], sk.bb['attack@extrabomb.stun'], sk.bb['attack@extrabomb.projectile_range']], [2.5, 5, 1.7], label(f)); + const { h, u, t } = field({ tier, elite, mod, skill: 2, shield: FRONT }); + const near = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [11, 7] }); + const fly = h.spawn('enemy_fly', { pos: [11, 6] }), far = h.spawn('enemy_dummy', { pos: [12, 8] }); + u.skill.gainSp(999); + u.skill.activate('test'); + const atk = u.s.atk; + assert.ok(h.runUntil(() => bombHits(h, u).length > 0, 2), `${label(f)}: the shell lands`); + // from (10, 4) to the enemy at (10, 7): it touches it 0.5 before, at x 6.5, i.e. 2.5 tiles in 0.5 s + approx(h.hooksOf('damaged').find((c) => c.dmg?.tags?.includes('jesca2:bomb')).t, 0.5 + h.hooksOf('skillStart').find((c) => c.unit === u).t, `${label(f)}: flight time`, 0.1); + const hits = bombHits(h, u); + assert.deepEqual(sortN(hits.map((c) => c.target.id)), sortN([near.id, side.id, fly.id]), `${label(f)}: within 1.7, the flyer too`); + for (const c of hits) { + approx(c.amount, atk * 2.5, `${label(f)}: 250 % ATK`); + assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true], label(f)); + } + const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun'); + assert.deepEqual(sortN(stuns.map((c) => c.target.id)), sortN([near.id, side.id, fly.id]), `${label(f)}: stunned`); + for (const c of stuns) approx(c.duration, 5, `${label(f)}: 5 s`); + assert.ok(!far.findBuff('stun'), `${label(f)}: out of the burst`); + assert.equal(u.skill.ammoLeft + h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 20, `${label(f)}: the shell spends no bullet`); + h.run(3); + assert.equal(bombHits(h, u).length, hits.length, `${label(f)}: one shell per cast`); + // a shield coming during the skill fires another (her shield back at once) + h.b.retreat(t, { reason: 'expired', permanent: true }); + t.mem.readyAt = h.b.time; + assert.ok(h.runUntil(() => t.alive, 1), label(f)); + assert.ok(h.runUntil(() => bombHits(h, u).length > hits.length, 2), `${label(f)}: a second shell`); + done(h); + } + // no shield: no shell; disarmed: it waits + const a = field({ skill: 2, shield: null }); + a.h.spawn('enemy_dummy', { pos: [10, 6] }); + a.u.skill.gainSp(999); + a.u.skill.activate('test'); + a.h.run(3); + assert.equal(bombHits(a.h, a.u).length, 0, 'no shield: no shell'); + done(a.h); + const b = field({ skill: 2, shield: FRONT }); + b.h.spawn('enemy_dummy', { pos: [10, 7] }); + b.h.b.addBuff(b.u, { key: 'test:disarm', flags: { disarm: true }, duration: 2 }); + b.u.skill.gainSp(999); + b.u.skill.activate('test'); + b.h.run(1.5); + assert.equal(bombHits(b.h, b.u).length, 0, 'disarmed: held'); + assert.ok(b.h.runUntil(() => bombHits(b.h, b.u).length > 0, 2), 'fired once the disarm is gone'); + done(b.h); +}); + +test('modules: SPT-X reveals the stealthed enemies of her range (stages 1 and 3); SPT-Y is a permanent +1 range (her initial range, S3 on top, never S2); with no module neither', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, shield: null }); + const inR = h.spawn('enemy_dummy', { pos: [10, 6] }), outR = h.spawn('enemy_dummy', { pos: [10, 9] }); + for (const e of [inR, outR]) h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } }); + h.run(0.5); + assert.equal(!!inR.s.flags.reveal, elite && mod === SPTX, `${label(f)}: reveal in range`); + assert.equal(!!outR.s.flags.reveal, false, `${label(f)}: never out of range`); + const ext = elite && mod === SPTY ? 1 : 0; + assert.equal(u.s.baseRangeExtend, ext, `${label(f)}: permanent extension`); + assert.deepEqual(u.liveRangeGrid.length, 3 + ext, `${label(f)}: initial range`); + if (elite && mod) { + const m = modOf(tier, mod); + assert.ok(mod === SPTX ? /隐匿/.test(m.traitOverride.moduleDesc) : m.traitOverride.bb.ability_range_forward_extend === 1, `${label(f)}: the data`); + } + done(h); + } +}); diff --git a/test/content/op_kalts.test.js b/test/content/op_kalts.test.js new file mode 100644 index 00000000..6b8cd1da --- /dev/null +++ b/test/content/op_kalts.test.js @@ -0,0 +1,355 @@ +// test/content/op_kalts.test.js — the 自选 operator kit of 凯尔希 (char_003_kalts, 6★ 医师; kit +// server/sim/content/kits/ops/op-kalts.js) and her summon Mon3tr (token_10002_kalts_mon3tr), fielded the production way (a +// DIY slot + its `diy` pick; Mon3tr as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no +// module) and elite (E2 Lv60, rank 7) with no module, PHY-X Mon2tr, PHY-Y 医者 or ISW-A 凯尔希特限证章 at stage 1 (tier 5) / +// 3 (tier 6). Every number is read back from data/backups.json (the form of that slot status, the token's variant); the +// fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_kalts.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 KALTS = 'char_003_kalts'; +const MON3TR = 'token_10002_kalts_mon3tr'; +const FORMS = BACKUPS.units[KALTS].forms; +const TOKEN = BACKUPS.tokens[MON3TR]; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const PHYX = 'uniequip_002_kalts', PHYY = 'uniequip_003_kalts', ISWA = 'uniequip_004_kalts'; +const S1 = 'skchr_kalts_1', S2 = 'skchr_kalts_2', S3 = 'skchr_kalts_3'; +const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusKey(tier, elite)]; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const variantOf = (tier, elite) => TOKEN.variants[`${KALTS}@${statusKey(tier, elite)}`]; +const rattleOf = (tier, elite, mod) => ((elite && mod && variantOf(tier, elite).byModule?.[mod]?.talents) || variantOf(tier, elite).talents).find((t) => t.bb.value != null).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_biter: dummy('enemy_biter', { atk: 600, range: 1.2, bat: 1 }), + enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0, atk: 200, bat: 1 }), + enemy_weak: enemyRec({ key: 'enemy_weak', hp: 50, speed: 0, mass: 0 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PHYX, PHYY, ISWA].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** A battle with 凯尔希 as uid 1 at (10, 2) facing RIGHT (`mon3tr`: her piece as uid 2), plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, mon3tr = { row: 10, col: 4 }, others = [], seed = 5, dp = 99 } = {}) { + const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: KALTS, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 2 }]; + if (mon3tr) units.push({ uid: 2, kind: 'token', tokenId: MON3TR, ownerUid: 1, row: mon3tr.row, col: mon3tr.col }); + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'kill'], captureNoisy: true, + units: [...units, ...others], + }); + h.step(); + return { h, u: h.unit(1), m: mon3tr ? h.unit(2) : null }; +} +const heals = (h, u) => h.hooksOf('heal').filter((c) => c.source === u && !c.opts?.regen); +const rattles = (h, m) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes('kalts:rattle') && (c.source === m || c.credit === m)); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('凯尔希 in every 自选 form: her operator kit (all three skills authored), stats + module attributes, 3-10, one heal per attack, blocks 1, 罗德岛, no 特质; her Mon3tr: the form\'s token stats, 禁疗, 1-1, blocks 3, melee ground-only physical', () => { + assert.equal(OPERATOR_KITS[KALTS], KITS[KALTS]); + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + for (const skill of [0, 1, 2]) { + const { h, u, m } = field({ tier, elite, mod, skill }); + const form = formOf(tier, elite), md = elite ? modOf(tier, mod) : null; + assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [KALTS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], + [form.stats.maxHp + (md?.attr.maxHp ?? 0), form.stats.atk + (md?.attr.atk ?? 0), form.stats.def + (md?.attr.def ?? 0), form.stats.aspd + (md?.attr.aspd ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.profile.dmgType, u.profile.heal, u.s.blockCnt, u.base.bat], ['heal', { mode: 'single' }, 1, 2.85], `${label(f)}: 医师`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-10`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [BACKUPS.diy.operators[KALTS].bonds, [], [MON3TR]], `${label(f)}: bonds / 特质 / summon`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`); + const ts = variantOf(tier, elite).stats; + assert.deepEqual([m.base.maxHp, m.base.atk, m.base.def, m.s.blockCnt, m.base.bat, m.base.cost, m.base.respawnTime], [ts.maxHp, ts.atk, ts.def, 3, 2, 10, 25], `${label(f)}: Mon3tr stats`); + assert.deepEqual([m.s.flags.noHeal, m.profile.attack, m.profile.dmgType, m.profile.canHitFly, m.liveRangeGrid], [true, 'melee', 'phys', false, [[0, 0], [0, 1]]], `${label(f)}: Mon3tr 禁疗 / melee ground-only`); + done(h); + } + } + // E2 Lv1 1469 / 392 / 172, E2 Lv60 1578 / 457 / 201; PHY-X +35 / +15 → +60 / +25 ATK / DEF, PHY-Y +115 / +5 → +195 / +7 + // HP / ASPD, ISW-A +24 / +5 → +50 / +7 ATK / ASPD; Mon3tr 4292 / 1149 / 336 → 5048 / 1317 / 382 + 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], [1469, 392, 1578, 457]); + assert.deepEqual([modOf(5, PHYX).attr, modOf(6, PHYX).attr, modOf(5, PHYY).attr, modOf(6, PHYY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr], + [{ atk: 35, def: 15 }, { atk: 60, def: 25 }, { maxHp: 115, aspd: 5 }, { maxHp: 195, aspd: 7 }, { atk: 24, aspd: 5 }, { atk: 50, aspd: 7 }]); + assert.deepEqual([variantOf(5, false).stats.maxHp, variantOf(5, false).stats.atk, variantOf(6, true).stats.maxHp], [4292, 1149, 5048]); +}); + +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(KALTS)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(KALTS), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: KALTS, skillIndex: 1, uniEquipId: PHYX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: KALTS, skillIndex: 1, uniEquipId: PHYX } } }); +}); + +test('T1 heal order: herself and her Mon3tr first when injured (the lower HP ratio; ties: herself), else the lowest ratio; her heals pass Mon3tr\'s 禁疗, another medic never heals it; with no module an operator Mon3tr is first too', () => { + const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 9, col: 5 }, { uid: 4, chessId: 'chess_char_2_02_a', row: 11, col: 3 }]; + const { h, u, m } = field({ skill: 0, others }); + const yak = h.unit(3), silent = h.unit(4); + const at = (x, r) => { x.hp = x.s.maxHp * r; }; + at(yak, 0.3); at(m, 0.6); + const n0 = heals(h, u).length; + assert.ok(h.runUntil(() => heals(h, u).length > n0, 4), 'a heal'); + assert.equal(heals(h, u)[n0].target, m, 'Mon3tr (60 %) before 角峰 (30 %)'); + approx(heals(h, u)[n0].amount, u.s.atk, 'a full heal through its 禁疗'); + assert.equal(h.hooksOf('heal').filter((c) => c.source === silent && c.target === m).length, 0, '赫默 never heals Mon3tr'); + // herself at the same ratio: herself + at(m, 0.5); at(u, 0.5); + const n1 = heals(h, u).length; + assert.ok(h.runUntil(() => heals(h, u).length > n1, 4)); + assert.equal(heals(h, u)[n1].target, u, 'a tie: herself'); + // both full: the lowest ratio of the rest + at(m, 1); at(u, 1); at(yak, 0.2); + const n2 = heals(h, u).length; + assert.ok(h.runUntil(() => heals(h, u).length > n2, 4)); + assert.equal(heals(h, u)[n2].target, yak, 'then 角峰'); + done(h); + // the operator Mon3tr (char_4179_monstr): first with no module, an ordinary target with one + for (const [elite, mod, first] of [[false, null, true], [true, PHYY, false]]) { + const opM = [{ uid: 3, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_4179_monstr', skillIndex: 0 }, row: 9, col: 4, dir: 'LEFT' }, + { uid: 4, chessId: 'chess_char_1_02_a', row: 11, col: 4 }]; + const r = field({ tier: 6, elite, mod, skill: 0, mon3tr: null, others: opM }); + const om = r.h.unit(3), y = r.h.unit(4); + assert.equal(om.def.charId, 'char_4179_monstr'); + om.hp = om.s.maxHp * 0.7; y.hp = y.s.maxHp * 0.4; + const k0 = heals(r.h, r.u).length; + assert.ok(r.h.runUntil(() => heals(r.h, r.u).length > k0, 4)); + assert.equal(heals(r.h, r.u)[k0].target, first ? om : y, `${mod ?? 'no module'}: ${first ? 'the operator Mon3tr' : '角峰'} first`); + done(r.h); + } +}); + +test('T1 Mon3tr: DEF ×0 off her range (PHY-Y stage 3: inside ASPD +20, DEF +20 %); withdrawn when she leaves (no blast), back 25 s after it left paying 10 DP, only while she stands; her redeployment readies it at once', () => { + for (const f of [[5, false, null], [6, true, PHYY], [5, true, PHYY]]) { + const [tier, elite, mod] = f; + const t0 = (elite && mod && modOf(tier, mod).talentChanges.find((x) => x.talentIndex === 0)?.bb) || formOf(tier, elite).talents.find((x) => x.index === 0).bb; + const y3 = tier === 6 && mod === PHYY; + assert.deepEqual([t0.def, t0.attack_speed], y3 ? [0.2, 20] : [0, 0], label(f)); + const { h, u, m } = field({ tier, elite, mod, skill: 0 }); + approx(m.s.def, m.base.def * (1 + t0.def), `${label(f)}: in her range`); + approx(m.s.aspd, m.base.aspd + t0.attack_speed, `${label(f)}: ASPD`); + const off = field({ tier, elite, mod, skill: 0, mon3tr: { row: 10, col: 1 } }); // behind her: off her 3-10 + assert.equal(off.m.s.def, 0, `${label(f)}: DEF 0 off her range`); + approx(off.m.s.aspd, off.m.base.aspd, `${label(f)}: no bonus off her range`); + done(off.h); + // she leaves: it is withdrawn (no 不毁重构), then waits for her + h.spawn('enemy_dummy', { pos: [10, 5] }); + h.b.kill(u, null); + h.step(); + assert.ok(!m.alive && !m.removed, `${label(f)}: withdrawn with her`); + assert.equal(h.hooksOf('death').find((c) => c.unit === m)?.reason, 'retreat', label(f)); + assert.equal(rattles(h, m).length, 0, `${label(f)}: no blast on a withdrawal`); + assert.ok(h.runUntil(() => u.alive, 90), `${label(f)}: she redeploys`); + const back = h.b.time; + assert.ok(h.runUntil(() => m.alive, 0.2), `${label(f)}: Mon3tr at once`); + assert.ok(h.b.time - back < 0.15, label(f)); + // killed: back 25 s later paying 10 DP + const dp0 = h.b.players[0].dp; + h.b.kill(m, null); + h.run(24.5); + assert.ok(!m.alive, `${label(f)}: not before 25 s`); + h.run(0.7); + assert.ok(m.alive, `${label(f)}: back`); + approx(h.b.players[0].dp, dp0 - 10, `${label(f)}: 10 DP`); + done(h); + } +}); + +test('T2 不毁重构: Mon3tr knocked out ⇒ every enemy of its 3×3 (air units too) takes 1200 true damage (溅射) and a 3 s stun; PHY-X stage 3: 1500 / 3.5 s, and once per deployment when a hit leaves it at or below half HP', () => { + for (const f of [[5, false, null], [6, true, PHYX], [5, true, PHYX], [6, true, PHYY]]) { + const [tier, elite, mod] = f; + const rb = rattleOf(tier, elite, mod); + const x3 = tier === 6 && mod === PHYX; + assert.deepEqual([rb.value, rb.stun, rb.hp_ratio ?? 0], x3 ? [1500, 3.5, 0.5] : [1200, 3, 0], label(f)); + const { h, m } = field({ tier, elite, mod, skill: 0 }); + const e1 = h.spawn('enemy_dummy', { pos: [11, 5] }), e2 = h.spawn('enemy_fly', { pos: [9, 3] }), far = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.step(); // the enemies on the tile index + if (x3) { + // a hit to 40 %: the half-HP blast, once + h.b.dealDamage(null, m, { amount: m.s.maxHp * 0.6, type: 'true' }); + assert.equal(rattles(h, m).length, 2, `${label(f)}: the half-HP blast`); + h.b.dealDamage(null, m, { amount: 1, type: 'true' }); + assert.equal(rattles(h, m).length, 2, `${label(f)}: once per deployment`); + } + const n0 = rattles(h, m).length; + h.b.dealDamage(null, m, { amount: 1e9, type: 'true' }); + assert.ok(!m.alive, label(f)); + const r = rattles(h, m).slice(n0); + assert.deepEqual(r.map((c) => c.target.id).sort(), [e1.id, e2.id].sort(), `${label(f)}: its 3×3, the flyer too`); + for (const c of r) { approx(c.amount, rb.value, `${label(f)}: ${rb.value} true`); assert.deepEqual([c.type, c.dmg.isSplash], ['true', true]); } + const st = h.hooksOf('statusApplied').filter((c) => c.status === 'stun' && c.target !== m); + assert.ok(st.length >= 2 && st.every((c) => Math.abs(c.duration - rb.stun) < 1e-6), `${label(f)}: ${rb.stun} s`); + assert.ok(!far.findBuff('stun'), `${label(f)}: two tiles away`); + done(h); + } +}); + +test('S1 指令:结构加固 (MANUAL, data DEFAULT — cast as she is about to heal): 33 / 36 s, her DEF +90 % / +120 % and Mon3tr\'s; 物理格挡 30 % / 40 %: a physical instance blocked whole (arts never)', () => { + for (const f of [[5, false, null], [6, true, PHYX]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S1); + assert.deepEqual([sk.trigger.rule, sk.duration, sk.bb.def, sk.bb['attack@def'], sk.bb.prob], ['DEFAULT', elite ? 36 : 33, elite ? 1.2 : 0.9, elite ? 1.2 : 0.9, elite ? 0.4 : 0.3], label(f)); + const { h, u, m } = field({ tier, elite, mod, skill: 0, seed: 23 }); + u.skill.gainSp(999); + h.run(3); + assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured, no cast`); + m.hp = m.s.maxHp * 0.5; + assert.ok(h.runUntil(() => u.skill.active, 4), `${label(f)}: cast on her heal`); + approx(u.skill.timeLeft, sk.duration, label(f), 0.01); + approx(u.s.def, u.base.def * (1 + sk.bb.def), `${label(f)}: her DEF`); + h.step(); + approx(m.s.def, m.base.def * (1 + sk.bb['attack@def']), `${label(f)}: Mon3tr DEF`); + let blocked = 0; + const N = 400; + for (let i = 0; i < N; i++) { + if (h.b.dealDamage(null, u, { amount: 1000, type: 'phys', canDodge: false }) === 0) blocked++; + u.hp = u.s.maxHp; + } + assert.ok(Math.abs(blocked / N - sk.bb.prob) < 0.07, `${label(f)}: ${blocked} / ${N} ≈ ${sk.bb.prob}`); + let artsBlocked = 0; + for (let i = 0; i < 100; i++) { if (h.b.dealDamage(null, u, { amount: 10, type: 'arts', canDodge: false }) === 0) artsBlocked++; u.hp = u.s.maxHp; } + assert.equal(artsBlocked, 0, `${label(f)}: arts never`); + h.runUntil(() => !u.skill.active, 40); + h.step(); + approx(m.s.def, m.base.def, `${label(f)}: Mon3tr DEF back`); + done(h); + } +}); + +test('S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +45 / +60, Mon3tr ATK +35 % / +50 % and each attack strikes every enemy it blocks; no Mon3tr: no cast, SP emptied and frozen; it leaving ends the skill', () => { + for (const f of [[5, false, null], [6, true, PHYY]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.trigger.rule, sk.duration, sk.bb.attack_speed, sk.bb['attack@atk']], ['DEFAULT', elite ? 17 : 16, elite ? 60 : 45, elite ? 0.5 : 0.35], label(f)); + const { h, u, m } = field({ tier, elite, mod, skill: 1, mon3tr: { row: 9, col: 5 } }); + const w1 = h.spawn('enemy_walk', { pos: [9, 6], routeIndex: 0 }), w2 = h.spawn('enemy_walk', { pos: [9, 7], routeIndex: 0 }); + assert.ok(h.runUntil(() => m.blocking.length === 2, 20), `${label(f)}: Mon3tr blocks both`); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 6), `${label(f)}: cast on a heal of Mon3tr`); + approx(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `${label(f)}: her ASPD`); + h.step(); + approx(m.s.atk, m.base.atk * (1 + sk.bb['attack@atk']), `${label(f)}: Mon3tr ATK`); + const n0 = h.hooksOf('damaged').length; + h.run(4.5); + const hits = h.hooksOf('damaged').slice(n0).filter((c) => c.source === m && c.dmg?.isAttack); + const ids = new Map(); + for (const c of hits) ids.set(c.dmg.attackId, (ids.get(c.dmg.attackId) ?? new Set()).add(c.target.id)); + assert.ok(ids.size >= 2 && [...ids.values()].every((s) => s.size === 2 && s.has(w1.id) && s.has(w2.id)), `${label(f)}: every attack on both`); + // Mon3tr killed: the skill ends within 0.1 s, the SP is emptied and recovers no more until it is back + h.b.kill(m, null); + h.run(0.15); + assert.ok(!u.skill.active, `${label(f)}: ended (绑定)`); + assert.equal(u.skill.sp, 0, label(f)); + h.run(5); + assert.equal(u.skill.sp, 0, `${label(f)}: no SP without Mon3tr`); + assert.ok(u.findBuff('kalts:binding'), label(f)); + m.mem.readyAt = h.b.time; + assert.ok(h.runUntil(() => m.alive, 1), label(f)); + h.run(2); + assert.ok(u.skill.sp > 1.5, `${label(f)}: SP again with Mon3tr back`); + done(h); + } + // no Mon3tr piece at all: S2 never gains SP, never casts + const { h, u } = field({ skill: 1, mon3tr: null, others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }] }); + h.unit(3).hp = 100; + h.run(30); + assert.deepEqual([u.skill.sp, u.skill.activations], [0, 0], 'no Mon3tr: no SP'); + done(h); +}); + +test('S3 指令:熔毁 (MANUAL, data DEFAULT, 绑定 Mon3tr, 18 s): Mon3tr DEF +120 % / +140 %, ATK +160 % / +190 % down by 1/18 every second, true damage; no kill ⇒ 50 % max HP 流失 at the end (it may knock it out: 不毁重构); a kill ⇒ none', () => { + for (const f of [[5, false, null], [6, true, ISWA]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.trigger.rule, sk.duration, sk.bb['attack@atk'], sk.bb['attack@def'], sk.bb['attack@hp_ratio']], ['DEFAULT', 18, elite ? 1.9 : 1.6, elite ? 1.4 : 1.2, 0.5], label(f)); + const { h, u, m } = field({ tier, elite, mod, skill: 2 }); + const e = h.spawn('enemy_biter', { pos: [10, 5] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 6), `${label(f)}: cast`); + const cast = h.hooksOf('skillStart').find((c) => c.unit === u).t; + h.step(); + approx(m.s.def, m.base.def * (1 + sk.bb['attack@def']), `${label(f)}: DEF`); + approx(m.s.atk, m.base.atk * (1 + sk.bb['attack@atk']), `${label(f)}: ATK at the cast`); + h.run(cast + 5.5 - h.b.time); + approx(m.s.atk, m.base.atk * (1 + sk.bb['attack@atk'] * (1 - 5 / 18)), `${label(f)}: 5 s on`); + const hits = h.hooksOf('damaged').filter((c) => c.source === m && c.target === e && c.dmg?.isAttack && c.t >= cast); + assert.ok(hits.length > 0 && hits.every((c) => c.type === 'true'), `${label(f)}: true damage`); + assert.ok(h.runUntil(() => !u.skill.active, 15), label(f)); + assert.equal(h.hooksOf('skillEnd').find((c) => c.unit === u)?.reason, 'duration', label(f)); + const loss = h.hooksOf('damaged').filter((c) => c.target === m && c.dmg?.tags?.includes('kalts:meltdown')); + assert.equal(loss.length, 1, `${label(f)}: the 流失 (it killed nothing: the biter has 1e9 HP)`); + approx(loss[0].amount, m.s.maxHp * sk.bb['attack@hp_ratio'], `${label(f)}: 50 % max HP`); + assert.ok(loss[0].dmg.tags.includes('hpLoss'), `${label(f)}: a 流失`); + h.step(); + approx(m.s.atk, m.base.atk, `${label(f)}: ATK back`); + done(h); + } + // the loss knocks it out: 不毁重构 + { + const { h, u, m } = field({ skill: 2 }); + h.spawn('enemy_dummy', { pos: [10, 5] }); + const near = h.spawn('enemy_dummy', { pos: [11, 4] }); + m.hp = m.s.maxHp * 0.3; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 6)); + // her heals keep it up meanwhile: set it low just before the end + assert.ok(h.runUntil(() => u.skill.timeLeft < 0.05, 20)); + m.hp = m.s.maxHp * 0.3; + assert.ok(h.runUntil(() => !u.skill.active, 1)); + assert.ok(!m.alive, 'knocked out by the 流失'); + assert.equal(h.hooksOf('death').find((c) => c.unit === m)?.reason, 'killed'); + assert.ok(rattles(h, m).some((c) => c.target === near), '不毁重构'); + done(h); + } + // a kill keeps its HP + { + const { h, u, m } = field({ skill: 2 }); + h.spawn('enemy_weak', { pos: [10, 5] }); + m.hp = m.s.maxHp * 0.9; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 6)); + assert.ok(h.runUntil(() => h.hooksOf('kill').some((c) => c.killer === m), 10), 'Mon3tr kills'); + assert.ok(h.runUntil(() => !u.skill.active, 20)); + assert.equal(h.hooksOf('damaged').filter((c) => c.target === m && c.dmg?.tags?.includes('kalts:meltdown')).length, 0, 'no 流失 after a kill'); + done(h); + } +}); + +test('modules: PHY-X ×1.15 on a heal of a target at or below 50 %; PHY-Y ×1.15 on a heal of a target on a 地面 tile; ISW-A (集成战略-only trait) and no module: plain heals', () => { + for (const f of [[5, true, PHYX], [6, true, PHYX], [5, true, PHYY], [6, true, PHYY], [6, true, ISWA], [6, true, null]]) { + const [tier, elite, mod] = f; + const md = modOf(tier, mod); + if (md) assert.deepEqual([md.traitOverride.bb.heal_scale, md.traitOverride.bb.hp_ratio ?? null], [1.15, mod === PHYY ? null : 0.5], label(f)); + // 角峰 on a 地面 tile (10, 4), 德克萨斯 on high ground (11, 2) + const { h, u } = field({ tier, elite, mod, skill: 0, mon3tr: null, others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }, { uid: 4, chessId: 'chess_char_1_08_a', row: 11, col: 2 }] }); + const yak = h.unit(3), tex = h.unit(4); + assert.deepEqual([yak.ground, tex.ground], [true, false]); + const healOf = (x, r) => { + for (const o of [yak, tex]) o.hp = o.s.maxHp; + x.hp = x.s.maxHp * r; + const n = heals(h, u).length; + assert.ok(h.runUntil(() => heals(h, u).length > n, 4), label(f)); + return heals(h, u)[n]; + }; + const a = healOf(yak, 0.45), b = healOf(yak, 0.8), c = healOf(tex, 0.45); + const atk = u.s.atk; + const want = (t, r) => atk * ((mod === PHYX && r <= 0.5) || (mod === PHYY && t.ground) ? 1.15 : 1); + approx(a.amount, want(yak, 0.45), `${label(f)}: 地面, 45 %`); + approx(b.amount, want(yak, 0.8), `${label(f)}: 地面, 80 %`); + approx(c.amount, want(tex, 0.45), `${label(f)}: high ground, 45 %`); + done(h); + } +}); diff --git a/test/content/op_mcnist.test.js b/test/content/op_mcnist.test.js new file mode 100644 index 00000000..259f58d6 --- /dev/null +++ b/test/content/op_mcnist.test.js @@ -0,0 +1,340 @@ +// test/content/op_mcnist.test.js — the 自选 operator kit of 机械师 (char_4230_mcnist, 6★ 哨戒铁卫; kit +// server/sim/content/kits/ops/op-mcnist.js) and her summon 结构性原理 (token_10069_mcnist_mcgraf), fielded the production way +// (a DIY slot + its `diy` pick; the summon as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank +// 4, no module) and elite (E2 Lv60, rank 7) with no module or SO-A 机械师特勤证章 at stage 1 (tier 5) / 3 (tier 6). Every +// number is read back from data/backups.json (the form of that slot status, the token's variant); the fidelity checklist +// of kits/README.md item by item. +// Run: node --test test/content/op_mcnist.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 MCN = 'char_4230_mcnist'; +const GRAF = 'token_10069_mcnist_mcgraf'; +const FORMS = BACKUPS.units[MCN].forms; +const TOKEN = BACKUPS.tokens[GRAF]; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const SOA = 'uniequip_002_mcnist'; +const S1 = 'skchr_mcnist_1', S2 = 'skchr_mcnist_2', S3 = 'skchr_mcnist_3'; +const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusKey(tier, elite)]; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const variantOf = (tier, elite) => TOKEN.variants[`${MCN}@${statusKey(tier, elite)}`]; +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { + enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + enemy_brute: dummy('enemy_brute', { atk: 2000, range: 3, bat: 1 }), enemy_poke: dummy('enemy_poke', { atk: 1, range: 3, bat: 0.5 }), + enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SOA].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; +const K = (r, c) => r * 21 + c; +const sortN = (a) => [...a].sort((x, y) => x - y); + +/** A battle with 机械师 as uid 1 at (10, 3) facing RIGHT (`graf`: her 结构性原理 as uid 2 at graf.row / col), plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, graf = { row: 10, col: 4 }, others = [], seed = 5, dp = 99 } = {}) { + const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: MCN, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 3 }]; + if (graf) units.push({ uid: 2, kind: 'token', tokenId: GRAF, ownerUid: 1, row: graf.row, col: graf.col, dir: graf.dir }); + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'ammoUsed'], captureNoisy: true, + units: [...units, ...others], + }); + h.step(); + return { h, u: h.unit(1), t: graf ? h.unit(2) : null }; +} +const barrier = (x, key) => x.findBuff(key); +const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes(tag)); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('机械师 in every 自选 form: her operator kit (all three skills authored), stats + SO-A attributes, 2-2, blocks 3, ranged physical, hits air, 罗德岛, no 特质; her 结构性原理: the form\'s token stats, 禁疗, blocks 3, melee ground-only physical', () => { + assert.equal(OPERATOR_KITS[MCN], KITS[MCN]); + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + for (const skill of [0, 1, 2]) { + const { h, u, t } = 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], [MCN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp, form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [3, 'ranged', 'phys', true, 1.2], `${label(f)}: 哨戒铁卫`); + assert.deepEqual(sortN(u.rangeKeys), sortN([K(10, 3), K(10, 4), K(10, 5)]), `${label(f)}: 2-2`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [BACKUPS.diy.operators[MCN].bonds, [], [GRAF]], `${label(f)}: bonds / 特质 / summon`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); + const ts = variantOf(tier, elite).stats; + assert.deepEqual([t.base.maxHp, t.base.atk, t.base.def, t.s.blockCnt, t.base.bat, t.base.cost, t.base.respawnTime], [ts.maxHp, ts.atk, ts.def, 3, 1.5, 10, 40], `${label(f)}: 结构性原理 stats`); + assert.deepEqual([t.s.flags.noHeal, t.profile.attack, t.profile.dmgType, t.profile.canHitFly, t.profile.noAttack], [true, 'melee', 'phys', false, false], `${label(f)}: 禁疗, melee ground-only`); + done(h); + } + } + // E2 Lv1 2614 / 425 / 514, E2 Lv60 3288 / 483 / 641; SO-A +60 / +40 → +100 / +80 ATK / DEF; the token 2800 / 492 / 451 → 3264 / 564 / 517 + 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.def], [2614, 425, 3288, 641]); + assert.deepEqual([modOf(5, SOA).attr, modOf(6, SOA).attr], [{ atk: 60, def: 40 }, { atk: 100, def: 80 }]); + assert.deepEqual([variantOf(5, false).stats.maxHp, variantOf(6, true).stats.maxHp], [2800, 3264]); +}); + +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(MCN)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MCN), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: MCN, skillIndex: 2, uniEquipId: null } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: MCN, skillIndex: 2, uniEquipId: null } } }); +}); + +test('T1 结构性原理: it lasts 30 s from each deployment (SO-A stage 3 too — its 无限 is 集成战略-only), comes back on its tile 40 s after it left paying 10 DP — only while she stands; it falls with her and her redeployment readies it at once', () => { + for (const f of [[5, false, null], [6, true, SOA], [5, true, SOA]]) { + const [tier, elite, mod] = f; + const { h, u, t } = field({ tier, elite, mod, skill: 0 }); + assert.equal(variantOf(tier, elite).talents.find((x) => x.bb['skill@duration'] != null).bb['skill@duration'], 30); + h.run(29.4); + assert.ok(t.alive, `${label(f)}: on the field before 30 s`); + h.run(0.7); + assert.ok(!t.alive && !t.removed, `${label(f)}: gone at 30 s (the piece stays)`); + const dp0 = h.b.players[0].dp; + h.run(39.4); + assert.ok(!t.alive, `${label(f)}: not before 40 s`); + h.run(0.7); + assert.ok(t.alive, `${label(f)}: back after 40 s`); + assert.deepEqual([t.tileR, t.tileC], [10, 4], label(f)); + approx(h.b.players[0].dp, dp0 - 10, `${label(f)}: 10 DP`); + h.b.kill(u, null); + h.step(); + assert.ok(!t.alive && !t.removed, `${label(f)}: falls with her`); + h.run(45); + assert.ok(!t.alive, `${label(f)}: none while she is down`); + assert.ok(h.runUntil(() => u.alive, 60), `${label(f)}: she redeploys`); + assert.ok(h.runUntil(() => t.alive, 0.2), `${label(f)}: with it at once (charge_token[born])`); + done(h); + } +}); + +test('T2 生命方程: she and her 结构性原理 get a 屏障 of 30 % max HP at each deployment; it takes physical and arts damage, not true; 超额防护: the hit that breaks it is cancelled whole, the next one hurts', () => { + for (const f of [[5, false, null], [6, true, SOA]]) { + const [tier, elite, mod] = f; + const { h, u, t } = field({ tier, elite, mod, skill: 0 }); + const hr = formOf(tier, elite).talents.find((x) => x.index === 1).bb.hp_ratio; + assert.equal(hr, 0.3); + for (const x of [u, t]) { + const b = barrier(x, 'mcnist:t2:barrier'); + approx(b.shield, x.s.maxHp * hr, `${label(f)}: ${x.kind} 屏障`); + assert.deepEqual(b.shieldType, ['phys', 'arts'], label(f)); + } + // true damage passes it; a physical hit bigger than it is cancelled whole; the next physical hit hurts + const hp0 = u.hp, sh0 = barrier(u, 'mcnist:t2:barrier').shield; + h.b.dealDamage(null, u, { amount: 100, type: 'true' }); + approx(u.hp, hp0 - 100, `${label(f)}: true damage reaches HP`); + approx(barrier(u, 'mcnist:t2:barrier').shield, sh0, `${label(f)}: the 屏障 untouched`); + h.b.dealDamage(null, u, { amount: 300, type: 'arts' }); + approx(barrier(u, 'mcnist:t2:barrier').shield, sh0 - 300, `${label(f)}: arts absorbed`); + const hp1 = u.hp; + const got = h.b.dealDamage(null, u, { amount: sh0 * 3, type: 'phys', canDodge: false }); + assert.equal(got, 0, `${label(f)}: the breaking hit is blocked whole`); + assert.deepEqual([u.hp, barrier(u, 'mcnist:t2:barrier')], [hp1, null], `${label(f)}: HP untouched, 屏障 gone`); + const got2 = h.b.dealDamage(null, u, { amount: 500, type: 'phys', canDodge: false }); + approx(got2, Math.max(500 - u.s.def, 25), `${label(f)}: the next hit hurts`); + // redeployed: a new one + h.b.kill(u, null); + assert.ok(h.runUntil(() => u.alive, 80), label(f)); + approx(barrier(u, 'mcnist:t2:barrier').shield, u.s.maxHp * hr, `${label(f)}: a new 屏障`); + done(h); + } +}); + +test('S1 聚类分析 (AUTO, attack SP, data DEFAULT): 11 / 9 SP, 3 bullets, interval 1.2 + 1.3 s, each attack strikes 5 times every 0.2 s at 95 % / 110 % ATK physical every enemy within 1.1 of its landing point (air units too)', () => { + for (const f of [[5, false, null], [6, true, SOA]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S1); + assert.deepEqual([sk.skillType, sk.spType, sk.spCost, sk.bb['attack@trigger_time'], sk.bb['attack@times'], sk.bb['attack@interval'], sk.bb['attack@projectile_range'], sk.bb['attack@atk_scale']], + ['AUTO', 'INCREASE_WHEN_ATTACK', elite ? 9 : 11, 3, 5, 0.2, 1.1, elite ? 1.1 : 0.95], label(f)); + const { h, u } = field({ tier, elite, mod, skill: 0, graf: null }); + assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType], ['DEFAULT', 'ammo', 'attack'], label(f)); + const main = h.spawn('enemy_dummy', { pos: [10, 5] }), near = h.spawn('enemy_dummy', { pos: [11, 5] }); + const fly = h.spawn('enemy_fly', { pos: [9, 5] }), far = h.spawn('enemy_dummy', { pos: [12, 5] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `${label(f)}: cast on her next attack`); + approx(u.s.interval, 2.5, `${label(f)}: 1.2 + 1.3 s`); + const n0 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === u && c.dmg?.isSkill), 3), label(f)); + h.run(1.2); + const hits = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.isSkill); + const by = (e) => hits.filter((c) => c.target === e); + assert.deepEqual([by(main).length, by(near).length, by(fly).length, by(far).length], [5, 5, 5, 0], `${label(f)}: 5 strikes on each within 1.1`); + const atk = u.s.atk; + for (const c of hits) approx(c.amount, atk * sk.bb['attack@atk_scale'], `${label(f)}: ${sk.bb['attack@atk_scale'] * 100} % ATK`); + const times = by(main).map((c) => c.t); + for (let i = 1; i < times.length; i++) approx(times[i] - times[i - 1], 0.2, `${label(f)}: 0.2 s apart`, 0.1); + h.runUntil(() => !u.skill.active, 20); + assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 3, `${label(f)}: 3 bullets`); + done(h); + } +}); + +test('S2 协防术式 (MANUAL, data DEFAULT — rawRule TAKE_DAMAGE): ATK +90 % / +120 %; she and her 结构性原理 get a 10 % 屏障; a break: 200 % ATK arts and 战栗 2 s around the holder (1.5), a bullet spent and a new 屏障; attacks spend none; the last bullet ends the skill and leaves its 屏障', () => { + for (const f of [[5, false, null], [6, true, SOA]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule, sk.bb.atk, sk.bb.hp_ratio, sk.bb.trigger_time, sk.bb.range_radius, sk.bb.atk_scale, sk.bb.not_combat], + ['DEFAULT', 'TAKE_DAMAGE', elite ? 1.2 : 0.9, 0.1, elite ? 7 : 6, 1.5, 2, 2], label(f)); + const { h, u, t } = field({ tier, elite, mod, skill: 1 }); + // her T2 屏障 first spent, so the S2 one is what breaks + h.b.removeBuff(u, 'mcnist:t2:barrier'); + const e0 = h.spawn('enemy_dummy', { pos: [10, 5] }), e1 = h.spawn('enemy_dummy', { pos: [11, 4] }), e2 = h.spawn('enemy_fly', { pos: [11, 3] }), out = h.spawn('enemy_dummy', { pos: [12, 5] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `${label(f)}: cast as she is about to attack`); + assert.equal(u.skill.rule, 'DEFAULT'); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + for (const x of [u, t]) approx(barrier(x, 'mcnist:s2:barrier').shield, x.s.maxHp * 0.1, `${label(f)}: ${x.kind} 10 % 屏障`); + h.run(6); + assert.equal(u.skill.ammoLeft, sk.bb.trigger_time, `${label(f)}: attacks spend no bullet`); + // break hers + const hp0 = u.hp; + const got = h.b.dealDamage(null, u, { amount: u.s.maxHp, type: 'phys', canDodge: false }); + assert.deepEqual([got, u.hp], [0, hp0], `${label(f)}: the breaking hit blocked whole (超额防护)`); + const burst = tagged(h, u, 'mcnist:shieldBurst'); + assert.deepEqual(sortN(burst.map((c) => c.target.id)), sortN([e1.id, e2.id]), `${label(f)}: within 1.5 of her, the flyer too`); + for (const c of burst) { approx(c.amount, u.s.atk * 2, `${label(f)}: 200 % ATK`); assert.equal(c.type, 'arts'); } + const tr = h.hooksOf('statusApplied').filter((c) => c.status === 'tremble' && c.source === u); + assert.deepEqual(sortN(tr.map((c) => c.target.id)), sortN([e1.id, e2.id]), `${label(f)}: 战栗`); + for (const c of tr) approx(c.duration, 2, `${label(f)}: 2 s`); + assert.ok(!out.findBuff('tremble') && !e0.findBuff('tremble'), `${label(f)}: 2 tiles away: none`); + assert.equal(u.skill.ammoLeft, sk.bb.trigger_time - 1, `${label(f)}: one bullet`); + approx(barrier(u, 'mcnist:s2:barrier').shield, u.s.maxHp * 0.1, `${label(f)}: a new 屏障`); + // spend the rest: the skill ends, the last 屏障 stays without its break effect + for (let i = 0; i < 20 && u.skill.active; i++) h.b.dealDamage(null, i % 2 ? t : u, { amount: 1e5, type: 'phys', canDodge: false }); + assert.ok(!u.skill.active, `${label(f)}: ended with its bullets`); + assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, sk.bb.trigger_time, label(f)); + const last = barrier(u, 'mcnist:s2:barrier') ?? barrier(t, 'mcnist:s2:barrier'); + assert.ok(last && last.shield > 0, `${label(f)}: the last 屏障 stays`); + const n = tagged(h, u, 'mcnist:shieldBurst').length; + h.b.dealDamage(null, barrier(u, 'mcnist:s2:barrier') ? u : t, { amount: 1e5, type: 'phys', canDodge: false }); + assert.equal(tagged(h, u, 'mcnist:shieldBurst').length, n, `${label(f)}: no burst after the skill`); + done(h); + } + // a 结构性原理 deployed during S2 gets one at once + const { h, u, t } = field({ skill: 1 }); + h.spawn('enemy_dummy', { pos: [10, 5] }); + h.b.retreat(t, { reason: 'expired', permanent: true }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + t.mem.readyAt = h.b.time; + assert.ok(h.runUntil(() => t.alive, 1)); + assert.ok(barrier(t, 'mcnist:s2:barrier'), 'a deployment during S2'); + done(h); +}); + +test('S3 工程学十字星 (MANUAL, data DEFAULT, 40 s): ATK +160 % / +220 %, interval 1.2 + 2.3 s; each attack lands a cross 0.8 s later: 220 % / 240 % ATK arts on every enemy of the x-6 around its target\'s tile (air units too); the enemies her 结构性原理 blocks are her targets', () => { + for (const f of [[5, false, null], [6, true, SOA]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb['attack@atk_scale'], sk.bbStr['attack@projectile_range']], + ['DEFAULT', 'TAKE_DAMAGE', 40, elite ? 2.2 : 1.6, 2.3, elite ? 2.4 : 2.2, 'x-6'], label(f)); + // the 结构性原理 on (12, 3) facing LEFT: its charge stays in place (high ground at (12, 2)), far from the targets + const { h, u } = field({ tier, elite, mod, skill: 2, graf: { row: 12, col: 3, dir: 'LEFT' } }); + const main = h.spawn('enemy_dummy', { pos: [10, 5] }), up2 = h.spawn('enemy_fly', { pos: [12, 5] }); + const side2 = h.spawn('enemy_dummy', { pos: [10, 7] }), diag = h.spawn('enemy_dummy', { pos: [11, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `${label(f)}: cast`); + approx(u.skill.timeLeft, 40, label(f), 0.01); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + approx(u.s.interval, 3.5, `${label(f)}: 1.2 + 2.3 s`); + const atk = u.s.atk; + assert.ok(h.runUntil(() => tagged(h, u, 'mcnist:cross').length > 0, 5), `${label(f)}: a cross lands`); + const first = tagged(h, u, 'mcnist:cross'); + const t0 = first[0].t; + const wave = first.filter((c) => Math.abs(c.t - t0) < 1e-6); + assert.deepEqual(sortN(wave.map((c) => c.target.id)), sortN([main.id, up2.id, side2.id]), `${label(f)}: the x-6 around (10, 5)`); + assert.ok(!wave.some((c) => c.target === diag), `${label(f)}: not the diagonal (11, 6)`); + for (const c of wave) { approx(c.amount, atk * sk.bb['attack@atk_scale'], `${label(f)}: ${sk.bb['attack@atk_scale'] * 100} %`); assert.equal(c.type, 'arts'); } + // 0.8 s after the throw (the attack that threw it made no damage of its own) + assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && !c.dmg?.tags?.includes('mcnist:cross') && c.target.side === 'enemy' && c.dmg?.isAttack && c.t < t0).length, 0, label(f)); + done(h); + } + // her 结构性原理 on (9, 7), out of her range, blocks a walker: during S3 she attacks it (its charge lands in place) + const { h, u, t } = field({ skill: 2, graf: { row: 9, col: 7, dir: 'RIGHT' } }); + const w = h.spawn('enemy_walk', { routeIndex: 0 }); + assert.ok(h.runUntil(() => t.blocking.includes(w), 30), 'the walker reaches it'); + assert.ok(!u.rangeKeySet.has(Math.round(w.y) * 21 + Math.round(w.x)), 'out of her range'); + h.run(2); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.target === w).length, 0, 'not her target without S3'); + u.skill.gainSp(999); + u.skill.activate('test'); + assert.ok(h.runUntil(() => t.alive && t.blocking.includes(w), 1), 'its charge stops at once (the walker next to it) and it blocks again'); + assert.ok(h.runUntil(() => tagged(h, u, 'mcnist:cross').some((c) => c.target === w), 6), 'a cross on the blocked walker'); + done(h); +}); + +test('S3 charge: the 结构性原理 runs along its facing to the farthest free low tile before an enemy (0.2 s + the distance at 20 tiles / s), deals 300 % of her ATK physical within 1.5 of the stop tile and is deployed anew there (full HP, 生命方程 again); then a weaken aura of 1.5: 50 % − 2.5 % per second', () => { + for (const f of [[5, false, null], [6, true, SOA]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S3); + const w = variantOf(tier, elite).talents.find((x) => x.bb.atk_interval != null).bb; + assert.deepEqual([sk.bb.atk_scale, sk.bb.projectile_range, w.atk, w.atk_interval, w.interval, w.range_radius], [3, 1.5, -0.5, 0.025, 1, 1.5], label(f)); + const { h, u, t } = field({ tier, elite, mod, skill: 2, graf: { row: 11, col: 3, dir: 'RIGHT' } }); + const wall = h.spawn('enemy_dummy', { pos: [11, 8] }), hit2 = h.spawn('enemy_fly', { pos: [12, 7] }), front = h.spawn('enemy_dummy', { pos: [10, 5] }); + t.hp = 100; + u.skill.gainSp(999); + const atk0 = u.s.atk; + assert.ok(h.runUntil(() => u.skill.active, 3), `${label(f)}: cast (her own target in range)`); + const cast = h.b.time; + const atk = u.s.atk; + assert.ok(atk > atk0, label(f)); + assert.ok(!t.alive, `${label(f)}: off the field while it charges`); + // from (11, 3) to (11, 7): 4 tiles ⇒ 0.2 + 4 / 20 s + assert.ok(h.runUntil(() => t.alive, 1), `${label(f)}: lands`); + approx(h.b.time - cast, 0.2 + 4 / 20, `${label(f)}: flight time`, 0.1); + assert.deepEqual([t.tileR, t.tileC], [11, 7], `${label(f)}: the farthest tile before the enemy`); + approx(t.hp, t.s.maxHp, `${label(f)}: a new deployment (full HP)`); + approx(barrier(t, 'mcnist:t2:barrier').shield, t.s.maxHp * 0.3, `${label(f)}: 生命方程 again`); + const ch = tagged(h, u, 'mcnist:charge'); + assert.deepEqual(sortN(ch.map((c) => c.target.id)), sortN([wall.id, hit2.id]), `${label(f)}: within 1.5 of (11, 7), the flyer too`); + for (const c of ch) { approx(c.amount, atk * 3, `${label(f)}: 300 % of her ATK`); assert.equal(c.type, 'phys'); } + h.run(0.3); + const wk = (e) => h.hooksOf('statusApplied').filter((c) => c.status === 'weaken' && c.target === e); + approx(wk(wall).at(-1).value, 0.5, `${label(f)}: 50 % at first`); + assert.equal(wk(front).length, 0, `${label(f)}: out of the aura`); + h.run(4); + approx(wk(wall).at(-1).value, 0.5 - 0.025 * 4, `${label(f)}: −2.5 % per second`, 1e-6); + h.run(17); + const n = wk(wall).length; + h.run(1); + assert.equal(wk(wall).length, n, `${label(f)}: gone at 0 (20 s)`); + done(h); + } + // no 结构性原理 at the cast: its next deployment during the skill charges + const { h, u, t } = field({ skill: 2, graf: { row: 11, col: 3, dir: 'RIGHT' } }); + h.spawn('enemy_dummy', { pos: [10, 5] }); + h.b.retreat(t, { reason: 'expired', permanent: true }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + t.mem.readyAt = h.b.time; + assert.ok(h.runUntil(() => t.alive && t.tileC !== 3, 2), 'charged after its deployment'); + done(h); +}); + +test('SO-A 机械师特勤证章: only its attributes act (stages 1 and 3) — no 集成战略 ATK ×1.5 on blocked enemies, the 结构性原理 keeps its 30 s', () => { + for (const [tier, mod] of [[5, SOA], [6, SOA], [6, null]]) { + const { h, u, t } = field({ tier, elite: true, mod, skill: 0, graf: { row: 10, col: 4 } }); + const m = modOf(tier, mod); + if (m) assert.match(m.traitOverride.moduleDesc, /沉沦者的黑流树海/); + const e = h.spawn('enemy_dummy', { pos: [10, 5] }); + const n0 = h.hooksOf('damaged').length; + h.run(3); + const hits = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.target === e && c.dmg?.isAttack && !c.dmg?.isSkill); + assert.ok(hits.length > 0, 'attacks'); + for (const c of hits) approx(c.amount, u.s.atk, `T${tier} ${mod}: plain ATK`); + h.run(28); + assert.ok(!t.alive, `T${tier} ${mod}: the 结构性原理 left at 30 s`); + done(h); + } +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index 0793c28c..dad2268b 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -1974,8 +1974,8 @@ "snaps": "8eac4d32 fa5c2376 bc1e9184 84fb1db6 b4b3b807 cc6f6399 c665e4ff 261094cd 5d5c61fd 1bf27396 b526ffa6 e433a3a2 7d8bd521 d7eb687d 4d18ecc9 f640c5fd 0912a7d6 cc22df71 1a833e56 d877d28b 8a887ef8 8014c7dd" }, "diy-049": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", - "fast": true, + "about": "5_diy1_a=jesca2/S1 5_diy2_a=jesca2/S2 5_diy1_a=jesca2/S3 5_diy2_b=jesca2/S1 5_diy1_b=jesca2/S2 5_diy2_b=jesca2/S3 5_diy1_b=jesca2/S1/002_jesca2 5_diy2_b=jesca2/S2/002_jesca2 5_diy1_b=jesca2/S3/002_jesca2 5_diy2_b=jesca2/S1/003_jesca2 5_diy1_b=jesca2/S2/003_jesca2 5_diy2_b=jesca2/S3/003_jesca2", + "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_multi_normal", @@ -1985,122 +1985,115 @@ "time": 230, "ticks": 6900, "reason": "timeout", - "killed": 2, - "total": 60, - "unspawned": 0, - "bossHpLeft": null, - "rngDraws": 0, - "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":2,"total":60,"perfect":false,"leaks":58,"leaksUncounted":0,"coins":0,"dmg":267738,"bossDmg":0,"heal":59968,"deaths":6,"layerGains":{}}}, - "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,76,0,304,6384,0,22,1], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,75,0,652,8724,0,41,2], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,6,0,21282,21790,0,137,3], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",3334,1,8,2,22607,38177,0,115,4], - ["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,10,0,2272,0,0,99,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,11,0,2916,0,0,108,3], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,1,0,0,0,0,0,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,3,0,97722,0,0,178,4], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,5,0,0,0,29529,69,4], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,7,0,0,0,30439,60,4], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,9,0,56880,0,0,144,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,12,0,63104,0,0,136,3] - ], - "enemies": {"enemy_1045_hammer_2":[12,0,11,37471,77249],"enemy_1061_zomshd":[48,2,45,37605,269489]}, - "hooks": [1,76,6900,1205,1205,3051,3051,29902,8,64,33,29,0,37113,285,23,60,56,1,8,0,0,0], - "events": {"atk":1205,"deploy":16,"die":8,"dmg":3051,"heal":129,"leak":56,"skill":62,"spawn":72,"status":555}, - "fx": {}, - "snaps": "c6bdc18a 6fb12fe1 c3c94d6f f42e9bba 6ea4c93a ff3506ac f551dbb2 248e7a29 e877982d 0b8ac60e d21ffc26 89eda7b5 7ef13ac0 2d2fce6f 32484e38 3407d0b5 1b59f452 55141007 fc87be97 6239dcab e11e96f9 250fe5b6 a75b766c" - }, - "diy-050": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", - "fast": false, - "kind": "normal", - "stageId": "act1autochess_m06", - "modeId": "mode_multi_hard", - "round": 12, - "seed": 574294847, - "waveId": "act1autochess_h05", - "time": 214.8, - "ticks": 6444, - "reason": "cleared", "killed": 0, "total": 60, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 115, + "rngDraws": 13, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":157435,"bossDmg":0,"heal":12039,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":60775,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,82,0,28,5208,0,2,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3371,1,6,0,0,0,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,9,0,20856,0,0,0,7], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,72,0,174,5712,0,6,0], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,67,0,13430,5276,0,29,1], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,78,0,61051,12366,0,48,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,85,0,698,3816,0,13,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,84,0,2755,4761,0,39,1], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,52,0,19051,2744,0,7,1], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,51,0,39393,3498,0,7,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,47,0,0,2448,1260,3,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,54,0,0,9324,10779,8,1] + ["p1",1,"op","chess_char_5_diy1_a",0,null,2589,1,82,0,714,5178,0,33,0], + ["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,63,0,1651,5178,0,56,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,73,0,11244,5178,0,29,1], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,64,0,1599,6530,0,65,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",2866.3,1,81,0,4320,6929,0,124,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,79,0,12208,9795,0,51,1], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_jesca2",0,0,43,0,650,6890,0,24,0], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_jesca2",3445,1,1,0,0,0,0,0,0], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_jesca2",3445,1,3,0,0,0,0,0,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_jesca2",3445,1,5,0,0,0,0,0,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_jesca2",3445,1,7,0,1387,0,0,51,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_jesca2",3445,1,9,0,27000,0,0,53,2] ], - "enemies": {"enemy_1045_hammer_2":[12,0,12,15694,22503],"enemy_1061_zomshd":[48,0,48,39458,134932]}, - "hooks": [1,85,6444,245,245,412,396,10112,23,83,15,15,0,16629,0,28,60,60,1,26,0,0,16], - "events": {"atk":263,"deploy":25,"die":23,"dmg":396,"fx":86,"heal":23,"leak":60,"skill":30,"spawn":72,"status":14}, - "fx": {"aoe":5,"dodge":16,"dp":7,"slash":56,"spGain":2}, - "snaps": "580b238a aa91295b 3af047b3 a833e612 f6a0b595 1eba1563 0c047aab 7838a66c 34fc5adc 627a4814 4d7c5d84 41a4b79a 721f8563 f6d5a347 76cda6c2 74e73fdb 220b1dd6 2f7b90e4 d3a2ce26 6a84c6a3 1aae6574 0e8ef572" + "enemies": {"enemy_1045_hammer_2":[12,0,12,22695,20693],"enemy_1061_zomshd":[48,0,45,22982,118642]}, + "hooks": [1,82,6900,646,646,2610,2599,16240,15,72,8,6,61,17583,2,48,60,57,1,15,0,0,11], + "events": {"atk":646,"deploy":22,"die":15,"dmg":2599,"fx":11,"leak":57,"skill":14,"spawn":72,"status":4}, + "fx": {"dodge":11}, + "snaps": "ba42617b c56f4cd7 32daf4ba ea7b16a6 c678eaa2 d276ac30 f459ee24 a4cb853b 5bf55077 5909a19c 1c05d106 f20147ed 99f86152 1ffdfdca ead5fe0a 10aef045 88bff8b4 917bbcf3 5be27532 e074c221 6e393a99 fa748234 81823f98" + }, + "diy-050": { + "about": "6_diy1_a=jesca2/S1 6_diy2_a=jesca2/S2 6_diy1_a=jesca2/S3 6_diy2_b=jesca2/S1 6_diy1_b=jesca2/S2 6_diy2_b=jesca2/S3 6_diy1_b=jesca2/S1/002_jesca2 6_diy2_b=jesca2/S2/002_jesca2 6_diy1_b=jesca2/S3/002_jesca2 6_diy2_b=jesca2/S1/003_jesca2 6_diy1_b=jesca2/S2/003_jesca2 6_diy2_b=jesca2/S3/003_jesca2", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m06", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 574294847, + "waveId": "act1autochess_h06", + "time": 209.53, + "ticks": 6286, + "reason": "cleared", + "killed": 6, + "total": 105, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 21, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":6,"total":105,"perfect":false,"leaks":99,"leaksUncounted":0,"coins":0,"dmg":142184,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,126,0,974,7767,0,7,0], + ["p1",2,"op","chess_char_6_diy2_a",1,null,2589,1,7,0,25079,0,0,152,4], + ["p1",3,"op","chess_char_6_diy1_a",2,null,2589,1,9,0,31271,0,0,37,2], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",0,0,109,0,221,9795,0,9,0], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,96,0,4856,6530,0,50,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,74,0,3593,6530,0,11,1], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_jesca2",3515,1,119,0,390,7030,0,14,0], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_jesca2",0,0,4,2,31909,3515,0,44,1], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_jesca2",0,0,70,1,6436,7030,0,12,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_jesca2",0,0,1,0,1351,3590,0,9,0], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_jesca2",0,0,3,3,25423,3590,0,50,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_jesca2",0,0,5,0,10681,3590,0,12,1] + ], + "enemies": {"enemy_1165_duhond":[51,6,45,1745,109362],"enemy_1174_duholy_2":[24,0,24,20766,26233],"enemy_1175_dushdo_2":[30,0,30,36455,6588]}, + "hooks": [1,126,6286,530,530,528,514,0,24,123,12,11,50,15342,252,47,105,99,1,24,0,0,14], + "events": {"atk":530,"deploy":21,"die":24,"dmg":514,"fx":23,"leak":99,"skill":23,"spawn":117,"status":169}, + "fx": {"dodge":14,"reveal":9}, + "snaps": "78be456d 78e48ca9 2bd9c210 8143b52a fcfaf8af 3ff43951 8697d9a4 a26d6b97 7dfd1fba 2c7577c2 5bc39b67 c5d496d2 45c4f58c aadfe7e0 1a837780 44c6cc25 b2a509a8 6c51e37f 481866ab f58770e1 803806bd" }, "diy-051": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "5_diy1_a=mcnist/S1 5_diy2_a=mcnist/S2 5_diy1_a=mcnist/S3 5_diy2_b=mcnist/S1 5_diy1_b=mcnist/S2 5_diy2_b=mcnist/S3 5_diy1_b=mcnist/S1/002_mcnist 5_diy2_b=mcnist/S2/002_mcnist 5_diy1_b=mcnist/S3/002_mcnist 6_diy1_a=mcnist/S1 6_diy2_a=mcnist/S2 6_diy1_a=mcnist/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m07", "modeId": "mode_single_normal", - "round": 13, + "round": 12, "seed": 3864737019, - "waveId": "act1autochess_h06", - "time": 204.8, - "ticks": 6144, + "waveId": "act1autochess_h05", + "time": 201.47, + "ticks": 6044, "reason": "cleared", - "killed": 197, - "total": 290, + "killed": 83, + "total": 99, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 282, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":197,"total":290,"perfect":false,"leaks":93,"leaksUncounted":0,"coins":0,"dmg":444885,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}}, + "players": {"p1":{"killed":83,"total":99,"perfect":false,"leaks":16,"leaksUncounted":0,"coins":0,"dmg":1015722,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,4,7,21881,2604,0,65,1], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,7,11,35637,3431,0,87,2], - ["p1",3,"op","chess_char_5_diy1_a",0,null,1560.61,1,279,11,28493,2169,0,91,2], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",1890.36,1,277,51,98505,2858,0,110,3], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,241,13,39114,3058,0,54,3], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,163,43,73993,3808,0,55,4], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,285,5,26351,8078,0,87,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,3,7,19,0,0,16,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,5,10,60,0,0,60,3], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",2339.65,1,8,13,64107,1191,0,82,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,6,10,18397,0,0,78,2], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,9,16,38328,0,0,93,2], + ["p1",1,"op","chess_char_5_diy1_a",0,null,2614,1,120,1,21835,2614,0,22,1], + ["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,6,9,61469,2614,0,79,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2614,1,112,8,116469,2614,0,50,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",3288,1,78,9,65023,3288,0,68,5], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",3288,1,95,5,72011,3288,0,101,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,81,16,217675,6576,0,48,2], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_mcnist",0,0,70,13,187327,6576,0,83,6], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_mcnist",3288,1,3,11,90830,0,0,79,1], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_mcnist",3288,1,122,6,50371,3288,0,24,1], + ["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,82,0,20215,5228,0,44,2], + ["p1",11,"op","chess_char_6_diy2_a",1,null,0,0,9,3,46785,2614,0,74,1], + ["p1",12,"op","chess_char_6_diy1_a",2,null,1100.17,1,109,2,65712,4128,0,67,2], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] ], - "enemies": { - "enemy_1195_sfyin": [39,30,9,4143,113683], - "enemy_1196_msfyin": [60,32,28,0,65], - "enemy_1197_sfshu": [48,38,10,10130,184299], - "enemy_1198_msfshu": [114,78,36,0,243], - "enemy_1199_sfjin": [18,11,7,12925,146349], - "enemy_1200_msfjin": [11,8,3,0,246] - }, - "hooks": [1,314,6144,1470,1470,1969,2082,0,212,305,25,30,0,33234,6218,91,290,93,1,212,0,0,11], - "events": {"atk":1512,"deploy":22,"die":212,"dmg":2076,"fx":273,"leak":93,"skill":48,"spawn":304,"status":488}, - "fx": {"aoe":56,"displace":1,"dodge":22,"dp":63,"slash":52,"summon":79}, - "snaps": "cf051c06 587dcba3 94ed6adb bba4fd81 815661ad cbf17744 a8d0c19b 32b88916 1e8bde83 51a1d669 b023b185 df725cf4 bf4681aa 2c372e04 1a001766 3d81d3d6 7f8effe5 11826d80 b4fb05d0 0eed6d6c 65f6503d" + "enemies": {"enemy_1010_demon":[42,38,4,14216,449007],"enemy_1320_wdrrl_2":[12,5,7,26818,212204],"enemy_1422_lrsldr_2":[45,40,5,1794,354511]}, + "hooks": [1,122,6044,1411,1411,2480,2480,0,97,113,26,22,73,14068,101,105,99,16,1,97,0,0,0], + "events": {"atk":1411,"deploy":21,"die":97,"dmg":2135,"fx":152,"leak":16,"skill":48,"spawn":113,"status":308}, + "fx": {"aoe":86,"explode":8,"shield":58}, + "snaps": "bafd7890 0f8e39c5 de2bf506 5cee072b 4131c0d3 130e376f 082e40af 1afd8471 ea4c89d1 957cfcf6 4542daa0 986dd0c1 a2fbd981 c88fa318 d7590fbf c396e408 93cb626c 5a3e8085 0e32ffe5 29ea4aca fb80caed" }, "diy-052": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "6_diy1_b=mcnist/S1 6_diy2_b=mcnist/S2 6_diy1_b=mcnist/S3 6_diy2_b=mcnist/S1/002_mcnist 6_diy1_b=mcnist/S2/002_mcnist 6_diy2_b=mcnist/S3/002_mcnist 5_diy1_a=kalts/S1 5_diy2_a=kalts/S2 5_diy1_a=kalts/S3 5_diy2_b=kalts/S1 5_diy1_b=kalts/S2 5_diy2_b=kalts/S3", "fast": false, "kind": "normal", "stageId": "act2autochess_m01", @@ -2108,35 +2101,282 @@ "round": 13, "seed": 1210502478, "waveId": "act1autochess_h06", - "time": 218.57, - "ticks": 6557, - "reason": "cleared", - "killed": 0, + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 35, "total": 123, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 81, + "rngDraws": 3, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":76446,"bossDmg":0,"heal":2762,"deaths":33,"layerGains":{}}}, + "players": {"p1":{"killed":35,"total":123,"perfect":false,"leaks":88,"leaksUncounted":0,"coins":0,"dmg":1203339,"bossDmg":0,"heal":46130,"deaths":16,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,6,0,2101,1865,0,8,0], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,9,0,4660,2494,0,8,0], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,157,0,13058,11540,0,21,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,156,0,24836,14840,0,17,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,151,0,3559,2544,0,18,0], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,3,0,11692,1643,0,23,0], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,4,0,9700,1372,0,5,1], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,11,0,1022,1819,0,1,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,86,0,0,4120,2092,4,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,74,0,0,4018,670,2,0], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,1,0,1487,1160,0,3,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,5,0,4330,1450,0,7,1] + ["p1",1,"op","chess_char_6_diy1_b",0,"none",3288,1,120,4,209779,8289,0,78,6], + ["p1",2,"op","chess_char_6_diy2_b",1,"none",2273.39,1,88,8,235218,4695,0,116,2], + ["p1",3,"op","chess_char_6_diy1_b",2,"none",2487.16,1,141,4,103454,8161,0,33,2], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_mcnist",3288,1,145,4,99030,6066,0,34,3], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_mcnist",1475.69,1,77,4,217574,8330,0,122,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_mcnist",0,0,8,11,338283,9833,0,63,2], + ["p1",7,"op","chess_char_5_diy1_a",0,null,1469,1,2,0,0,4598,7788,43,4], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,3,0,0,1861,16009,46,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,4,0,0,9599,13155,45,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,130,0,0,6378,759,2,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",1578,1,146,0,0,6892,4193,10,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,89,0,0,12056,4228,15,0] ], - "enemies": {"enemy_1019_jshoot_2":[57,0,57,29550,4113],"enemy_1299_ymkilr_2":[27,0,27,14576,26756],"enemy_1422_lrsldr_2":[39,0,39,4739,45577]}, - "hooks": [1,157,6557,277,277,283,291,7,33,156,4,24,0,6272,192,29,123,123,1,33,0,0,0], - "events": {"atk":278,"deploy":34,"die":33,"dmg":269,"fx":19,"heal":7,"leak":123,"skill":8,"spawn":135,"status":92}, - "fx": {"aoe":2,"displace":15,"spGain":2}, - "snaps": "21be44ba 23e43266 29788d55 2f1037e9 363ad836 051606c5 26acdfeb 8dc54607 7a7c6a86 7ebe59b3 d1661fdb 203d434c 2e82423b f7e9ffc1 7a1afa0e 8998df31 0ca38cb6 f3ac5677 87bcc7cf 0f5f3879 0e082cac 62241cef" + "enemies": {"enemy_1019_jshoot_2":[57,24,32,48946,474937],"enemy_1299_ymkilr_2":[27,0,27,32191,120187],"enemy_1422_lrsldr_2":[39,11,27,5621,608215]}, + "hooks": [1,146,6900,1681,1681,2454,2452,161,51,137,21,18,47,12458,189,63,123,86,1,51,0,0,0], + "events": {"atk":1681,"deploy":23,"die":51,"dmg":2054,"fx":90,"heal":161,"leak":86,"skill":39,"spawn":135,"status":436}, + "fx": {"aoe":50,"block":2,"shield":38}, + "snaps": "0e9d4d53 a00748cc 605af3f8 d326a721 c4b0bb47 9b322b84 cdd1f1f3 762e9810 69ef27a1 d8725a0e 2a1640c8 a7234100 029dc53a b7164144 432d72a5 364f4796 70c22926 f25672f6 a6b2b85e c0dec1bd 4b736b32 bbe18387 53db4d81" + }, + "diy-053": { + "about": "5_diy1_b=kalts/S1/002_kalts 5_diy2_b=kalts/S2/002_kalts 5_diy1_b=kalts/S3/002_kalts 5_diy2_b=kalts/S1/003_kalts 5_diy1_b=kalts/S2/003_kalts 5_diy2_b=kalts/S3/003_kalts 5_diy1_b=kalts/S1/004_kalts 5_diy2_b=kalts/S2/004_kalts 5_diy1_b=kalts/S3/004_kalts 6_diy1_a=kalts/S1 6_diy2_a=kalts/S2 6_diy1_a=kalts/S3", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m02", + "modeId": "mode_multi_funny", + "round": 12, + "seed": 2850577873, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 0, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":0,"total":114,"perfect":false,"leaks":114,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":66122,"deaths":0,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_kalts",1578,1,6,0,0,0,9962,74,4], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_kalts",1578,1,7,0,0,0,10234,74,0], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_kalts",1578,1,13,0,0,0,4936,37,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_kalts",1693,1,4,0,0,0,9856,44,4], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_kalts",1693,1,5,0,0,0,8288,37,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_kalts",1693,1,8,0,0,0,0,0,0], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_004_kalts",1578,1,14,0,0,0,4802,36,4], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_004_kalts",1578,1,9,0,0,14541,667,5,0], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_004_kalts",1578,1,12,0,0,14941,0,0,0], + ["p1",10,"op","chess_char_6_diy1_a",0,null,1469,1,10,0,0,9856,0,0,0], + ["p1",11,"op","chess_char_6_diy2_a",1,null,1469,1,11,0,0,9408,0,0,0], + ["p1",12,"op","chess_char_6_diy1_a",2,null,1469,1,3,0,0,17377,17377,75,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,0,42,46858,0],"enemy_1169_duphlx_2":[51,0,49,19264,0],"enemy_1172_dugago":[18,0,18,0,0]}, + "hooks": [1,128,6900,796,796,414,414,382,0,109,12,9,0,8820,0,5,114,109,1,0,0,0,0], + "events": {"atk":796,"deploy":12,"dmg":414,"heal":382,"leak":109,"skill":21,"spawn":128,"status":114}, + "fx": {}, + "snaps": "e92af7ef bf43d79f adbe0394 444aa075 5d35943e 9aad9b42 03fe0969 9248bfd2 bfa47b08 7884deab 3b839490 1b069526 a6d98e95 88dbd298 1697e311 52f8b2be 700a5aa5 d017b2af 47953ad9 c54a9945 d41990e7 95af588c 243cb2c1" + }, + "diy-054": { + "about": "6_diy1_b=kalts/S1 6_diy2_b=kalts/S2 6_diy1_b=kalts/S3 6_diy2_b=kalts/S1/002_kalts 6_diy1_b=kalts/S2/002_kalts 6_diy2_b=kalts/S3/002_kalts 6_diy1_b=kalts/S1/003_kalts 6_diy2_b=kalts/S2/003_kalts 6_diy1_b=kalts/S3/003_kalts 6_diy2_b=kalts/S1/004_kalts 6_diy1_b=kalts/S2/004_kalts 6_diy2_b=kalts/S3/004_kalts", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m03", + "modeId": "mode_single_hard", + "round": 13, + "seed": 196461732, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 0, + "total": 66, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 13, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":0,"total":66,"perfect":false,"leaks":66,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":32620,"deaths":19,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_b",0,"none",1578,1,91,0,0,4592,3730,19,2], + ["p1",2,"op","chess_char_6_diy2_b",1,"none",1578,1,92,0,0,3972,3508,10,0], + ["p1",3,"op","chess_char_6_diy1_b",2,"none",1578,1,89,0,0,6175,2765,11,0], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_kalts",0,0,56,0,0,18504,1311,4,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_kalts",0,0,76,0,0,4268,2696,7,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_kalts",0,0,85,0,0,4271,3091,8,0], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_003_kalts",0,0,5,0,0,1773,582,2,1], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_003_kalts",0,0,7,0,0,2355,2900,9,0], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_003_kalts",1773,1,10,0,0,0,4628,15,0], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_004_kalts",0,0,61,0,0,5942,1896,6,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_004_kalts",0,0,57,0,0,8859,2747,10,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_004_kalts",0,0,6,0,0,2281,2765,8,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1005_yokai_2":[12,0,11,2947,0],"enemy_1041_lazerd_2":[24,0,24,60045,0],"enemy_1042_frostd":[30,0,30,0,0]}, + "hooks": [1,92,6900,293,293,166,160,109,19,84,5,4,0,2850,0,0,66,65,1,19,0,0,0], + "events": {"atk":293,"deploy":23,"die":19,"dmg":160,"fx":6,"heal":109,"leak":65,"skill":9,"spawn":81,"status":52}, + "fx": {"block":6}, + "snaps": "12a65e4e b2393bab 4afaef8d 7cf86c19 eddabc86 09ba94d6 ef1914ed 6937fc3e 3bef265d e93f87c7 944d68b2 046c5938 4f56e13f 5dd4f708 e9639686 02fbe216 01335f03 37322cb1 449f21b1 f6747c86 8e1171a7 b1229590 c708ef68" + }, + "diy-055": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_normal", + "round": 12, + "seed": 1597707536, + "waveId": "act1autochess_h05", + "time": 226.43, + "ticks": 6793, + "reason": "cleared", + "killed": 8, + "total": 117, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":8,"total":117,"perfect":false,"leaks":109,"leaksUncounted":0,"coins":0,"dmg":52598,"bossDmg":0,"heal":8900,"deaths":25,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,117,0,0,6078,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,111,0,0,7823,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,144,0,0,5128,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",208.49,1,145,0,0,10902,0,0,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,132,0,18064,2542,0,48,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",868.62,1,146,5,21138,3661,0,44,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,86,1,660,2480,0,2,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,79,1,4810,2924,0,9,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,5,0,0,1218,5678,12,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,92,0,0,4841,3223,7,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,142,0,3950,2796,0,11,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,135,1,3976,4845,0,9,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1005_yokai":[51,8,43,0,18454],"enemy_1005_yokai_2":[54,0,54,21039,2515],"enemy_1005_yokai_3":[12,0,12,34200,31629]}, + "hooks": [1,146,6793,492,492,425,425,19,33,142,4,4,0,11479,18,0,117,109,1,33,0,0,0], + "events": {"atk":492,"deploy":27,"die":33,"dmg":425,"heal":19,"leak":109,"skill":8,"spawn":131,"status":28}, + "fx": {}, + "snaps": "e90d8ac5 3e7826d8 2ea79392 0d0a30f6 55810e9b a62c5a87 b1c5cfca 6959234f 84a973da 3386636e fa19929f 31ac9ec4 cc1b23bc 0ce6593e 1aa312f9 8de415cc 7670e151 f5f13c6f 12c4e5a4 75fb7a93 385d158a f1ce79bb 455d3b6d" + }, + "diy-056": { + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m01", + "modeId": "mode_multi_hard", + "round": 12, + "seed": 3238296381, + "waveId": "act1autochess_h05", + "time": 208.03, + "ticks": 6241, + "reason": "cleared", + "killed": 3, + "total": 75, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 196, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":3,"total":75,"perfect":false,"leaks":72,"leaksUncounted":0,"coins":0,"dmg":162615,"bossDmg":0,"heal":9891,"deaths":23,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,7,0,249,2808,0,3,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,9,1,600,3371,0,5,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,97,1,12118,4587,0,5,1], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,101,0,24851,5712,0,11,1], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,68,0,6186,5276,0,8,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,8,0,35092,8965,0,13,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,75,0,17393,2748,0,33,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,84,1,48820,3924,0,40,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,61,0,8325,3344,0,4,0], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,55,0,8980,3498,0,4,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,93,0,0,3198,8062,15,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,102,0,0,4657,1829,3,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1267_nhpbr_2":[36,2,34,5542,68908],"enemy_1270_nhstlk_2":[27,1,26,12814,85923],"enemy_1272_nhtank_2":[12,0,12,33732,7784]}, + "hooks": [1,102,6241,222,222,529,526,32,26,98,6,6,0,8985,0,21,75,72,1,56,0,0,3], + "events": {"atk":261,"deploy":24,"die":26,"dmg":526,"fx":42,"heal":32,"leak":72,"skill":12,"spawn":90,"status":32}, + "fx": {"dodge":3,"dp":3,"slash":16,"spGain":2,"zone":18}, + "snaps": "4a66aa50 cd957f8d 29040588 85dbfdfb 24a06993 403a5cd0 af8c7db4 ece6f6e6 e208e92d b00a9483 cdbb6067 b86efe8c 4d65b9e6 9e9b8ccc 4dd884e0 de6f0bb5 1ee54cb6 187ba789 edc6ec98 27566532 7101f464" + }, + "diy-057": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_single_normal", + "round": 13, + "seed": 583790378, + "waveId": "act1autochess_h06", + "time": 229.57, + "ticks": 6887, + "reason": "cleared", + "killed": 25, + "total": 147, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 349, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":25,"total":147,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":447188,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,144,0,5014,5208,0,37,1], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,1,0,7239,3431,0,80,2], + ["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,143,0,12621,3730,0,59,1], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,139,5,129318,4748,0,83,2], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1529,1,176,2,19391,3058,0,28,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1904,1,174,8,121508,3808,0,33,4], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1154,1,165,0,5198,6924,0,54,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,171,0,7386,4344,0,52,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,177,2,17400,2638,0,21,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,146,6,70277,7062,0,82,2], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,6,0,21302,0,0,139,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,8,2,30532,0,0,150,3] + ], + "enemies": {"enemy_1166_dusbr_2":[39,10,29,2081,102398],"enemy_1169_duphlx_2":[57,9,48,10670,156040],"enemy_1170_dushld_2":[51,6,45,32200,188750]}, + "hooks": [1,177,6887,1087,1087,1627,1734,0,48,170,21,28,0,26130,14208,52,147,122,1,48,0,0,14], + "events": {"atk":1145,"deploy":30,"die":48,"dmg":1705,"fx":155,"leak":122,"skill":40,"spawn":159,"status":1199}, + "fx": {"aoe":51,"displace":8,"dodge":32,"dp":16,"slash":48}, + "snaps": "3c92533d c853aee7 5b1063fd 5fc57b7e 4a05e8e6 38a71bec 3070ca8c d432fa88 49683316 30a3854a 9104d2b4 923ae15a 22aa5459 cdd051aa 9f802034 82bafa84 ccf4b89d e3e17beb 6eb62b58 fe7abec6 989ccd10 d648552b 98e9bf71" + }, + "diy-058": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m03", + "modeId": "mode_multi_abyss", + "round": 13, + "seed": 2224511831, + "waveId": "act1autochess_h06", + "time": 208.07, + "ticks": 6242, + "reason": "cleared", + "killed": 2, + "total": 105, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 148, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":2,"total":105,"perfect":false,"leaks":103,"leaksUncounted":0,"coins":0,"dmg":240102,"bossDmg":0,"heal":14281,"deaths":30,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,5,0,14676,1865,0,12,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,7,1,65680,3799,0,21,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,140,0,19129,12041,0,30,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,139,0,38020,15591,0,35,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,131,0,5981,1272,0,26,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,12,0,8584,1643,0,26,0], + ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,78,0,5748,4303,0,7,0], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,4,1,75199,1819,0,10,1], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,6,0,0,2056,5589,7,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,9,0,0,5567,8692,11,1], + ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,13,0,3038,1160,0,13,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,73,0,4048,5494,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_1229_darmy":[27,1,26,5045,88057],"enemy_1229_darmy_2":[57,1,56,19387,116235],"enemy_1275_dwlock_2":[21,0,21,32176,35810]}, + "hooks": [1,140,6242,294,294,501,589,31,32,135,6,24,0,7426,388,24,105,103,1,32,0,4,3], + "events": {"atk":328,"deploy":32,"die":32,"dmg":541,"fx":78,"heal":27,"leak":103,"skill":12,"spawn":120,"status":178}, + "fx": {"aoe":8,"beam":24,"burst":4,"displace":25,"dodge":5,"spGain":12}, + "snaps": "c9dff647 505536a6 046d3d99 29e1ce4f 95f692ed cb471e6e 3e929e64 72083a81 77cee0d9 d920f33c d26b1231 10d91924 2bc3222a 3b3d8eb1 a41709a6 a48f0d23 774d80e5 2256086c dbd65fc9 29f0c70c fa23ead3" } } } From 87f5c61764d050016f0d3e428dac6b2ae6fa2b20 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 01:37:03 +0800 Subject: [PATCH 3/3] feedback5 O16: op_kalts.test.js checks ISW-A at stage 1 too (attributes only: one heal target, plain heals) --- test/content/op_kalts.test.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/content/op_kalts.test.js b/test/content/op_kalts.test.js index 6b8cd1da..3bfeb397 100644 --- a/test/content/op_kalts.test.js +++ b/test/content/op_kalts.test.js @@ -328,8 +328,8 @@ test('S3 指令:熔毁 (MANUAL, data DEFAULT, 绑定 Mon3tr, 18 s): Mon3tr DEF } }); -test('modules: PHY-X ×1.15 on a heal of a target at or below 50 %; PHY-Y ×1.15 on a heal of a target on a 地面 tile; ISW-A (集成战略-only trait) and no module: plain heals', () => { - for (const f of [[5, true, PHYX], [6, true, PHYX], [5, true, PHYY], [6, true, PHYY], [6, true, ISWA], [6, true, null]]) { +test('modules: PHY-X ×1.15 on a heal of a target at or below 50 %; PHY-Y ×1.15 on a heal of a target on a 地面 tile; ISW-A (集成战略-only trait and talent: no 2 targets, no Mon3tr +50 %) and no module: plain heals', () => { + for (const f of [[5, true, PHYX], [6, true, PHYX], [5, true, PHYY], [6, true, PHYY], [5, true, ISWA], [6, true, ISWA], [6, true, null]]) { const [tier, elite, mod] = f; const md = modOf(tier, mod); if (md) assert.deepEqual([md.traitOverride.bb.heal_scale, md.traitOverride.bb.hp_ratio ?? null], [1.15, mod === PHYY ? null : 0.5], label(f)); @@ -350,6 +350,15 @@ test('modules: PHY-X ×1.15 on a heal of a target at or below 50 %; PHY-Y ×1.15 approx(a.amount, want(yak, 0.45), `${label(f)}: 地面, 45 %`); approx(b.amount, want(yak, 0.8), `${label(f)}: 地面, 80 %`); approx(c.amount, want(tex, 0.45), `${label(f)}: high ground, 45 %`); + if (mod === ISWA) { + // both injured: one heal per attack (no 集成战略 second target) + yak.hp = yak.s.maxHp * 0.5; tex.hp = tex.s.maxHp * 0.5; + const n = heals(h, u).length; + assert.ok(h.runUntil(() => heals(h, u).length > n, 4)); + h.step(); + const att = new Set(heals(h, u).slice(n).map((x) => x.t)); + assert.ok([...att].every((t) => heals(h, u).filter((x) => x.t === t).length === 1), `${label(f)}: one target per heal`); + } done(h); } });