// server/sim/content/items/battle.js — battle side of the 56 equipment items (research 04 §4, docs/DATA.md §5). // // Every operator's `unit.items` (equipped ids; merged items are golden `_b` ids with their own numbers) becomes a set of // item grants. A grant = one concrete item record installed on one unit through `installItem(battle, unit, itemId)`: // its behaviour registers hooks / timers / buffs through a Scope, and `grant.dispose()` removes all of it again (used // by lendItemEffects: 萨尔贡 × 娜仁图亚 lends a carrier's items to its neighbours for 60 s). // // Numbers always come from the concrete record's buffs (support.buffsOf: `{ ...bb, ...bbStr }` per buff, looked up by // the buff's bbStr.key — the flattened `params` of data/items.json lose duplicate keys such as 蒸汽之心 attack_speed). // // Stat rule (research 02 §2.1, PRTS "直接乘算"; research 04 §2 assumed additive %): every equipment percentage is its own // multiplier on the final stat — atk/def/max_hp → atkMul/defMul/hpMul = 1 + x; attack_speed → aspd (additive); // magic_resistance → resFlat; respawn_time → redeployMul 1 + x; sp_recovery_per_sec → spRecoveryFlat; // magic_resist_penetrate → resIgnorePct; taunt_level → taunt. Two different items on one unit both apply, and a // normal + golden copy of the same item may coexist (different ids → different buff keys) and both apply. // // Gating: "若携带者为【X】盟约干员" = memberOf (support.isMember: own bonds + 变形同构体 grants; 调和 members only while the // core bond is active). Combo partners (bbStr.equip_chess_id / other_equip) are item keys without the _a/_b suffix: // either quality satisfies them; lent items count as carried. Both are evaluated live, at the moment of use. // // Hook priorities (non-default): 'fatal' — consumable death savers (坚固维式重锤's first lethal hit, M3茧甲 revives) // run LAST (PRIO_REVIVE −100: "被击倒时" = nothing else prevented the knock-down, so a skill's / talent's own undying // (kits use 10 … −60) never wastes a charge); 骑士戒律's free in-skill undying runs early (20). Flat damage reduction // 'hit' −10 (after the other damage modifiers). Proc damage dealt by items carries the tag 'item' and never // re-triggers item procs. // // 蒸汽之心 (Victoria carrier) — LIVE model: the hammer types (灼燃/坚固/加速/战栗维式重锤) carried by the owner's operators // that are on the field right now (cached ≤ 0.5 s, dropped whenever an operator deploys or leaves) are granted to the // carrier; a hammer the carrier carries itself counts twice ("效果变为2倍"). Implemented as one shared hammer runtime // per unit whose multiplier // m = own copies of that hammer + (1 if 蒸汽之心 applies and the type is on the field): burn ×m of arts damage, // undying duration ×m, ASPD ×m, tremble chance ×m. Granted hammers without an own copy use 蒸汽之心's blackboard. import { num, itemRecord, itemKeyOf, buffsOf, isOp, onField, unitBonds, isMember, bondActive, isGroundOp, frontTile, alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, } from '../support/index.js'; import { mitigate } from '../../damage.js'; // ===================================================================================================================== // data helpers /** Buff params `{ ...bb, ...bbStr }` of the record's buff whose bbStr.key is `bbKey` (null when absent). */ function bp(rec, bbKey) { for (const b of buffsOf(rec)) if (b.bbKey === bbKey) return b.p; return null; } /** Partner key of a combo buff (equip_chess_id / other_equip), without suffix. */ function partnerOf(p) { if (!p) return null; if (typeof p.equip_chess_id === 'string' && p.equip_chess_id) return itemKeyOf(p.equip_chess_id); if (typeof p.other_equip === 'string' && p.other_equip) return itemKeyOf(p.other_equip.split(',')[0].trim()); return null; } const isProc = (dmg) => !!(dmg && dmg.tags && dmg.tags.indexOf('item') >= 0); const procTags = (k) => ['item', `item:${k}`]; const chance = (battle, p) => p > 0 && (p >= 1 || battle.rng() < p); /** 'fatal' priorities (see header). */ export const PRIO_REVIVE = -100; const PRIO_FREE_UNDYING = 20; /** Stat keys of a stat buff → mods (直接乘算, see header). */ const STAT_BUFFS = new Set([ 'attr_common_global_buff', 'act1autochess_equip_acarm056_global_buff', 'act1autochess_equip_acarm050_global_buff', 'magic_penetrate_global_buff', 'act1autochess_equip_acarm054_global_buff', ]); function statMods(p) { const m = {}; let any = false; const set = (k, v) => { m[k] = v; any = true; }; if (num(p.atk)) set('atkMul', 1 + num(p.atk)); if (num(p.def)) set('defMul', 1 + num(p.def)); if (num(p.max_hp)) set('hpMul', Math.max(0.01, 1 + num(p.max_hp))); if (num(p.attack_speed)) set('aspd', num(p.attack_speed)); if (num(p.magic_resistance)) set('resFlat', num(p.magic_resistance)); if (num(p.respawn_time)) set('redeployMul', Math.max(0, 1 + num(p.respawn_time))); if (num(p.sp_recovery_per_sec)) set('spRecoveryFlat', num(p.sp_recovery_per_sec)); if (num(p.magic_resist_penetrate)) set('resIgnorePct', num(p.magic_resist_penetrate)); if (num(p.taunt_level)) set('taunt', num(p.taunt_level)); return any ? m : null; } // ---- hammers (蒸汽之心) — derived from data: hammer records carry bbStr.big_hammer on their special buff const STEAM_KEY = 'chess_item_6_05_e'; const STEAM_BUFF = 'act1autochess_equip_acarm079_global_buff'; function hammerTypeOf(p) { if (!p) return null; if (p.damage_scale != null) return 'burn'; if (p.undeadable_duration != null) return 'undying'; if (p.disarmed_duration != null) return 'tremble'; if (p.attack_speed != null) return 'aspd'; return null; } /** hammer item key → { type, bbKey } */ const HAMMERS = new Map(); function hammerInfo(key) { if (!HAMMERS.size) { const steam = itemRecord(`${STEAM_KEY}_a`); const sp = steam ? bp(steam, STEAM_BUFF) : null; const keys = sp ? Object.keys(sp).filter((k) => /^hammer_\d+$/.test(k)).map((k) => itemKeyOf(sp[k])) : []; for (const k of keys) { const rec = itemRecord(`${k}_a`); const b = rec ? buffsOf(rec).find((x) => x.p.big_hammer != null) : null; const type = b ? hammerTypeOf(b.p) : null; if (type) HAMMERS.set(k, { type, bbKey: b.bbKey }); } if (!HAMMERS.size) HAMMERS.set('__none__', null); } return HAMMERS.get(key) ?? null; } // ===================================================================================================================== // membership / carrying /** "若携带者为【X】盟约干员": own bonds (incl. 变形同构体 grants), or 调和 while the core bond X is active. */ export function memberOf(battle, u, bondId) { if (!isOp(u) || !bondId) return false; if (unitBonds(u).includes(bondId)) return true; return isMember(battle, u, bondId) && bondActive(battle, u.ownerId, bondId); } function runtime(battle) { return battleStore(battle, 'items:rt', () => { const rt = { grants: new Map(), hammers: new Map(), field: new Map(), lends: new Map() }; // the per-player "hammers on the field" cache (蒸汽之心) is dropped whenever an operator enters or leaves the field: // a cache filled before the initial deployment must not hide the field's hammers for the first 0.5 s const drop = (c) => { if (c.unit && c.unit.kind === 'op') rt.field.clear(); }; battle.on('deploy', drop); battle.on('death', drop); return rt; }); } function grantsOf(rt, u) { let m = rt.grants.get(u); if (!m) { m = new Map(); rt.grants.set(u, m); } return m; } /** Does `u` carry an item of this key (own equipment or a lent grant)? */ export function carries(battle, u, key) { if (!key || !u) return false; for (const id of itemsOf(u)) if (itemKeyOf(id) === key) return true; const g = runtime(battle).grants.get(u); if (g) for (const x of g.values()) if (x.lent && x.key === key) return true; return false; } // ===================================================================================================================== // Scope: everything an item behaviour registers, removable at once class Scope { constructor(battle, unit, tag) { this.b = battle; this.u = unit; this.tag = tag; this.hooks = []; this.scheds = []; this.buffs = []; this.fns = []; this.disposed = false; } key(part) { return `${this.tag}:${part}`; } on(name, fn, priority = 0) { const h = this.b.on(name, fn, { owner: this.u, priority }); if (h) this.hooks.push(h); return h; } every(sec, fn, opts = {}) { const s = this.b.every(sec, fn, { owner: this.u, ...opts }); this.scheds.push(s); return s; } after(sec, fn) { const s = this.b.after(sec, fn, { owner: this.u }); this.scheds.push(s); return s; } /** Match-long stat buff on the carrier (persist, survives death/redeploy). */ stat(part, mods, extra = {}) { const k = this.key(part); const r = passiveBuff(this.b, this.u, k, mods, extra); if (r) this.track(this.u, k); return r; } /** Any buff on any unit, removed at dispose. */ buff(target, b) { const r = this.b.addBuff(target, b); if (r) this.track(target, b.key); return r; } track(target, key) { if (!this.buffs.some(([t, k]) => t === target && k === key)) this.buffs.push([target, key]); } onDispose(fn) { this.fns.push(fn); } dispose() { if (this.disposed) return; this.disposed = true; for (const h of this.hooks) this.b.off(h); for (const s of this.scheds) s.cancel(); for (const [t, k] of this.buffs) this.b.removeBuff(t, k); for (const f of this.fns) { try { f(); } catch { /* keep disposing */ } } this.hooks = []; this.scheds = []; this.buffs = []; this.fns = []; } } // ===================================================================================================================== // hammer runtime (shared per unit: own hammers + 蒸汽之心) function fieldHammers(battle, rt, pid) { let c = rt.field.get(pid); if (!c) { c = { t: -Infinity, set: new Set() }; rt.field.set(pid, c); } if (battle.time - c.t >= 0.5 - 1e-9) { c.t = battle.time; c.set.clear(); for (const a of battle.allyUnits) { if (a.kind !== 'op' || a.ownerId !== pid || !onField(a)) continue; for (const id of itemsOf(a)) { const h = hammerInfo(itemKeyOf(id)); if (h) c.set.add(h.type); } } } return c.set; } function hammerState(battle, rt, u) { let hs = rt.hammers.get(u); if (!hs) { hs = { own: { burn: 0, undying: 0, aspd: 0, tremble: 0 }, params: {}, steam: 0, steamP: null, refs: 0, scope: null, aspdCur: 0, undyingUsed: false, undyingUntil: -Infinity }; rt.hammers.set(u, hs); } return hs; } /** Effective multiplier of a hammer type on `u` and its params (null when the type does not apply). */ function hammerMul(battle, rt, u, hs, type) { const own = hs.own[type]; const steam = hs.steam > 0 && memberOf(battle, u, 'victoriaShip') && fieldHammers(battle, rt, u.ownerId).has(type) ? 1 : 0; return own + steam; } const hammerParams = (hs, type) => hs.params[type] ?? hs.steamP ?? null; function hammerRefresh(battle, rt, u, hs) { if (!hs.scope) return; const p = hammerParams(hs, 'aspd'); const v = p ? num(p.attack_speed) * hammerMul(battle, rt, u, hs, 'aspd') : 0; if (v === hs.aspdCur) return; hs.aspdCur = v; const k = hs.scope.key('aspd'); if (v) hs.scope.stat('aspd', { aspd: v }); else battle.removeBuff(u, k); } function hammerAcquire(battle, rt, u) { const hs = hammerState(battle, rt, u); hs.refs++; if (hs.scope) return hs; const S = new Scope(battle, u, `item:hammer#${u.id}`); hs.scope = S; hs.aspdCur = 0; S.every(0.5, () => hammerRefresh(battle, rt, u, hs), { immediate: true }); // 灼燃: arts damage also deals burn (元素损伤) = damage_scale × damage × m S.on('damaged', (c) => { if (c.source !== u || c.type !== 'arts' || !(c.amount > 0) || isProc(c.dmg) || !c.target || c.target.side !== 'enemy' || !c.target.alive) return; const p = hammerParams(hs, 'burn'); if (!p) return; const m = hammerMul(battle, rt, u, hs, 'burn'); if (m > 0) battle.dealDamage(u, c.target, { type: 'element', element: 'burn', amount: c.amount * num(p.damage_scale) * m, tags: procTags('hammer_burn') }); }); // 战栗: ground carrier, on attack prob × m ⇒ 战栗 disarmed_duration s S.on('attack', (c) => { if (c.attacker !== u || !isGroundOp(u)) return; const p = hammerParams(hs, 'tremble'); if (!p) return; const m = hammerMul(battle, rt, u, hs, 'tremble'); if (!(m > 0)) return; const pr = Math.min(1, num(p.prob) * m); for (const t of c.targets) { if (t && t.side === 'enemy' && t.alive && chance(battle, pr)) battle.applyStatus(t, 'tremble', { duration: num(p.disarmed_duration, 2), source: u }); } }); // 坚固: first lethal hit per battle ⇒ HP never below 1 for undeadable_duration × m s S.on('fatal', (c) => { if (c.unit !== u || c.prevented) return; if (battle.time < hs.undyingUntil) { c.prevented = true; return; } if (hs.undyingUsed) return; const p = hammerParams(hs, 'undying'); if (!p) return; const m = hammerMul(battle, rt, u, hs, 'undying'); if (!(m > 0)) return; hs.undyingUsed = true; const dur = num(p.undeadable_duration, 8) * m; hs.undyingUntil = battle.time + dur; c.prevented = true; fxOn(battle, 'undying', u, 'item:hammer', 'chess_item_3_09_e', { duration: dur }); }, PRIO_REVIVE); return hs; } function hammerRelease(battle, rt, u, hs) { hs.refs = Math.max(0, hs.refs - 1); if (hs.refs > 0 || !hs.scope) { hammerRefresh(battle, rt, u, hs); return; } hs.scope.dispose(); hs.scope = null; hs.aspdCur = 0; } // ===================================================================================================================== // item behaviours: key → (battle, u, rec, S, ctx) — register through S (disposed with the grant) /** Damage mitigation estimate for flat DR / weakness damage (same formula as damage.js). */ function mitigated(amount, type, target, source, dmg) { const ss = source && source.s ? source.s : null; return mitigate(amount, type, target.s, { defIgnorePct: (dmg.defIgnorePct || 0) + (ss ? ss.defIgnorePct : 0), defIgnoreFlat: (dmg.defIgnoreFlat || 0) + (ss ? ss.defIgnoreFlat : 0), resIgnorePct: (dmg.resIgnorePct || 0) + (ss ? ss.resIgnorePct : 0), resIgnoreFlat: (dmg.resIgnoreFlat || 0) + (ss ? ss.resIgnoreFlat : 0), }); } /** Per enemy target of an attack by `u` (heals excluded). */ function onAttackEnemies(S, u, fn) { S.on('attack', (c) => { if (c.attacker !== u) return; const ts = c.targets; for (let i = 0; i < ts.length; i++) { const t = ts[i]; if (t && t.side === 'enemy' && t.alive) fn(t, c); } }); } /** Generic buff-key behaviours (shared by several items). */ const BY_BUFF = { // 源石溶剂: 每秒流失 damage 点生命值 (HP loss, may kill) periodic_damage(battle, u, p, S) { const d = num(p.damage); if (!(d > 0)) return; S.stat('drain', null, { interval: 1, onTick: ({ unit }) => { if (unit.deployed) battle.loseHp(unit, d, { source: unit }); } }); }, // 奥术法阵: 攻击使目标失去特殊能力 silence s silence_attachment(battle, u, p, S) { const d = num(p.silence); if (d > 0) onAttackEnemies(S, u, (t) => battle.applyStatus(t, 'silence', { duration: d, source: u })); }, // 海沟实验体: 伤害减免 value per damage instance (after DEF/RES) halfidle_block_fixed_damage(battle, u, p, S) { const v = num(p.value); if (!(v > 0)) return; S.on('hit', (c) => { if (c.target !== u) return; const d = c.dmg; if (d.type === 'element') return; const base = mitigated(d.amount * d.mul, d.type, u, c.source, d); if (!(base > 0)) return; d.mul *= Math.max(0, base - v) / base; }, -10); }, // 叙拉古正装: allies on the two tiles beside the carrier ("左右两格", perpendicular to its direction: the facing-RIGHT // offsets (±1, 0) rotated by its dir) get ASPD +attack_speed act2autochess_equip_acarm121_ability(battle, u, p, S) { const v = num(p.attack_speed); if (!v) return; const key = S.key('side'); const mods = { aspd: v }; const SIDES = [[1, 0], [-1, 0]]; S.every(0.5, () => { if (!onField(u)) return; for (const a of alliesAround(battle, u, SIDES)) if (a.kind === 'op') S.buff(a, { key, duration: 0.6, mods, refresh: 'replace' }); }, { immediate: true }); }, }; /** Item-key behaviours. */ const BY_ITEM = { // 精准狙击镜: damage ×damage_scale against targets ≥ radius tiles away chess_item_3_02_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm063_global_buff'); if (!p) return; const r = num(p.radius, 3), sc = num(p.damage_scale, 1); S.on('hit', (c) => { if (c.source !== u || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return; if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc; }); }, // 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one multiplier 1 + atk × stacks chess_item_3_04_e(battle, u, rec, S) { const p = bp(rec, 'act1vautochess_equip_acarm024_global_buff'); if (!p) return; let n = 0; const cap = Math.max(1, Math.floor(num(p.atk_buff_cnt, 10))); S.on('skillStart', (c) => { if (c.unit !== u || n >= cap || !chance(battle, num(p.prob, 1))) return; n++; S.stat('stacks', { atkMul: 1 + num(p.atk) * n }); }); }, // 迅捷作战粮: on deploy SP += sp_each_person × (1 + other operators sharing a bond) chess_item_3_05_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm051_global_buff'); const each = p ? num(p.sp_each_person) : 0; if (!(each > 0)) return; S.on('deploy', (c) => { if (c.unit !== u || !u.skill) return; const mine = unitBonds(u); let n = 0; if (mine.length) { for (const a of battle.allyUnits) { if (a === u || a.kind !== 'op' || a.ownerId !== u.ownerId || a.removed) continue; if (!(c.initial ? !a.removed : onField(a))) continue; if (unitBonds(a).some((b) => mine.includes(b))) n++; } } u.skill.gainSp(each * (1 + n), 'item'); }); }, // 歌利亚头盔: HP +init_max_hp; deploy with no operator on the tile in front ⇒ HP +ex_max_hp (until it leaves) chess_item_3_06_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm049_global_buff'); if (!p) return; if (num(p.init_max_hp)) S.stat('hp', { hpMul: 1 + num(p.init_max_hp) }); const ex = num(p.ex_max_hp); if (!ex) return; const key = S.key('front'); const check = (initial) => { const [fr, fc] = frontTile(u); let occupied = false; for (const a of battle.allyUnits) { if (a === u || a.kind !== 'op' || a.removed) continue; const here = initial ? a.homeR === fr && a.homeC === fc : onField(a) && a.tileR === fr && a.tileC === fc; if (here) { occupied = true; break; } } if (!occupied) S.buff(u, { key, mods: { hpMul: 1 + ex }, refresh: 'replace' }); else battle.removeBuff(u, key); }; S.on('deploy', (c) => { if (c.unit === u) check(!!c.initial); }); if (onField(u)) check(false); }, // 突袭手雷: within `duration` s after each deploy, attacks stun the target `stun` s chess_item_3_11_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm064_global_buff'); if (!p) return; const win = num(p.duration), st = num(p.stun); if (!(win > 0 && st > 0)) return; onAttackEnemies(S, u, (t) => { if (battle.time - u.deployedAt <= win + 1e-9) battle.applyStatus(t, 'stun', { duration: st, source: u }); }); }, // 有限加速器: every attack or heal ⇒ ASPD +attack_speed (≤ max_buff_cnt stacks, whole battle) chess_item_4_03_e(battle, u, rec, S) { const p = bp(rec, 'act1vautochess_equip_acarm037_global_buff'); if (!p || !num(p.attack_speed)) return; const key = S.key('stacks'); const cap = Math.max(1, Math.floor(num(p.max_buff_cnt, 60))); const mods = { aspd: num(p.attack_speed) }; S.on('attack', (c) => { if (c.attacker !== u) return; S.buff(u, { key, mods, refresh: 'stack', stacks: 1, maxStacks: cap, persist: true, allowDead: true }); }); }, // 伪装服: first damage taken in the battle ⇒ 隐匿 `duration` s chess_item_4_04_e(battle, u, rec, S) { const p = bp(rec, 'act2autochess_equip_acarm055_global_buff'); const d = p ? num(p.duration) : 0; if (!(d > 0)) return; let used = false; S.on('damaged', (c) => { if (used || c.target !== u || !(c.amount > 0) || !u.alive) return; used = true; battle.applyStatus(u, 'stealth', { duration: d, source: u }); fxOn(battle, 'camouflage', u, 'item:chess_item_4_04_e', rec.id, { duration: d }); }); }, // 防暴盾: while blocking, damage from units it does not block ×damage_scale chess_item_4_05_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm060_global_buff'); if (!p) return; const sc = num(p.damage_scale, 1); S.on('hit', (c) => { // "受到来自非自身阻挡单位的伤害": only damage whose source is a unit the carrier does not block (terrain / sourceless // bursts and its own HP loss are not "from a unit") const src = c.source; if (c.target !== u || !u.blocking.length || c.dmg.type === 'element' || !src || src === u) return; if (u.blocking.indexOf(src) >= 0) return; c.dmg.mul *= sc; }); }, // 休眠子裔: each attacked target ⇒ heal self hp_ratio × max HP chess_item_4_06_e(battle, u, rec, S) { const p = bp(rec, 'act1vautochess_equip_acarm025_global_buff'); const r = p ? num(p.hp_ratio) : 0; if (r > 0) onAttackEnemies(S, u, () => battle.heal(u, u, u.s.maxHp * r, { self: true })); }, // 卡西米尔竞技旗: damage ×damage_scale for `interval` s after each deploy, then −damage_scale_minus every ex_interval s chess_item_4_07_e(battle, u, rec, S) { const p = bp(rec, 'act2autochess_equip_acarm120_global_buff'); if (!p) return; const sc = num(p.damage_scale, 1), win = num(p.interval, 15), step = Math.max(0.1, num(p.ex_interval, 0.5)), minus = num(p.damage_scale_minus); S.on('hit', (c) => { if (c.source !== u || c.dmg.type === 'element') return; const t = battle.time - u.deployedAt; let m = sc; if (t > win + 1e-9) m = minus < 0 ? Math.max(1, sc + minus * Math.ceil((t - win) / step - 1e-9)) : 1; if (m !== 1) c.dmg.mul *= m; }); }, // 拉特兰桥夹: ammo skill down to 1 bullet ⇒ prob: +ceil(ammo_percent × ammo) bullets (≤ max_trigger_cnt per deploy) chess_item_4_08_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm057_global_buff'); if (!p) return; const cap = Math.floor(num(p.max_trigger_cnt, 3)); let seq = -1, n = 0; S.on('ammoUsed', (c) => { if (c.unit !== u || c.left !== 1 || !c.skill) return; if (u.deploySeq !== seq) { seq = u.deploySeq; n = 0; } if (n >= cap || !chance(battle, num(p.prob))) return; n++; c.skill.addAmmo(Math.max(1, Math.ceil(num(p.ammo_percent) * c.skill.ammo - 1e-9))); fxOn(battle, 'buff', u, 'item:chess_item_4_08_e', rec.id); }); }, // 浓缩嗅盐: while HP ratio > hp_ratio, immune to 晕眩/冻结 … special states chess_item_4_10_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm059_global_buff'); if (!p) return; const thr = num(p.hp_ratio, 1); S.on('beforeStatus', (c) => { if (c.target === u && CC.has(c.status) && u.hpRatio > thr) c.cancel = true; }); }, // 护盾无人机: heals ⇒ prob: target gets 1 shield layer (≤ max_stack_cnt) chess_item_4_11_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm061_global_buff'); if (!p) return; const pr = num(p.prob), cap = Math.max(1, Math.floor(num(p.max_stack_cnt, 1))); S.on('heal', (c) => { if (c.source !== u || !c.target || (c.opts && (c.opts.regen || c.opts.self)) || !(c.amount > 0)) return; const cur = c.target.findBuff(SHIELD_KEY); if (cur && cur.shieldHits >= cap) return; if (!chance(battle, pr)) return; if (addShieldLayer(battle, c.target, SHIELD_KEY, cap)) fxOn(battle, 'shield', c.target, 'item:chess_item_4_11_e', rec.id); }); }, // M3茧甲: knocked down in battle ⇒ revive at full HP (max_respawn_cnt per battle) chess_item_4_12_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm068_global_buff'); const max = p ? Math.floor(num(p.max_respawn_cnt, 1)) : 0; if (!(max > 0)) return; let used = 0; S.on('fatal', (c) => { if (c.unit !== u || c.prevented || used >= max) return; used++; c.prevented = true; u.hp = u.s.maxHp; fxOn(battle, 'revive', u, 'item:chess_item_4_12_e', rec.id, { left: max - used }); }, PRIO_REVIVE); }, // 催泪瓦斯: on attack prob ⇒ 1 麻痹 stack chess_item_5_01_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm058_global_buff'); const pr = p ? num(p.prob) : 0; if (pr > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'palsy', { value: 1, source: u }); }); }, // 谢拉格不融冰: on attack prob ⇒ 寒冷 `cold` s chess_item_5_02_e(battle, u, rec, S) { const p = bp(rec, 'act1vautochess_equip_acarm003_global_buff'); if (!p) return; const pr = num(p.prob), d = num(p.cold); if (pr > 0 && d > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'cold', { duration: d, source: u }); }); }, // 双模机械臂: physical/arts damage becomes 弱点伤害 (whichever type the target resists less) chess_item_5_03_e(battle, u, rec, S) { S.on('hit', (c) => { if (c.source === u) weaknessRetype(c.dmg, u, c.target); }, 5); }, // 天马之盔: with 天马之枪 ⇒ regen hp_recovery_per_sec_by_max_hp_ratio × max HP per second chess_item_5_09_e(battle, u, rec, S) { const p = bp(rec, 'act2autochess_equip_acarm118_global_buff'); const partner = partnerOf(p); const r = p ? num(p.hp_recovery_per_sec_by_max_hp_ratio) : 0; if (!partner || !(r > 0)) return; S.stat('set', null, { interval: 1, onTick: ({ unit }) => { if (unit.deployed && carries(battle, unit, partner)) battle.heal(unit, unit, unit.s.maxHp * r, { self: true }); } }); }, // 天马之枪: with 天马之盔 ⇒ every damage instance also deals atk_scale × ATK true damage chess_item_6_01_e(battle, u, rec, S) { const p = bp(rec, 'act2autochess_equip_acarm117_global_buff'); const partner = partnerOf(p); const sc = p ? num(p.atk_scale) : 0; if (!partner || !(sc > 0)) return; S.on('damaged', (c) => { if (c.source !== u || isProc(c.dmg) || c.type === 'element' || !c.target || c.target.side !== 'enemy' || !c.target.alive) return; if (!carries(battle, u, partner)) return; battle.dealDamage(u, c.target, { amount: u.s.atk * sc, type: 'true', canDodge: false, tags: procTags('pegasus') }); }); }, // 铳骑之威 (拉特兰): on attack 35 % ⇒ extra bullet at 1 enemy in range, atk_scale_1 (with 拉特兰桥夹 atk_scale_2) × ATK phys chess_item_6_02_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm076_global_buff'); if (!p) return; const partner = partnerOf(p); S.on('attack', (c) => { if (c.attacker !== u || !c.targets.some((t) => t && t.side === 'enemy') || !memberOf(battle, u, 'lateranoShip')) return; if (!chance(battle, num(p.prob))) return; const cands = battle.enemiesInKeys(u.rangeKeys, u, u.profile); const t = battle.rng.pick(cands); if (!t) return; const sc = partner && carries(battle, u, partner) ? num(p.atk_scale_2) : num(p.atk_scale_1); battle.fx('strike', { x: t.x, y: t.y, id: t.id, src: 'item:chess_item_6_02_e', key: rec.id, from: u.id }); battle.dealDamage(u, t, { amount: u.s.atk * sc, type: 'phys', tags: procTags('gunknight') }); }); }, // 天师古鼎 (炎): ASPD +attack_speed × min(operators gained this round, max_cnt) for the battle chess_item_6_03_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm077_global_buff'); if (!p || !memberOf(battle, u, 'yanShip')) return; const k = Math.min(Math.floor(num(p.max_cnt, 3)), Math.max(0, Math.floor(num(contentInfo(battle, u.ownerId).roundStats?.gainedChess)))); if (k > 0) S.stat('yan', { aspd: num(p.attack_speed) * k }); }, // 海沟实验体 (阿戈尔): hit taken ⇒ atk_scale × ATK arts to the source (lock_duration CD); + 阿戈尔重刃 ⇒ one more hit chess_item_6_04_e(battle, u, rec, S) { const p = bp(rec, 'act2autochess_equip_acarm078_global_buff'); if (!p) return; const partner = partnerOf(p); const cd = num(p.lock_duration, 0.5), sc = num(p.atk_scale); let ready = -Infinity; S.on('damaged', (c) => { const src = c.source; if (c.target !== u || !src || src.side !== 'enemy' || !src.alive || battle.time < ready || isProc(c.dmg)) return; if (!u.alive || !memberOf(battle, u, 'egirShip')) return; ready = battle.time + cd; const hits = partner && carries(battle, u, partner) ? 2 : 1; for (let i = 0; i < hits && src.alive; i++) battle.dealDamage(u, src, { amount: u.s.atk * sc, type: 'arts', tags: procTags('trench') }); }); }, // 蒸汽之心 (维多利亚): hammer effects of the field (see header) chess_item_6_05_e(battle, u, rec, S, g) { const rt = runtime(battle); const hs = hammerAcquire(battle, rt, u); hs.steam++; if (!hs.steamP) hs.steamP = bp(rec, STEAM_BUFF); hammerRefresh(battle, rt, u, hs); S.onDispose(() => { hs.steam = Math.max(0, hs.steam - 1); hammerRelease(battle, rt, u, hs); }); }, // 耶拉冈德之泪 (谢拉格): every interval s, cold/frozen enemies in range take atk_scale (+不融冰: atk_scale_ex) × ATK arts chess_item_6_06_e(battle, u, rec, S) { const p = bp(rec, 'act2autochess_equip_acarm102_ability'); if (!p) return; const partner = partnerOf(p); S.every(Math.max(0.1, num(p.interval, 1)), () => { if (!onField(u) || !memberOf(battle, u, 'kjeragShip')) return; const sc = partner && carries(battle, u, partner) ? num(p.atk_scale_ex) : num(p.atk_scale); for (const e of battle.enemiesInKeys(u.rangeKeys, u, { canHitFly: true })) { const f = e.s.flags; if (f.cold || f.freeze) battle.dealDamage(u, e, { amount: u.s.atk * sc, type: 'arts', canDodge: false, tags: procTags('tears') }); } }); }, // 黄沙罗盘: 初始技力 +init_sp each deploy; (萨尔贡) first skill end +sp; + 萨尔贡浓茶: each cast all 萨尔贡 +addition_sp chess_item_6_07_e(battle, u, rec, S) { const p = bp(rec, 'act1autochess_equip_acarm103_global_buff'); if (!p) return; const partner = partnerOf(p); const init = num(p.init_sp); if (init > 0) { S.on('deploy', (c) => { if (c.unit === u && u.skill) u.skill.gainSp(init, 'init'); }); if (onField(u) && u.skill) u.skill.gainSp(init, 'init'); } let firstDone = false; S.on('skillEnd', (c) => { if (c.unit !== u || firstDone || c.reason === 'death' || !memberOf(battle, u, 'sargonShip')) return; firstDone = true; if (u.skill && u.alive) u.skill.gainSp(num(p.sp), 'item'); }); S.on('skillStart', (c) => { if (c.unit !== u || !partner || !memberOf(battle, u, 'sargonShip') || !carries(battle, u, partner)) return; const n = num(p.addition_sp); if (!(n > 0)) return; for (const a of battle.allyUnits) { if (a.kind === 'op' && a.ownerId === u.ownerId && onField(a) && a.skill && memberOf(battle, a, 'sargonShip')) a.skill.gainSp(n, 'item'); } }); }, // 骑士戒律 (卡西米尔): skill start ⇒ 20 s aura: enemies in range ASPD ×attack_speed, move ×move_speed; // + 卡西米尔竞技旗: during the (timed) skill ATK +atk, lethal damage does not retreat it — it retreats when the skill ends chess_item_6_10_e(battle, u, rec, S) { const p = bp(rec, 'act2autochess_equip_acarm119_global_buff'); const a = bp(rec, 'act2autochess_equip_acarm119_ability'); if (!p) return; const partner = partnerOf(p); const win = num(p.duration, 20); const aspdMul = a ? num(a.attack_speed, 1) : 1, moveMul = a ? num(a.move_speed, 1) : 1; const auraKey = S.key('aura'), comboKey = S.key('combo'); let until = -Infinity, combo = false, doomed = false; S.on('skillStart', (c) => { if (c.unit !== u || !memberOf(battle, u, 'kazimierzShip')) return; until = battle.time + win; if (partner && carries(battle, u, partner) && c.skill && c.skill.isTimed) { combo = true; doomed = false; S.buff(u, { key: comboKey, mods: { atkMul: 1 + num(p.atk) } }); } }); S.on('skillEnd', (c) => { if (c.unit !== u || !combo) return; combo = false; battle.removeBuff(u, comboKey); if (doomed && c.reason !== 'death') { doomed = false; battle.after(0, () => { if (u.alive) battle.retreat(u, { reason: 'retreat' }); }); } }); S.on('fatal', (c) => { if (c.unit !== u || c.prevented || !combo) return; c.prevented = true; doomed = true; }, PRIO_FREE_UNDYING); S.every(0.5, () => { if (battle.time >= until || !onField(u)) return; for (const e of battle.enemiesInKeys(u.rangeKeys, u, { canHitFly: true })) { S.buff(e, { key: auraKey, duration: 0.6, refresh: 'replace', mods: { aspd: -(1 - aspdMul) * num(e.base.aspd, 100), moveMul } }); } }, { immediate: true }); }, // 家族徽章 (叙拉古): while 隐匿 ATK grows atk_per_sec/s up to +max_atk until the first damage after 隐匿 ends // (or leaving the field); + 叙拉古正装 ⇒ that first hit also deals atk_scale × ATK true damage chess_item_6_11_e(battle, u, rec, S) { const p = bp(rec, 'act2autochess_equip_acarm122_global_buff'); if (!p) return; const partner = partnerOf(p); const key = S.key('stealth'); const per = num(p.atk_per_sec), max = num(p.max_atk, 1); const st = { bonus: 0, wasStealth: false, armed: false, seq: -1 }; const TICKS = 0.25; S.every(TICKS, () => { if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; st.armed = false; st.wasStealth = false; } if (!onField(u) || !memberOf(battle, u, 'siracusaShip')) return; const stealth = !!u.s.flags.stealth; if (stealth && st.bonus < max) { st.bonus = Math.min(max, st.bonus + per * TICKS); S.buff(u, { key, mods: { atkMul: 1 + st.bonus }, refresh: 'replace' }); } if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true; st.wasStealth = stealth; }); S.on('damaged', (c) => { if (c.source !== u || !st.armed || isProc(c.dmg) || !(c.amount > 0)) return; st.armed = false; if (partner && carries(battle, u, partner) && c.target && c.target.alive && c.target.side === 'enemy') { battle.dealDamage(u, c.target, { amount: u.s.atk * num(p.atk_scale), type: 'true', canDodge: false, tags: procTags('crest') }); battle.fx('strike', { x: c.target.x, y: c.target.y, id: c.target.id, src: 'item:chess_item_6_11_e', key: rec.id, from: u.id }); } st.bonus = 0; battle.removeBuff(u, key); }); S.on('death', (c) => { if (c.unit === u) { st.bonus = 0; st.armed = false; } }); }, }; /** 浓缩嗅盐 "免疫晕眩、冻结等特殊状态" — research 04 §4 list (stun/freeze/sleep/levitate/palsy). 寒冷 is a debuff, not a * control state: it still applies, but the freeze of a second 寒冷 is itself a `freeze` status and is cancelled. */ const CC = new Set(['stun', 'freeze', 'sleep', 'levitate', 'palsy']); const SHIELD_KEY = 'item:shield_drone'; /** * 弱点伤害 (双模机械臂, band 陈 以己之长): a physical or arts DamageInfo from `source` becomes whichever of the two types * `target` resists less (same mitigation formula as damage.js, incl. the source's ignore stats). Mutates `dmg.type`. */ export function weaknessRetype(dmg, source, target) { if (!dmg || !target || !target.s || (dmg.type !== 'phys' && dmg.type !== 'arts')) return; const ph = mitigated(dmg.amount, 'phys', target, source, dmg); const ar = mitigated(dmg.amount, 'arts', target, source, dmg); const best = ar > ph + 1e-9 ? 'arts' : ph > ar + 1e-9 ? 'phys' : dmg.type; if (best !== dmg.type) dmg.type = best; } /** * "获得1层护盾(最多`cap`层)": one shield layer (negates one damage instance) under buff `key`. Returns true when a * layer was added (false at the cap). */ export function addShieldLayer(battle, target, key, cap = 1) { if (!target || !target.alive) return false; const cur = target.findBuff(key); const have = cur ? cur.shieldHits || 0 : 0; if (have >= cap) return false; battle.addBuff(target, { key, shieldHits: have + 1, visible: true, refresh: 'replace' }); return true; } /** Does this record have a battle part? (stat buffs, known buff behaviours, hammer specials, item behaviours) */ export function hasBattleEffect(rec) { if (!rec || rec.itemType !== 'EQUIP') return false; const key = itemKeyOf(rec.id); if (BY_ITEM[key] || hammerInfo(key)) return true; return buffsOf(rec).some((b) => STAT_BUFFS.has(b.bbKey) || BY_BUFF[b.bbKey]); } // ===================================================================================================================== // grants /** * Install the battle effects of item `itemId` on `u`. `lent` grants use their own buff keys (`@lend`). * Returns the grant `{ id, key, lent, dispose() }` or null when the item has no battle part. */ export function installItem(battle, u, itemId, { lent = false } = {}) { const rec = itemRecord(itemId); if (!isOp(u) || !hasBattleEffect(rec)) return null; const rt = runtime(battle); const gm = grantsOf(rt, u); const gid = lent ? `lend:${itemId}` : `own:${itemId}:${gm.size}`; const S = new Scope(battle, u, `item:${itemId}${lent ? '@lend' : ''}`); const key = itemKeyOf(itemId); const grant = { gid, id: itemId, key, lent, scope: S, timer: null, dispose: null }; grant.dispose = () => { if (S.disposed) return; S.dispose(); if (grant.timer) grant.timer.cancel(); if (gm.get(gid) === grant) gm.delete(gid); }; gm.set(gid, grant); try { for (const b of buffsOf(rec)) { if (STAT_BUFFS.has(b.bbKey)) { const mods = statMods(b.p); if (mods) S.stat(`stat:${b.bbKey}`, mods); } else if (BY_BUFF[b.bbKey]) BY_BUFF[b.bbKey](battle, u, b.p, S); } const h = hammerInfo(key); if (h) { const hs = hammerAcquire(battle, rt, u); hs.own[h.type]++; if (!hs.params[h.type]) hs.params[h.type] = bp(rec, h.bbKey); hammerRefresh(battle, rt, u, hs); S.onDispose(() => { hs.own[h.type] = Math.max(0, hs.own[h.type] - 1); hammerRelease(battle, rt, u, hs); }); } if (BY_ITEM[key]) BY_ITEM[key](battle, u, rec, S, grant); } catch (e) { battle._handlerError?.(`item:${itemId}`, u, e); } return grant; } /** Battle install: every operator's equipped items. Nearly free when nobody carries equipment. */ export function install(battle) { let any = false; for (const u of battle.allyUnits) if (u.kind === 'op' && itemsOf(u).length) { any = true; break; } if (!any) return; for (const u of battle.allyUnits) { if (u.kind !== 'op') continue; for (const id of itemsOf(u)) installItem(battle, u, id); } } /** * 萨尔贡 × 娜仁图亚 (P1): temporarily grant `toUnit` the battle effects of every item `fromUnit` carries with * tier ≤ maxTier (meta-only / consume items skipped) for `duration` s. Lending the same item id to the same unit again * refreshes its timer (no stacking, whoever lends it). Returns the number of item effects lent (new + refreshed). */ export function lendItemEffects(battle, fromUnit, toUnit, { maxTier = 5, duration = 60 } = {}) { if (!battle || !isOp(fromUnit) || !isOp(toUnit) || fromUnit === toUnit) return 0; if (!toUnit.alive || toUnit.removed) return 0; const dur = num(duration, 60); if (!(dur > 0)) return 0; const rt = runtime(battle); const gm = grantsOf(rt, toUnit); let n = 0; for (const id of itemsOf(fromUnit)) { const rec = itemRecord(id); if (!rec || num(rec.tier, 99) > maxTier || !hasBattleEffect(rec)) continue; let g = gm.get(`lend:${id}`); if (!g) g = installItem(battle, toUnit, id, { lent: true }); if (!g) continue; if (g.timer) g.timer.cancel(); g.until = battle.time + dur; g.timer = battle.after(dur, () => g.dispose()); n++; } if (n) fxOn(battle, 'buff', toUnit, 'item:lend', 'lend', { from: fromUnit.id, n, duration: dur }); return n; } /** Active grants of a unit (tests / UI): [{ id, key, lent, until? }]. */ export function itemGrants(battle, u) { const g = runtime(battle).grants.get(u); return g ? [...g.values()].map((x) => ({ id: x.id, key: x.key, lent: x.lent, until: x.until })) : []; }