Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-hsgma2.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

303 lines
21 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/kits/ops/op-hsgma2.js — 斩业星熊 (char_1044_hsgma2) 自选 operator kit: 6★ 驭法铁卫 (重装), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_1044_hsgma2, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, AST-X at stage 1 (tier 5) or 3 (tier 6). Full potential (the owner's decision of 2026-10-07).
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), gamedata_const
// ba.berserk 坚忍 ("根据已损失的生命值获得相应比例的属性加成,损失一定比例时达最大加成") and ba.sluggish 停顿, PRTS 斩业星熊 (业火 备注:
// 负生命值 — "期间持有禁疗,一切生命值变动将先计入负生命值来抵消/增加负生命值…只有负生命值达到上限时才会被正常击倒…负生命值与真实生命值
// 无关", "生命恢复的提供方式为增加自身的“生命回复速度(百分比)”属性,不受治疗加成和禁疗影响", "需成功受到伤害,生命恢复效果才会失效并
// 重新计时"; S1 备注 "反伤效果会直接选中伤害来源,但仅对阵营为敌方的来源造成伤害"; S2 备注 "治疗效果无视自身的禁疗,计算治疗量时以目标
// 受伤前后的生命值差的绝对值为准", "投出的盾牌以自身前方1.2格为起点,逆时针旋转365°,角速度90°/s,碰撞半径1.0,碰撞范围内存在可选目标时
// 速度-80%", "技能持续至盾牌旋转365°并收回时结束"; S3 备注 "技能期间攻击可对空", 临死模式 — 阻回 / 沉默 / 不死, allies' 不死 and the
// lethal excess as her 流失, the forced exit, "若携带X模组,则X模组的“造成额外伤害”效果依然生效"; AST-X 备注 — the extra damage's
// conditions "①此为普通攻击或普通攻击附带的伤害;②伤害来源于斩业星熊自身;③不为本模组特性的伤害…伤害为法术附加伤害", "仅对敌方
// 单位反弹伤害,伤害为法术普通伤害"), PRTS 卫戍协议/帮助 技能策略 (her S3 row "于技能持续时间过半后…关闭技能(进入其技能效果的
// 临死模式)" added in 上半, REMOVED with 下半; "通常不会自动关闭技能").
// - Trait (驭法铁卫) "技能开启时普通攻击会造成法术伤害": physical attacks while no skill runs (the kit's trait dmgType — the
// data's 'phys' too since 0.2.0 WE2 — build-data classifyAttack) and arts while one runs (every skill's attack override); block 3, ground-only
// melee on her 1-1. Module AST-X 无迹 adds "且攻击和受到攻击时对目标额外造成10%攻击力的法术伤害" (trait bb atk_scale): every
// damage instance of her normal attacks (skill attacks included; not the shield, a counter or the module's own damage)
// is followed by atk_scale × ATK arts on that enemy (附加伤害: no dodge), and every enemy damage instance she takes
// returns atk_scale × ATK arts (普通伤害) to its source (tier1 byEnemyAttack) — whether a skill runs or not [ASSUMED: PRTS's list of the checked
// conditions names no skill period].
// - T1 业火 "生命降至0时进入“我执”…": a lethal damage instance leaves her on the field in 我执 instead (the damage past her
// HP opens the negative pool — the engine keeps her at its 1-HP floor for the official 0); in 我执 every later damage
// instance goes to the pool (no HP loss; it still counts as damage taken: 受击回复 SP, counters) until the pool would
// reach max_minus_hp_ratio × max HP — that blow knocks her out as usual (unless a 不死 holds her); she holds 禁疗 (flags
// noHeal + healFree, the status 'healFree'); after [heal].interval s without a damage instance landing she gets the
// 生命回复速度(百分比) attribute [heal].hp_recovery_per_sec_by_max_hp_ratio (an hpRegenRatio buff — not 治疗: 禁疗 does
// not stop it, no 治疗加成 scales it); whatever raises her HP above the floor (that regeneration, S2's heal that ignores
// 禁疗) clears the pool first, and she leaves 我执 once it is empty. A 流失 that reaches 0 counts whole into the pool
// [ASSUMED: the engine reports no HP before a loss].
// - T2 鬼之架势 "在场时,自身获得最高+50法术抗性和35%攻击力的坚忍(损失70%生命值时达到最大加成)": RES +min_magic_resistance and ATK
// +min_atk scaled linearly by the HP lost from max_hp_ratio down to min_hp_ratio (坚忍), re-read every tick; in 我执 at
// its maximum. AST-X stage 3: the maximum at 50 % lost (min_hp_ratio 0.5) and "达到最大加成时,攻击力额外+15%" (atk).
// - S1 恶业苦果 (AUTO, 受击回复 SP, 持续时间无限 — a toggle): ATK +atk, DEF +def, arts attacks; every enemy damage instance
// she takes deals atk_scale × ATK arts to its source (a direct pick of the damage source, enemies only). A self buff: the owner's
// AUTO rule fires it as soon as its SP is full (`trigger: 'SP_FULL'`, as 引星棘刺 S1 — kits/README.md checklist 5).
// - S2 无始无明 (AUTO, attack SP; data DEFAULT): the cast replaces the attack about to be made — the shield throw, three hits
// of attack@atk_scale × ATK arts on every enemy she blocks (none blocked: her target [ASSUMED]); then the shield circles
// her: from 1.2 tiles ahead, counter-clockwise 365° at 90°/s, ×0.2 while a targetable ground enemy [ASSUMED: no 对空
// note, unlike S3] is within 1.0 of it; every `interval` s from the throw [ASSUMED] each such enemy takes 停顿 `sluggish`
// s and shield_atk_scale × ATK arts, and she heals heal_ratio × the HP it lost (ignoring her 禁疗). The skill runs until
// the turn is done (its return taken as instant [ASSUMED]): no SP meanwhile, and her normal attacks of the flight are arts.
// - S3 地狱变相 (MANUAL; data ACTIVE_RANGE on the running x-2 — the owner's rule over the 重装 exception, rawRule
// TAKE_DAMAGE): `duration` s — range x-2, max HP +max_hp, ATK +atk, two hits on up to attack@max_target enemies, air units
// too (PRTS 备注), arts. 主动关闭 (a close: `skill.end('manual')`) starts the 临死模式 for before_dead_duration s: the
// skill's bonuses and range stay, four hits, 阻回 + 沉默, 不死 (her negative pool never knocks her out); every other allied
// operator on her range holds 不死 and the part of a damage instance past its HP becomes her 流失 (a 流失 it takes is not
// passed on); then she leaves the field (a retreat with the death animation, as 史尔特尔's 余烬; the official partial DP
// refund of a withdrawal is not modelled — no retreat in the sim refunds DP). No strategy of this mode closes it: PRTS
// 帮助 lists the 上半 row that did as removed in 下半, so in a fight the skill simply runs its `duration`.
import { num, talentBb, traitBb, skillRec, up, byEnemyAttack } from '../shared/tier1.js';
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyInRadius } from '../../../body.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
import { sin, cos, atan2 } from '../../../detmath.js';
const S1 = 'skchr_hsgma2_1';
const S2 = 'skchr_hsgma2_2';
const S3 = 'skchr_hsgma2_3';
/** S2's shield (PRTS 备注): start 1.2 tiles ahead, 365° counter-clockwise at 90°/s, collision radius 1.0, −80 % near a target. */
const SHIELD_START = 1.2;
const SHIELD_TURN = 365;
const SHIELD_SPEED = 90;
const SHIELD_RADIUS = 1.0;
const SHIELD_SLOW = 0.8;
/** An engine cap on S2's run (the shield's turn ends it long before: 365° at 18°/s takes 20.3 s). */
const S2_CAP = 60;
/** S3's x-2 when the data carries no grid. */
const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1],
[0, 2], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]);
const GROUND = Object.freeze({ canHitFly: false });
const EGO = 'talent:hsgma2:ego';
const EGO_REGEN = 'talent:hsgma2:egoRegen';
const BERSERK = 'talent:hsgma2:berserk';
const DYING = 'skill:hsgma2:dying';
const ASTX = 'hsgma2:astx';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0));
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
export default {
char_1044_hsgma2: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const throwParts = { hits: 3, atkScale: num(b2['attack@atk_scale'], 1), hitAllBlocked: true };
// this unit's attack overrides (the kit is built per unit): S2's throw turns into plain arts attacks after it lands,
// S3's two hits become four in the 临死模式
const s2Attack = { dmgType: 'arts' };
const s3Attack = { dmgType: 'arts', hits: 2 };
const plainS2 = () => { delete s2Attack.hits; delete s2Attack.atkScale; delete s2Attack.hitAllBlocked; };
/** S2: the shield's centre after `turned` degrees (row 0 is the bottom row: counter-clockwise = a growing angle). */
const shieldAt = (unit, sh) => {
const a = sh.a0 + (sh.turned * Math.PI) / 180;
return { x: unit.x + SHIELD_START * cos(a), y: unit.y + SHIELD_START * sin(a) };
};
const touching = (battle, unit, p) => battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND) && bodyInRadius(e, p.x, p.y, SHIELD_RADIUS));
/** S2: one shield pulse — 停顿, the arts hit, and her heal of heal_ratio × the HP it removed (ignoring her 禁疗). */
const shieldPulse = (battle, unit, p) => {
for (const e of touching(battle, unit, p)) {
battle.applyStatus(e, 'sluggish', { duration: num(b2.sluggish, 1), source: unit });
if (!e.alive) continue;
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.shield_atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'hsgma2:shield'] });
if (dealt > 0 && unit.alive) battle.heal(unit, unit, dealt * num(b2.heal_ratio), { self: true, ignoreHealFree: true });
battle.fx('strike', { x: p.x, y: p.y, id: unit.id, skill: 'hsgma2:shield' });
}
};
return {
trait: { dmgType: 'phys' },
skills: {
[S1]: {
kind: 'toggle', trigger: { rule: 'SP_FULL' },
mods: nz({ atkPct: num(b1.atk), defPct: num(b1.def) }),
attack: { dmgType: 'arts' },
},
[S2]: {
kind: 'duration', duration: S2_CAP,
attack: s2Attack,
onStart({ unit }) { Object.assign(s2Attack, throwParts); unit.mem.hsShield = null; },
onAttack({ battle, unit }) {
if (!('hits' in s2Attack)) return;
plainS2(); // the throw was this attack: the shield flies from now on
const [fr, fc] = unit.fwd;
unit.mem.hsShield = { a0: atan2(fr, fc), turned: 0, acc: 0 };
battle.fx('zone', { x: unit.x, y: unit.y, radius: SHIELD_START + SHIELD_RADIUS, dur: SHIELD_TURN / SHIELD_SPEED, id: unit.id, skill: 'hsgma2:shield' });
shieldPulse(battle, unit, shieldAt(unit, unit.mem.hsShield));
},
onTick({ battle, unit, skill, dt }) {
const sh = unit.mem.hsShield;
if (!sh) return;
const iv = Math.max(0.05, num(b2.interval, 0.5));
sh.acc += dt;
while (sh.acc + 1e-9 >= iv && unit.mem.hsShield === sh) { sh.acc -= iv; shieldPulse(battle, unit, shieldAt(unit, sh)); }
if (unit.mem.hsShield !== sh) return;
const slowed = touching(battle, unit, shieldAt(unit, sh)).length > 0;
sh.turned += SHIELD_SPEED * (slowed ? 1 - SHIELD_SLOW : 1) * dt;
if (sh.turned >= SHIELD_TURN - 1e-9) skill.end('shield');
},
onEnd({ unit }) { unit.mem.hsShield = null; plainS2(); },
},
[S3]: {
kind: 'duration',
mods: nz({ hpPct: num(b3.max_hp), atkPct: num(b3.atk) }),
targeting: { rangeGrid: skillRec(chess, S3)?.rangeGrid ?? X2, maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 1))), canHitFly: true },
attack: s3Attack,
onStart({ unit }) { s3Attack.hits = 2; unit.mem.hsDying = null; },
onEnd({ battle, unit, skill, reason }) {
const dying = unit.mem.hsDying;
if (!dying && reason === 'manual' && unit.alive && unit.deployed) {
// 主动关闭 ⇒ 临死模式: the skill keeps running (its bonuses, range and override stay; no skillStart — it is
// no new cast) for before_dead_duration s with four hits, 阻回 + 沉默, and 不死 (the fatal hooks below)
const dur = num(b3.before_dead_duration, 11);
unit.mem.hsDying = { seq: unit.deploySeq };
s3Attack.hits = 4;
skill.active = true;
skill.timeLeft = dur;
battle.addBuff(unit, { key: DYING, duration: dur, flags: { noSp: true, silence: true }, visible: true, tags: ['skill'] });
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id });
return;
}
if (!dying) return;
unit.mem.hsDying = null;
s3Attack.hits = 2;
battle.removeBuff(unit, DYING);
// "11秒后强制退出战场" (视为撤回干员)
if (reason === 'duration' && unit.alive && unit.deployed) battle.retreat(unit, { reason: 'retreat', dying: true });
},
},
},
talents: [
{ install(battle, unit) { // 业火: 我执 — the negative pool, 禁疗, the quiet-time regeneration
const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp;
const quiet = num(t0['hsgma2_t_1[heal].interval'], 5);
const regen = num(t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']);
// the pool as a share of its cap, read when a snapshot is taken (b.snap `neg`, snapshot.js negView): the green HP bar
// sits on the 1-HP floor in 我执, so the client draws the pool as the red bar — display only
unit.negFill = () => (unit.mem.hsEgo && unit.mem.hsEgo.pool > 0 ? unit.mem.hsEgo.pool / Math.max(1e-9, capOf()) : 0);
const enter = (pool) => {
unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time };
battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] });
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id, talent: 'hsgma2:ego' });
};
const leave = () => {
unit.mem.hsEgo = null;
battle.removeBuff(unit, EGO);
battle.removeBuff(unit, EGO_REGEN);
};
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.hsOverflow = false; leave(); } }, { owner: unit });
// a damage instance (after the shields): into the pool while in 我执, or the lethal one opens it
battle.on('hpDamage', (c) => {
if (c.target !== unit || !unit.alive) return;
const ego = unit.mem.hsEgo;
if (ego) {
ego.lastHurt = battle.time;
if (ego.pool + c.amount < capOf()) { ego.pool += c.amount; c.amount = 0; } else unit.mem.hsOverflow = true;
return;
}
if (c.amount < unit.hp) return;
const over = c.amount - unit.hp;
if (over < capOf()) { c.amount = Math.max(0, unit.hp - 1); enter(over); } else unit.mem.hsOverflow = true;
}, { owner: unit, priority: -50 });
// HP reaching 0 anyway: the 临死模式's 不死 holds her; a full pool knocks her out; a 流失 goes to the pool
battle.on('fatal', (c) => {
if (c.unit !== unit || c.prevented) return;
const overflow = !!unit.mem.hsOverflow;
unit.mem.hsOverflow = false;
const ego = unit.mem.hsEgo;
if (unit.mem.hsDying) {
c.prevented = true;
if (ego) ego.pool = Math.min(capOf(), ego.pool + c.amount); else enter(Math.min(capOf(), c.amount));
return;
}
if (overflow) return;
const pool = (ego ? ego.pool : 0) + c.amount;
if (pool >= capOf()) return;
c.prevented = true;
if (ego) ego.pool = pool; else enter(pool);
}, { owner: unit, priority: -40 });
battle.on('tick', () => {
unit.mem.hsOverflow = false;
const ego = unit.mem.hsEgo;
if (!ego) return;
if (!up(unit)) { leave(); return; }
// HP above the 1-HP floor (her regeneration, a heal that ignores 禁疗) clears the pool first
const surplus = unit.hp - 1;
if (surplus > 0 && ego.pool > 0) { const take = Math.min(surplus, ego.pool); ego.pool -= take; unit.hp -= take; }
if (!(ego.pool > 1e-9)) { leave(); return; }
const want = regen > 0 && battle.time - ego.lastHurt >= quiet - 1e-9;
const has = !!unit.findBuff(EGO_REGEN);
if (want && !has) battle.addBuff(unit, { key: EGO_REGEN, mods: { hpRegenRatio: regen }, tags: ['talent'] });
else if (!want && has) battle.removeBuff(unit, EGO_REGEN);
}, { owner: unit });
} },
{ install(battle, unit) { // 鬼之架势: 坚忍 RES / ATK by the HP lost; AST-X stage 3: ATK +atk more at the maximum
const hi = num(t1.max_hp_ratio, 1), lo = num(t1.min_hp_ratio, 0.3);
const res0 = num(t1.max_magic_resistance), res1 = num(t1.min_magic_resistance);
const atk0 = num(t1.max_atk), atk1 = num(t1.min_atk), extra = num(t1.atk);
if (!(hi > lo) || !(res0 || res1 || atk0 || atk1)) return;
battle.on('tick', () => {
const cur = unit.findBuff(BERSERK);
if (!up(unit)) { if (cur) battle.removeBuff(unit, BERSERK); return; }
const f = clamp01((hi - unit.hpRatio) / (hi - lo));
if (cur && Math.abs((cur.data?.f ?? -1) - f) < 1e-4) return;
const mods = nz({ resFlat: res0 + (res1 - res0) * f, atkPct: atk0 + (atk1 - atk0) * f + (f >= 1 - 1e-9 ? extra : 0) });
if (!Object.keys(mods).length) { if (cur) battle.removeBuff(unit, BERSERK); return; }
battle.addBuff(unit, { key: BERSERK, mods, data: { f }, tags: ['talent'] });
}, { owner: unit });
} },
],
install(battle, unit) {
// S3 attacks air as well: its expanded auto-cast area must not inherit the inactive ground-only profile.
if (unit.skill?.id === S3) unit.skill.addTriggerRange(() => [{
keys: absoluteRangeKeys(skillRec(chess, S3)?.rangeGrid ?? X2, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
profile: { canHitFly: true },
}]);
// S1 恶业苦果: every enemy damage instance she takes (the official hsgma2_s_1 on ON_TAKE_DAMAGE, not its attacks only:
// tier1 byEnemyAttack) ⇒ atk_scale × ATK arts back to its source
battle.on('damaged', (c) => {
const s = c.source;
if (c.target !== unit || !unit.alive || unit.skill?.id !== S1 || !unit.skill.active) return;
if (!byEnemyAttack(c) || !s.alive) return;
battle.dealDamage(unit, s, { amount: unit.s.atk * num(b1.atk_scale), type: 'arts', canDodge: false, isSkill: true, ignoreSelect: true, tags: ['skill', 'counter'] });
battle.fx('counter', { x: s.x, y: s.y, id: unit.id });
}, { owner: unit });
// AST-X 无迹: her attack damage ⇒ +atk_scale × ATK arts (附加伤害); an enemy damage instance she takes (the official
// hsgma2_e_003_tr_take: ON_TAKE_DAMAGE from the other side) ⇒ atk_scale × ATK arts back
const astx = num(tb.atk_scale);
if (astx > 0) {
battle.on('damaged', (c) => {
const t = c.target;
if (c.source !== unit || !c.dmg?.isAttack || c.type === 'element' || !t || t.side !== 'enemy' || !hasHp(t)) return;
battle.dealDamage(unit, t, { amount: unit.s.atk * astx, type: 'arts', canDodge: false, tags: ['module', ASTX] });
}, { owner: unit, priority: -20 });
battle.on('damaged', (c) => {
const s = c.source;
if (c.target !== unit || !unit.alive || !byEnemyAttack(c) || !s.alive) return;
battle.dealDamage(unit, s, { amount: unit.s.atk * astx, type: 'arts', ignoreSelect: true, tags: ['module', ASTX, 'counter'] });
}, { owner: unit });
}
// S3 临死模式: every other allied operator on her range holds 不死; the part of a damage instance past its HP is her 流失
const shelters = (t) => !!unit.mem.hsDying && up(unit) && !!t && t !== unit && t.side === 'ally' && t.kind === 'op' && t.alive
&& !!unit.rangeKeySet && unit.rangeKeySet.has(t.tileR * COLS + t.tileC);
battle.on('hpDamage', (c) => {
const t = c.target;
if (!shelters(t) || c.amount < t.hp) return;
const over = c.amount - t.hp;
c.amount = Math.max(0, t.hp - 1);
if (over > 0) battle.loseHp(unit, over, { source: c.credit ?? null, tags: ['skill', 'hsgma2:shoulder'] });
}, { owner: unit, priority: -60 });
battle.on('fatal', (c) => { if (!c.prevented && shelters(c.unit)) c.prevented = true; }, { owner: unit, priority: -40 });
},
};
},
};