Files
ark-stronghold-protocol/server/sim/content/items/battle.js
T
sganggs 620f88c0d8 Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
2026-10-02 04:19:28 +08:00

864 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (PRTS 盟约记录 "装备效果提供的属性加成均为直接乘算"; 直接乘算 = summed with every other percentage, PRTS 游戏数据
// 基础 — research 04 §2's additive reading; v2.5 had each item its own multiplier): atk/def/max_hp → support directMods
// (atkPct/defPct/hpPct += x; constants.js DIRECT_BONUS_STACKING); 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, directMods,
} from '../support/index.js';
import { mitigate, hasHp } 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; };
// ATK / DEF / max HP "+X%" are 直接乘算 (support directMods: additive with every other percentage)
for (const [k, v] of Object.entries(directMods({ atk: num(p.atk), def: num(p.def), hp: num(p.max_hp) ? Math.max(-0.99, num(p.max_hp)) : 0 }))) set(k, v);
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 — not on a killing blow (the hook runs
// before the kill, at 0 HP: no burst on the corpse)
S.on('damaged', (c) => {
if (c.source !== u || c.type !== 'arts' || !(c.amount > 0) || isProc(c.dmg) || !c.target || c.target.side !== 'enemy' || !hasHp(c.target)) 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 直接乘算 bonus of 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', directMods({ atk: 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', directMods({ hp: 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: directMods({ hp: 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: directMods({ atk: 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: directMods({ atk: 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 })) : [];
}