feedback5: merge O16 — 自选 kits for 涤火杰西卡 (with her 机动盾牌), 机械师 and 凯尔希 (with Mon3tr); engine: shieldType may list types, profile hook healThrough; golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 01:49:36 +08:00
commit faa40ca68f
14 files changed
+2461 -119

No files matched your search

+7 -2
View File
@@ -54,15 +54,20 @@ export class BattleQueries {
return false;
}
/** Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. */
/**
* Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. A `noHeal` (禁疗) ally
* is left out unless the healer's profile heals it through its 禁疗 (`healThrough(healer, ally)`: 凯尔希's Mon3tr —
* damage.js heal).
*/
injuredAlliesInKeys(keys, healer, includeElement = false) {
const out = [];
if (!keys) return out;
const set = keys instanceof Set ? keys : (healer && healer.rangeKeys === keys && healer.rangeKeySet ? healer.rangeKeySet : new Set(keys));
const through = healer && healer.profile && typeof healer.profile.healThrough === 'function' ? healer.profile.healThrough : null;
for (const a of this.allyUnits) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') continue;
if (!set.has(a.tileR * COLS + a.tileC)) continue;
if (a !== healer && (a.s.flags.noHeal || (a.profile && a.profile.noHeal))) continue;
if (a !== healer && ((a.s.flags.noHeal && !(through && through(healer, a))) || (a.profile && a.profile.noHeal))) continue;
const injured = a.hp < a.s.maxHp - 1e-6;
const elem = includeElement && (a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion) > 0;
if (injured || elem) out.push(a);
+3 -2
View File
@@ -155,8 +155,9 @@ export function makeBuff(b) {
tags: b.tags ?? [],
shield: b.shield ?? (b.mods && b.mods.shield) ?? 0,
shieldHits: b.shieldHits ?? 0,
// a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental'; null =
// every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") — 夜莺 S2 法术护盾 "能吸收…法术伤害"
// a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental', or a list
// of them (机械师's 屏障: ['phys', 'arts']); null = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") —
// 夜莺 S2 法术护盾 "能吸收…法术伤害"
shieldType: b.shieldType ?? null,
persist: !!b.persist,
status: b.status ?? null,
+3
View File
@@ -124,6 +124,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-chen2.js',
'op-ray.js',
'op-ascln.js',
'op-jesca2.js',
'op-mcnist.js',
'op-kalts.js',
]);
/**
+322
View File
@@ -0,0 +1,322 @@
// server/sim/content/kits/ops/op-jesca2.js — 涤火杰西卡 (char_1034_jesca2) 自选 operator kit: 6★ 哨戒铁卫 (重装), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (SPT-X “私家英雄”, SPT-Y
// 未曾风化) at every form, and the kit of her summon 机动盾牌 (token_10032_jesca2_jckshd). Kit contract and the 自选 rules:
// ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_1034_jesca2): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7,
// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no
// account]. Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json);
// PRTS 涤火杰西卡 (灵活应变 备注 "转向行为不受异常效果影响,仅在机动盾牌持续时间内生效;机动盾牌退场后,涤火杰西卡转回原方向",
// "本天赋的防御增加效果无视孤立"; 蓄能释放 备注 "受到任何伤害时均可触发(包括无法触发受击回复的伤害)"; S2 备注 "此技能的攻击范围
// 不受“攻击距离”属性影响"; S3 备注 "仅技能持续期间可触发发射炮弹的行为,技能期间多次部署机动盾牌可触发多次发射炮弹行为(同一时间内最多
// 生效1次)", "发射炮弹行为视为不消耗弹药的普通攻击,受缴械类效果影响时该行为延迟至此类效果消失后进行", "炮弹飞行终点为自身当前攻击
// 范围内正前方最远一格中点,碰撞半径0.5,爆炸半径1.7"; SPT-Y 修正 "攻击距离+1(部分技能不受此影响)"); PRTS 机动盾牌 (备注 "持有
// 禁疗", "无视野范围", "退场时返还1个可部署的机动盾牌"; 嘲讽等级 1, 阻挡数 2); the client's battle data (charpack char_1034_jesca2:
// Talents/1/DefAura range b-1 ignoring 孤立, Talents/2 x-5, die_to_kill_token, charge_token[born]; token prefab
// token_10032_jesca2_jckshd: jckshd_t = 禁疗 (abnormal flag 7) + taunt for `duration` s, HostMark b-1, charge_token[finish];
// skill prefabs skchr_jesca2_1 JckshdBuff / skchr_jesca2_3 JckshdBuffS3 (auras on her x-5); projectile
// projectile_chr_jesca2_s3_bomb (speed 5, first enemy it passes, ground and air) → _explosion (every enemy in range, air
// too); buff_template_data jesca2_jckshd_dir_set / dir_ctrl, jesca2_t_2[sp], jesca2_e_003[t_sp] / [extend], jesca2_s_3[bomb]).
// - Trait (哨戒铁卫) "能够阻挡三个敌人,可以进行远程攻击": the profession default — ranged physical, blocks 3, hits air units
// (PRTS 分支特性信息 哨戒铁卫 "可对空"), ground enemies target her normally.
// - Module SPT-X “私家英雄”: trait addition "攻击范围内敌人的隐匿效果失效" (jesca2_e_002_t: 隐匿 immunity on the enemies of her
// range): reveal (stages 1 and 3). Stage 3 changes T1: the shield lasts 60 s and is stronger (the token's module
// attributes and talent — data byModule). Stats: the module attributes.
// - Module SPT-Y 未曾风化: "攻击距离+1(部分技能不受此影响)" — a permanent forward range extension (jesca2_e_003[extend]); S2's
// 2-5 ignores it (its 备注), S3 grows on it. Stage 3 changes T2 (below). Stats: the module attributes.
// - T1 灵活应变: her 机动盾牌 is a hand piece the player places next to her ("只能放置于涤火杰西卡四周" — the prep's placement
// rule; a piece on any other tile gets no turn, mark or aura here) and deploys with the board. Each deployment: it lasts
// the token talent's `duration` (50 s; SPT-X stage 3: 60 s), holds 禁疗 and taunt level `taunt_level` (its hidden talent:
// "更容易受到攻击"), makes no attack and blocks 2 (its stats); she turns to face it (her range turns with her) and turns
// back to the direction she had when it came, once it leaves — whatever the reason, stunned or not (PRTS 备注; the
// client's dir_set / dir_ctrl[reset]); while it stands next to her, she and the unit on the tile behind her (range b-1,
// any ally unit, 孤立 ones too) DEF +def. It leaves when she leaves (die_to_kill_token: killed), and comes back on its tile
// its respawnTime (30 s) after it left, paying its cost (5 DP), only while she stands — her (re)deployment makes a waiting
// one ready at once (charge_token[born]) [ASSUMED: the 卫戍 auto redeploy of a placed summon, as 鸿雪's 打字机 / 谬因's 中继器].
// - T2 蓄能释放: every damage instance the shield takes — any damage, 无来源 / DoT included, not a 流失 or an element 损伤 —
// while it stands on her x-5: `prob` to give her `sp` SP (none while a skill of hers runs: AK). SPT-Y stage 3: prob 0.6 and
// each success also cuts the shield's own next redeploy time by respawn_time (1 s), at most respawn_time_max (14 s) per
// shield deployment (jesca2_e_003[t_sp]: the card buff lasts until the next spawn; AlwaysNext — the cut comes with the
// roll even when no SP can be gained).
// - S1 坚守阵线 (AUTO, 持续时间无限 — `toggle`): ATK +atk, DEF +def; every shield on her x-5 DEF +def while it runs (JckshdBuff
// aura) and +duration s of life once per shield deployment (jckshd_t, the overriding lifetime buff of the aura). An AUTO
// skill acting on herself and her shield fires at full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule, kits/README.md
// checklist 5; skchr_jesca2_1 `_allowNoTarget`).
// - S2 掩蔽护卫 (MANUAL, 15 s, data ACTIVE_RANGE on its 2-5 — the 重装 exception of 742f478): range 2-5 (not grown by
// 攻击距离), ATK +atk, attack interval base_attack_time s (−0.9 on 1.2 s), 75 % physical and arts dodge (the `evade`
// template, prob).
// - S3 饱和迸射 (MANUAL, 20 发弹药, data ACTIVE_RANGE on its 2-2 + 1): 攻击距离 +ability_range_forward_extend, attack interval
// +base_attack_time (0.6 s), ATK +atk, DEF +jesca2_s_3[def].def; her shields on x-5 DEF +jesca2_s_3_token[def].def. While
// a shield stands next to her (the mark: at the cast, or a shield deployed during the skill — at most one pending), she
// fires a shell as soon as she is not stunned / frozen / asleep / disarmed: an attack that spends no bullet and restarts
// her attack timer [ASSUMED: the template clears and restarts the attack cooldown]; it flies at BOMB_SPEED from her
// position to the centre of the farthest tile straight ahead of her current range, hits the first enemy it touches
// (BOMB_HIT_RADIUS, ground or air) or the end point, and explodes: every selectable enemy within
// attack@extrabomb.projectile_range takes attack@extrabomb.atk_scale × her ATK at the launch, physical (普通伤害, dodgeable),
// and is stunned attack@extrabomb.stun s (not when it dodged the hit [ASSUMED]). The skill's end drops a pending shell.
import { num, talentBb, traitBb, moduleOn, skillRec, batMod, up, giveSp, installReveal } from '../shared/tier1.js';
import { offsetTile, dirFromDelta, dirVec } from '../../../dir.js';
import { isHpLoss } from '../../../damage.js';
const S1 = 'skchr_jesca2_1';
const S2 = 'skchr_jesca2_2';
const S3 = 'skchr_jesca2_3';
/** Her summon 机动盾牌 (talent 灵活应变). */
export const SHIELD = 'token_10032_jesca2_jckshd';
/** S3 shell (PRTS 备注 / projectile_chr_jesca2_s3_bomb): flight speed (tiles / s) and collision radius. */
const BOMB_SPEED = 5;
const BOMB_HIT_RADIUS = 0.5;
/** The shell's explosion radius when the blackboard lacks it (PRTS "爆炸半径1.7"). */
const BOMB_BLAST_FALLBACK = 1.7;
/** Aura refresh period and buff life (s): the kits' aura convention (a unit leaving the tile loses it within one refresh). */
const AURA = 0.2;
const AURA_DUR = 0.25;
/** How often a waiting shield tries to come back once ready (her on the field, its tile free, DP paid). */
const RETRY = 0.1;
const DEF_KEY = 'talent:jesca2:def';
const SHIELD_SKILL_KEY = 'skill:jesca2:shield';
const SHIELD_ABNORMAL_KEY = 'jesca2:shield:abnormal';
const TAG_BOMB = 'jesca2:bomb';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 机动盾牌 of `unit`. */
const isShieldOf = (t, unit) => !!t && t.kind === 'token' && t.defId === SHIELD && t.ownerUnit === unit;
const shieldsOf = (battle, unit) => battle.allyUnits.filter((t) => isShieldOf(t, unit));
/** `t` stands on one of the four tiles next to `unit` (the placement rule "四周"; her x-5 auras reach it there). */
const besideOf = (unit, t) => Math.abs(t.tileR - unit.tileR) + Math.abs(t.tileC - unit.tileC) === 1;
/** The shield of hers standing next to her (the mark jesca2_jckshd_s_3[mark]: its HostMark b-1 holds her), or null. */
const markOf = (battle, unit) => shieldsOf(battle, unit).find((t) => up(t) && besideOf(unit, t)) ?? null;
const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id);
/** Face `dir` (the range turns with her: Battle.refreshRange; a SkillRuntime trigger grid follows `unit.dir`). */
function turnTo(battle, unit, dir) {
if (unit.dir === dir) return;
unit.dir = dir;
if (unit.alive && unit.deployed) battle.refreshRange(unit);
}
/** The farthest local column straight ahead (local row 0) of a relative grid (her current range). */
function farthestAhead(grid) {
let n = 0;
for (const p of grid ?? []) if (Array.isArray(p) && p[0] === 0 && p[1] > n) n = p[1];
return n;
}
/**
* 灵活应变's piece: the shield's kit (lifetime = its talent's `duration`, 禁疗 + taunt, her turn, its redeploy). `owner` =
* 涤火杰西卡, `s1Ext` = the life her S1 adds (bb.duration).
*/
function shieldKit(owner, { s1Ext }) {
return {
skill: null,
trait: { noAttack: true }, // "无视野范围" (and ATK 0)
talents: [],
install(battle, t) {
const tal = (t.def?.talents ?? []).find((x) => x && x.bb && x.bb.duration != null)?.bb ?? {};
const life = num(tal.duration, 50);
const taunt = num(tal.taunt_level, 1);
// 持有禁疗 (PRTS; jckshd_t abnormal flag 7 — the data's `abnormal` lacks it [ASSUMED: given here]) and "更容易受到攻击"
battle.addBuff(t, { key: SHIELD_ABNORMAL_KEY, flags: { noHeal: true }, mods: { taunt }, persist: true, allowDead: true });
battle.on('deploy', (c) => {
if (c.unit !== t) return;
t.mem.lifeEnd = battle.time + life;
t.mem.s1Done = false;
t.mem.respawnCut = 0;
// she turns to face it; it faces away from her (dir_set: AssignRelativeDirectionToBB, SetBodyDirection)
if (up(owner) && besideOf(owner, t)) {
const dir = dirFromDelta(t.tileR - owner.tileR, t.tileC - owner.tileC, owner.dir);
if (owner.mem.jescaOldDir == null) owner.mem.jescaOldDir = owner.dir;
t.dir = dir;
turnTo(battle, owner, dir);
battle.fx('buff', { x: owner.x, y: owner.y, id: owner.id, kind: 'turn' });
// S3: a shield taking the field during the skill fires a shell (at most one pending)
if (skillOn(owner, S3)) owner.mem.jescaBomb = true;
}
}, { owner: t });
battle.on('tick', () => {
if (!up(t)) return;
// S1's aura gives a shield on her x-5 +s1Ext s of life, once per deployment
if (!t.mem.s1Done && s1Ext > 0 && skillOn(owner, S1) && up(owner) && besideOf(owner, t)) {
t.mem.s1Done = true;
t.mem.lifeEnd += s1Ext;
}
if (battle.time + 1e-9 >= t.mem.lifeEnd) battle.retreat(t, { reason: 'expired', permanent: true });
}, { owner: t });
battle.on('death', (c) => {
if (c.unit !== t || battle.finished) return;
// dir_ctrl[reset]: she turns back to the direction she had when it came — stunned or not, on the field or not
if (owner.mem.jescaOldDir != null) {
turnTo(battle, owner, owner.mem.jescaOldDir);
owner.mem.jescaOldDir = null;
}
// the piece stays (退场时返还1个可部署的机动盾牌): back on its tile once ready, while she stands
t.removed = false;
t.mem.readyAt = battle.time + Math.max(0, num(t.base.respawnTime) - num(t.mem.respawnCut));
t.mem.respawnCut = 0;
if (t.mem.retry) return;
t.mem.retry = battle.every(RETRY, (b, sched) => {
const stop = () => { sched.cancel(); t.mem.retry = null; };
if (t.alive || t.removed || b.finished) { stop(); return; }
if (!up(owner) || b.time + 1e-9 < t.mem.readyAt) return;
if (b.redeploy(t, { free: false })) stop();
}, { owner: t });
}, { owner: t, priority: -10 });
},
};
}
/**
* S3's shell: from her tile straight ahead to the centre of the farthest tile of her current range, at BOMB_SPEED; the
* first selectable enemy within BOMB_HIT_RADIUS (ground or air) or the end point sets it off.
*/
function fireBomb(battle, unit, b3) {
const [fr, fc] = dirVec(unit.dir);
const k = farthestAhead(unit.liveRangeGrid);
const bomb = {
x: unit.x, y: unit.y, tx: unit.tileC + fc * k, ty: unit.tileR + fr * k,
atk: unit.s.atk, // the launch's ATK (_cachedAtkKey)
scale: num(b3['attack@extrabomb.atk_scale'], 2.5),
radius: num(b3['attack@extrabomb.projectile_range'], BOMB_BLAST_FALLBACK),
stun: num(b3['attack@extrabomb.stun'], 5),
};
(unit.mem.jescaShells ??= []).push(bomb);
unit.atkCd = unit.s.interval; // the shell is her attack: the timer restarts [ASSUMED]
battle.fx('bombardShell', { x: bomb.tx, y: bomb.ty, id: unit.id, r: bomb.radius, t: Math.hypot(bomb.tx - bomb.x, bomb.ty - bomb.y) / BOMB_SPEED });
}
/** Move the shells one tick; a shell touching an enemy or reaching its end point explodes. */
function flyShells(battle, unit, dt) {
const list = unit.mem.jescaShells;
if (!list || !list.length) return;
const keep = [];
for (const s of list) {
const dx = s.tx - s.x, dy = s.ty - s.y;
const d = Math.hypot(dx, dy);
const step = BOMB_SPEED * dt;
let hit = battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0;
if (!hit) {
if (d <= step) { s.x = s.tx; s.y = s.ty; hit = true; } else { s.x += (dx / d) * step; s.y += (dy / d) * step; }
if (!hit && battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0) hit = true;
}
if (hit) explode(battle, unit, s); else keep.push(s);
}
unit.mem.jescaShells = keep;
}
function explode(battle, unit, s) {
battle.fx('bombard', { x: s.x, y: s.y, id: unit.id, r: s.radius });
const dodged = (unit.mem.jescaDodged = new Set());
for (const e of battle.foesInRadius(s.x, s.y, s.radius)) {
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount: s.atk * s.scale, type: 'phys', isSkill: true, tags: ['skill', TAG_BOMB] });
if (e.alive && !dodged.has(e)) battle.applyStatus(e, 'stun', { duration: s.stun, source: unit });
}
unit.mem.jescaDodged = null;
}
export default {
char_1034_jesca2: (bb, chess) => {
const t0 = talentBb(chess, 0); // 灵活应变: def (+15 %)
const t1 = talentBb(chess, 1); // 蓄能释放: sp, prob (SPT-Y stage 3: + respawn_time, respawn_time_max)
const tb = traitBb(chess); // SPT-Y: ability_range_forward_extend
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const mod = moduleOn(chess) ? chess.module.id : null;
const shieldDef = (unit) => (skillOn(unit, S1) ? num(b1.def) : 0) + (skillOn(unit, S3) ? num(b3['jesca2_s_3_token[def].def']) : 0);
return {
skills: {
[S1]: { kind: 'toggle', trigger: 'SP_FULL', mods: { atkPct: num(b1.atk), defPct: num(b1.def) } },
[S2]: {
kind: 'duration',
// 2-5, never grown by 攻击距离 (PRTS 备注)
targeting: { rangeGrid: s2?.rangeGrid ?? null, noRangeExtend: true },
mods: { atkPct: num(b2.atk), batPct: batMod(b2.base_attack_time, chess), dodgePhys: num(b2.prob), dodgeArts: num(b2.prob) },
},
[S3]: {
kind: 'ammo', ammo: num(b3['attack@trigger_time'], 20),
targeting: { rangeExtend: num(b3.ability_range_forward_extend, 1) },
mods: { atkPct: num(b3.atk), defPct: num(b3['jesca2_s_3[def].def']), batPct: batMod(b3.base_attack_time, chess) },
onStart({ battle, unit }) { unit.mem.jescaBomb = !!markOf(battle, unit); },
onEnd({ unit }) { unit.mem.jescaBomb = false; },
},
},
talents: [
{ install(battle, unit) { // 灵活应变: her shield pieces run the shield's kit (set up before the battle starts)
const kit = () => shieldKit(unit, { s1Ext: num(b1.duration) });
for (const t of battle.allyUnits) {
if (!isShieldOf(t, unit) || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t); // a piece set up before her: drop its generic kit's hooks
battle._setupUnit(t, kit());
}
// die_to_kill_token: her shields fall when she leaves; charge_token[born]: her (re)deployment readies a waiting one
battle.on('death', (c) => {
if (c.unit !== unit) return;
for (const t of shieldsOf(battle, unit)) if (t.alive) battle.kill(t, null);
}, { owner: unit });
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
for (const t of shieldsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time;
}, { owner: unit });
// she and the unit on the tile behind her (b-1, 孤立 ones too: ignoreAllyTargetFree) DEF +def while her shield
// stands next to her
const d = num(t0.def);
if (!(d > 0)) return;
const aura = () => {
if (!up(unit) || !markOf(battle, unit)) return;
const [br, bc] = offsetTile(unit.tileR, unit.tileC, 0, -1, unit.dir);
for (const a of [unit, battle.unitAt(br, bc)]) {
if (!a || a.side !== 'ally' || a.kind === 'device' || !a.alive) continue;
const cur = a.findBuff(DEF_KEY);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > d && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key: DEF_KEY, duration: AURA_DUR, mods: { defPct: d }, source: unit, data: { v: d }, tags: ['talent', 'aura'] });
}
};
battle.every(AURA, aura, { owner: unit, immediate: true });
battle.on('deploy', (c) => { if (isShieldOf(c.unit, unit)) aura(); }, { owner: unit });
} },
{ install(battle, unit) { // 蓄能释放: the shield hurt ⇒ prob: +sp SP to her (SPT-Y stage 3: its next redeploy −1 s, ≤ 14 s)
const prob = num(t1.prob), sp = num(t1.sp);
const cut = num(t1.respawn_time), cutMax = num(t1.respawn_time_max);
if (!(prob > 0)) return;
battle.on('damaged', (c) => {
const t = c.target;
if (!isShieldOf(t, unit) || !up(unit) || !besideOf(unit, t)) return;
if (!c.dmg || isHpLoss(c.dmg) || c.type === 'element') return;
if (!battle.rng.chance(prob)) return;
giveSp(unit, sp, 'talent');
if (cut > 0 && cutMax > 0) t.mem.respawnCut = Math.min(cutMax, num(t.mem.respawnCut) + Math.abs(cut));
}, { owner: unit });
} },
],
install(battle, unit) {
// SPT-X “私家英雄”: 攻击范围内敌人的隐匿效果失效
if (mod === 'uniequip_002_jesca2') installReveal(battle, unit);
// SPT-Y 未曾风化: 攻击距离+1 — a permanent extension (her initial range too); S2's 2-5 ignores it (noRangeExtend)
const ext = num(tb.ability_range_forward_extend);
if (ext > 0) battle.addBuff(unit, { key: 'module:jesca2:range', mods: { rangeExtend: ext }, persist: true, allowDead: true });
// S1 / S3: her shields on x-5 DEF +def while the skill runs (JckshdBuff / JckshdBuffS3 auras)
battle.on('tick', (c) => {
for (const t of shieldsOf(battle, unit)) {
if (!t.alive) continue;
const want = up(unit) && besideOf(unit, t) ? shieldDef(unit) : 0;
const has = t.findBuff(SHIELD_SKILL_KEY);
if (want > 0 && (!has || has.mods?.defPct !== want)) {
battle.addBuff(t, { key: SHIELD_SKILL_KEY, mods: { defPct: want }, source: unit, tags: ['skill'] });
} else if (!(want > 0) && has) battle.removeBuff(t, SHIELD_SKILL_KEY);
}
// S3's shell: fired as soon as she can attack (not stunned / frozen / asleep, not disarmed), shells in flight move
if (unit.mem.jescaBomb && skillOn(unit, S3) && unit.canAct && !unit.s.flags.disarm) {
unit.mem.jescaBomb = false;
fireBomb(battle, unit, b3);
}
flyShells(battle, unit, c.dt ?? battle.dt);
}, { owner: unit });
// a shell's explosion that is dodged carries no stun [ASSUMED]
battle.on('dodge', (c) => {
if (c.source === unit && unit.mem.jescaDodged && c.dmg?.tags?.includes(TAG_BOMB)) unit.mem.jescaDodged.add(c.target);
}, { owner: unit });
},
};
},
};
+266
View File
@@ -0,0 +1,266 @@
// server/sim/content/kits/ops/op-kalts.js — 凯尔希 (char_003_kalts) 自选 operator kit: 6★ 医师 (医疗), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her modules (PHY-X Mon2tr, PHY-Y 医者, ISW-A 凯尔希特限
// 证章) at every form, and the kit of her summon Mon3tr (token_10002_kalts_mon3tr) — its block, its attacks and her skills
// acting through it. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_003_kalts): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7,
// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no
// account]. Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json);
// PRTS 凯尔希 (Mon3tr 备注 "仅在未装配模组时优先治疗效果为优先治疗自身、干员Mon3tr和凯尔希的召唤物", "可以强行选择…凯尔希的召唤物
// 作为治疗目标,相同生命比例下优先治疗自身", "治疗效果无视禁疗,但对象选取受禁疗制约"; 不毁重构 备注 "可对空"; S3 备注 "技能效果流失
// 的生命值可以击倒Mon3tr,并触发第二天赋", "攻击力加成每秒更新一次"); PRTS Mon3tr(凯尔希的召唤物) (备注 "持有禁疗(可被凯尔希…无视)",
// "凯尔希退场时强制撤退场上的Mon3tr"; 攻击范围 1-1, 阻挡数 3); gamedata_const ba.binding 绑定 "绑定对象不在场时技能强制结束,清空
// 所有技力且无法回复技力"; the client's battle data (charpack char_003_kalts: its attack `_ignoreHealFree`, WithdrawTokens,
// Talents/1 the in-range aura kalts_t_1[token_def_down_finish] + charge_token[born], Talents/2; token prefab
// token_10002_kalts_mon3tr: mon3tr_c_healfree (abnormal flag 7 = 禁疗), kalts_t_1[token_def_down] (DEF FINAL_SCALER 0),
// kalts_token[finish_kill_mark], Talents/2 the death rattle (FixedValueDamage PURE `value`, SPLASH, stun `stun`, range x-4),
// modes S2 (`_limitedMaxTargetNumToBlockedCnt`) / S3 (damage type PURE); skill prefabs skchr_kalts_1 (kalts_s_1[evade] =
// damage_block[phy]), skchr_kalts_2 / _3 (`_onlyAvailableWhenTokenValid`, RecoverSpCond every 0.1 s); equips kalts_equip_1_*
// (heal_scale_up[hpratio][LE]; stage 2+ the toggle-once HP ≤ 50 % rattle), kalts_equip_2_* (heal_scale_up[lowland]),
// kalts_equip_3_* (`_checkRoguelikeMode`); buff_template_data kalts_s_2_3[sp_cond], kalts_t_withdraw_token,
// kalts_s_3[ratio_atk], kalts_token_death_rattle_projectile, kalts_e_002_t[token]).
// - Trait (医师) "恢复友方单位生命": the profession default (one heal per attack on an injured ally of her 3-10 range), with T1's
// order: when she, her Mon3tr or — with no module — an operator Mon3tr (char_4179_monstr) is injured, the one of them with
// the lowest HP ratio (ties: herself); else the lowest HP ratio. Her heals reach her Mon3tr through its 禁疗, and her heal
// selection takes it (profile `healThrough`, damage.js heal / Battle.injuredAlliesInKeys); other 禁疗 units stay out.
// - Module PHY-X Mon2tr: ×heal_scale (1.15) on a heal whose target is at or below hp_ratio (50 %) before it
// (heal_scale_up[hpratio][LE]); stage 3 changes T2 (below). PHY-Y 医者: ×heal_scale on a heal whose target stands on a 地面
// tile (heal_scale_up[lowland]); stage 3 changes T1 (below). ISW-A 凯尔希特限证章: only its attributes act — its trait
// ("在集成战略中,同时治疗两个目标") and talent ("在集成战略中,Mon3tr不占用部署位,生命上限和攻击力+50%…") are 集成战略-only
// (`_checkRoguelikeMode`): N/A here, the trait blackboard it carries is not used. Stats: the module attributes.
// - T1 Mon3tr: her Mon3tr is a hand piece the player places; it deploys with the board, blocks 3 and strikes one ground
// enemy of its 1-1 in melee for physical damage (its data), holds 禁疗 (only her heals pass — the data's `abnormal` lacks it
// [ASSUMED: given here]); its DEF is ×0 while its tile is outside her attack range (kalts_t_1[token_def_down]; PHY-Y stage
// 3: inside it ASPD +attack_speed, DEF +def — kalts_t_1[token_def_down_finish]). It is withdrawn when she leaves (no
// rattle); it comes back on its tile its respawnTime (25 s) after it left, paying its cost (10 DP), only while she stands —
// her (re)deployment readies a waiting one at once (charge_token[born]) [ASSUMED: the 卫戍 auto redeploy of a placed summon].
// - T2 不毁重构: Mon3tr knocked out (not withdrawn; S3's 流失 counts) ⇒ every selectable enemy of its 3×3 (the talent's x-4,
// air units too) takes `value` (1200) true damage (溅射, not dodgeable) and is stunned `stun` (3) s. PHY-X stage 3 (the
// token's module talent: 3.5 s, 1500, hp_ratio): also once per deployment when a hit leaves it at or below hp_ratio of
// its max HP (the equip's toggle-once `_maxHpRatio` 0.5) and it survives [ASSUMED].
// - S1 指令:结构加固 (MANUAL, data DEFAULT — a heal skill: cast as she is about to heal): her DEF +def and Mon3tr's DEF
// +attack@def while it runs; 物理格挡 prob (damage_block[phy]: a physical damage instance she takes is blocked whole —
// not a dodge, nothing ignores it; a 流失 is no damage).
// - S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +attack_speed; Mon3tr ATK +attack@atk and each of its attacks
// strikes every enemy it blocks (one target when it blocks none [ASSUMED]).
// - S3 指令:熔毁 (MANUAL, data DEFAULT, 绑定 Mon3tr, 18 s): Mon3tr DEF +attack@def, its attacks deal true damage, its ATK
// +attack@atk × the remaining share of the skill, updated every second from the cast (+190 % … +10.6 %); unless it killed
// an enemy meanwhile, at the skill's normal end it loses attack@hp_ratio × its max HP (a 流失 that may knock it out).
// - 绑定 (S2 / S3): cast only while her Mon3tr is on the field; every 0.1 s without one, a running S2 / S3 ends, her SP is
// emptied and recovers no more (阻回 flag `noSp`) until it is back (kalts_s_2_3[sp_cond]).
import { num, talentBb, traitBb, moduleOn, skillRec, up } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_kalts_1';
const S2 = 'skchr_kalts_2';
const S3 = 'skchr_kalts_3';
/** Her summon Mon3tr (talent Mon3tr). */
export const MON3TR = 'token_10002_kalts_mon3tr';
/** The operator Mon3tr (an owned 自选 pick): in T1's heal order when she wears no module (PRTS 备注). */
const OP_MON3TR = 'char_4179_monstr';
const PHY_X = 'uniequip_002_kalts';
const PHY_Y = 'uniequip_003_kalts';
/** 不毁重构's area when the token talent lacks it (x-4: its tile and the 8 around it). */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
/** 绑定's check period (RecoverSpCond triggerInterval 0.1). */
const BIND_IV = 0.1;
/** How often a waiting Mon3tr tries to come back once ready. */
const RETRY = 0.1;
const BIND_KEY = 'kalts:binding';
const TAG_RATTLE = 'kalts:rattle';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const isMon3trOf = (t, unit) => !!t && t.kind === 'token' && t.defId === MON3TR && t.ownerUnit === unit;
const mon3trsOf = (battle, unit) => battle.allyUnits.filter((t) => isMon3trOf(t, unit));
const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id);
/** Keep buff `key` with `mods` on `u` while `want`; replace it when the mods changed. */
function holdBuff(battle, u, key, want, mods) {
const has = u.findBuff(key);
if (!want) { if (has) battle.removeBuff(u, key); return; }
if (has && Object.keys(mods).every((k) => has.mods?.[k] === mods[k])) return;
battle.addBuff(u, { key, mods, tags: ['skill'] });
}
/** 不毁重构's blast from Mon3tr's tile: `value` true damage (溅射) + `stun` s on every selectable enemy of its 3×3. */
function rattle(battle, m, { value, stun, grid }) {
battle.fx('aoe', { x: m.x, y: m.y, radius: 1.5, id: m.id, skill: TAG_RATTLE });
const keys = absoluteRangeKeys(grid, m.tileR, m.tileC, m.dir, 0);
for (const e of battle.enemiesInKeys(keys, m, { canHitFly: true })) {
if (!e.alive) continue;
battle.dealDamage(m, e, { amount: value, type: 'true', isSplash: true, canDodge: false, tags: [TAG_RATTLE] });
if (e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: m });
}
}
/**
* Mon3tr's kit: 禁疗, DEF ×0 outside her range (PHY-Y stage 3 bonus inside), her S1 / S2 / S3 acting through it, 不毁重构
* (and PHY-X stage 3's), its return. `owner` = 凯尔希.
*/
function mon3trKit(owner, { t0, b1, b2, b3 }) {
return {
skill: null,
talents: [],
install(battle, m) {
const rt = (m.def?.talents ?? []).find((x) => x && x.bb && x.bb.value != null);
const rb = rt?.bb ?? {};
const blast = { value: num(rb.value, 1200), stun: num(rb.stun, 3), grid: rt?.rangeGrid?.length ? rt.rangeGrid : X4 };
const halfAt = num(rb.hp_ratio); // PHY-X stage 3: 生命值首次低于50%
battle.addBuff(m, { key: 'kalts:mon3tr:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
battle.on('deploy', (c) => {
if (c.unit !== m) return;
m.mem.halfDone = false;
m.mem.s3NoKill = false;
}, { owner: m });
// her range, her skills: buffs held every tick
battle.on('tick', () => {
if (!up(m)) return;
const inRange = up(owner) && !!owner.rangeKeySet && owner.rangeKeySet.has(m.tileR * COLS + m.tileC);
holdBuff(battle, m, 'kalts:mon3tr:defZero', !inRange, { defMul: 0 });
holdBuff(battle, m, 'kalts:mon3tr:near', inRange && (num(t0.attack_speed) > 0 || num(t0.def) > 0), { aspd: num(t0.attack_speed), defPct: num(t0.def) });
holdBuff(battle, m, 'kalts:s1:mon3tr', skillOn(owner, S1), { defPct: num(b1['attack@def']) });
holdBuff(battle, m, 'kalts:s2:mon3tr', skillOn(owner, S2), { atkPct: num(b2['attack@atk']) });
let s3Atk = 0;
if (skillOn(owner, S3)) {
const sk = owner.skill;
const dur = sk.duration > 0 ? sk.duration : 1;
const steps = Math.floor(battle.time - sk.lastStart + 1e-9); // "攻击力加成每秒更新一次", from the cast
s3Atk = num(b3['attack@atk']) * Math.max(0, 1 - steps / dur);
}
holdBuff(battle, m, 'kalts:s3:mon3tr', skillOn(owner, S3), { atkPct: s3Atk, defPct: num(b3['attack@def']) });
}, { owner: m });
// S2: each attack strikes every enemy it blocks
battle.on('beforeAttack', (c) => {
if (c.attacker !== m || !skillOn(owner, S2)) return;
const blocked = battle.blockedTargets(m, c.profile);
if (blocked.length) c.targets = blocked;
}, { owner: m });
// S3: true damage; a kill keeps its HP
battle.on('hit', (c) => {
if (c.source === m && c.dmg && c.dmg.isAttack && skillOn(owner, S3)) c.dmg.type = 'true';
}, { owner: m, priority: 50 });
battle.on('kill', (c) => { if (c.killer === m && c.victim && c.victim.side === 'enemy') m.mem.s3NoKill = false; }, { owner: m });
// PHY-X stage 3: once per deployment, a hit that leaves it at or below half
if (halfAt > 0) {
battle.on('damaged', (c) => {
if (c.target !== m || m.mem.halfDone || !m.alive || !(m.hp > 0) || m.hpRatio > halfAt + 1e-9) return;
m.mem.halfDone = true;
rattle(battle, m, blast);
}, { owner: m });
}
battle.on('death', (c) => {
if (c.unit !== m || battle.finished) return;
if (c.reason === 'killed') rattle(battle, m, blast); // 被击倒后(不包括撤退)
// the piece stays (退场时返还1个可部署的Mon3tr): back on its tile once ready, while she stands
m.removed = false;
m.mem.readyAt = battle.time + Math.max(0, num(m.base.respawnTime));
if (m.mem.retry) return;
m.mem.retry = battle.every(RETRY, (b, sched) => {
const stop = () => { sched.cancel(); m.mem.retry = null; };
if (m.alive || m.removed || b.finished) { stop(); return; }
if (!up(owner) || b.time + 1e-9 < m.mem.readyAt) return;
if (b.redeploy(m, { free: false })) stop();
}, { owner: m });
}, { owner: m, priority: -10 });
},
};
}
export default {
char_003_kalts: (bb, chess) => {
const t0 = talentBb(chess, 0); // Mon3tr: def, attack_speed (PHY-Y stage 3: 0.2 / 20 inside her range)
const tb = traitBb(chess); // PHY-X: heal_scale, hp_ratio; PHY-Y: heal_scale (ISW-A's: 集成战略-only, unused)
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const mod = moduleOn(chess) ? chess.module.id : null;
const picked = chess?.skill?.skillId ?? null;
return {
// her heals pass her Mon3tr's 禁疗 and her heal selection takes it (PRTS: "持有禁疗(可被凯尔希…无视)")
trait: { healThrough: (healer, a) => isMon3trOf(a, healer) },
skills: {
[S1]: { kind: 'duration', mods: { defPct: num(b1.def) } },
[S2]: {
kind: 'duration', mods: { aspd: num(b2.attack_speed) },
onStart({ battle, unit, skill }) { if (!mon3trsOf(battle, unit).some(up)) skill.end('binding'); }, // 绑定
},
[S3]: {
kind: 'duration',
onStart({ battle, unit, skill }) {
const m = mon3trsOf(battle, unit).find(up);
if (!m) { skill.end('binding'); return; }
m.mem.s3NoKill = true; // kalts_s_3[no_kill_mark]
},
onEnd({ battle, unit, reason }) {
if (reason !== 'duration') return; // ended by 绑定 or her leaving: no loss
for (const m of mon3trsOf(battle, unit)) {
if (!up(m) || !m.mem.s3NoKill) continue;
m.mem.s3NoKill = false;
battle.loseHp(m, m.s.maxHp * num(b3['attack@hp_ratio'], 0.5), { source: m, tags: ['kalts:meltdown'] });
}
},
},
},
talents: [
{ install(battle, unit) { // Mon3tr: her pieces run Mon3tr's kit; withdrawn with her, readied by her deployment
for (const t of battle.allyUnits) {
if (!isMon3trOf(t, unit) || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t);
battle._setupUnit(t, mon3trKit(unit, { t0, b1, b2, b3 }));
}
battle.on('death', (c) => {
if (c.unit !== unit) return;
for (const t of mon3trsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'retreat', permanent: true });
}, { owner: unit });
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
for (const t of mon3trsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time;
}, { owner: unit });
// the heal order: herself, her Mon3tr (and, with no module, an operator Mon3tr) first when injured
const noModule = !moduleOn(chess);
const first = (a) => a === unit || isMon3trOf(a, unit) || (noModule && a.kind === 'op' && a.def?.charId === OP_MON3TR);
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !c.profile || c.profile.dmgType !== 'heal') return;
const pri = battle.injuredAlliesInKeys(unit.rangeKeys, unit).filter(first);
if (!pri.length) return;
pri.sort((a, b) => a.hpRatio - b.hpRatio || (a === unit ? -1 : b === unit ? 1 : 0) || a.deploySeq - b.deploySeq);
c.targets = [pri[0]];
}, { owner: unit, priority: 10 });
} },
// 不毁重构 lives in Mon3tr's kit (its knock-out, PHY-X stage 3's half-HP blast)
],
install(battle, unit) {
// PHY-X / PHY-Y: ×heal_scale on the heals she outputs to a target at or below hp_ratio / on a 地面 tile
const hs = num(tb.heal_scale, 1);
const boosted = mod === PHY_X ? (t) => t.hpRatio <= num(tb.hp_ratio, 0.5) + 1e-9 : mod === PHY_Y ? (t) => !!t.ground : null;
if (boosted && hs !== 1) {
battle.on('heal', (c) => {
if (c.source !== unit || c.opts?.regen || !c.target || !boosted(c.target)) return;
c.amount *= hs;
}, { owner: unit });
}
// S1 物理格挡: a physical damage instance blocked whole with prob
if (picked === S1) {
const p = num(b1.prob);
battle.on('hit', (c) => {
if (c.target !== unit || !skillOn(unit, S1) || !c.dmg || c.dmg.cancel || c.dmg.type !== 'phys' || !(p > 0)) return;
if (!battle.rng.chance(p)) return;
c.dmg.cancel = true;
battle.fx('block', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit, priority: -500 });
}
// 绑定 (S2 / S3): no Mon3tr on the field ⇒ the skill ends, the SP is emptied and recovers no more
if (picked === S2 || picked === S3) {
battle.every(BIND_IV, () => {
if (!unit.alive || !unit.deployed || !unit.skill) return;
if (mon3trsOf(battle, unit).some(up)) { if (unit.findBuff(BIND_KEY)) battle.removeBuff(unit, BIND_KEY); return; }
const sk = unit.skill;
if (sk.active) sk.end('binding');
sk.sp = 0;
sk.charges = 0;
if (!unit.findBuff(BIND_KEY)) battle.addBuff(unit, { key: BIND_KEY, flags: { noSp: true }, tags: ['skill'] });
}, { owner: unit, immediate: true });
}
},
};
},
};
+368
View File
@@ -0,0 +1,368 @@
// server/sim/content/kits/ops/op-mcnist.js — 机械师 (char_4230_mcnist) 自选 operator kit: 6★ 哨戒铁卫 (重装), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots — not the 补位 stand-in Mechanist (char_610_acfend, standin-acfend.js); every skill,
// both talents, the trait and her module SO-A 机械师特勤证章 at every form, and the kit of her summon 结构性原理
// (token_10069_mcnist_mcgraf). Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4230_mcnist): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7,
// the module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json); PRTS 机械师
// (生命方程 备注: the 【超额防护】 of her own 屏障 — "当此屏障吸收部分伤害…后,若此时“持有的所有同来源的屏障”的屏障值总和等于0
// 时,将本次伤害直接视为抵挡"; S1 备注 "弹道溅射半径1.1(碰撞判定),可对空"; S2 备注 "屏障“被摧毁时...”的效果,其溅射半径1.5(中点
// 判定),可对空;该效果存在极短的触发冷却…且冷却期间强制格挡受到的所有伤害", "屏障持续时间无限,但技能结束后屏障不再具备“被摧毁时...”
// 的效果"; S3 备注 "每次攻击变为向攻击目标所在地块中心投掷十字星弹道,弹道延迟0.8秒后落地,伤害范围x-6(碰撞判定),可对空",
// "结构性原理的“冲锋”效果为向其部署方向发射弹道,弹道终点位于其正前方尽可能远的可通行、可部署地面地块(无法越过存在敌人的地块以及
// 高台地块)", "弹道停止时,以该地块为中心对半径1.5的敌方单位产生伤害(碰撞判定);那之后,场上的结构性原理立刻撤退并在停止地块上
// 重新部署(非移动),随后其获得半径1.5(中点判定)的【虚弱光环】…初始为50%,每秒减少2.5%,降至0%后虚弱光环结束", "技能开启时若场上
// 不存在结构性原理,则冲锋会保留至下次部署结构性原理后再触发"); PRTS 结构性原理 (备注 "持有禁疗", 天赋 备注 "此天赋提供的屏障视为
// 来源于自身的持有者"); the client's battle data (charpack char_4230_mcnist: Talents/1 charge_token[born] + die_to_kill_token,
// Talents/2 mcnist_t_2, the 精通 abilities tagged rogue_6; token prefab token_10069_mcnist_mcgraf: mcgraf_s_3_passive
// (abnormal flag 7 = 禁疗), charge_token[finish], Talents/2 mcnist_t_2, Talents/-1 the weaken aura; skill prefabs
// skchr_mcnist_2 (ammo counted by CharSkillManualTriggerCountEvent, not by attacks) / skchr_mcnist_3; projectiles
// projectile_chr_mcnist_s1 / _s3 / _s3_token_*; buff_template_data mcnist_t_2, mcnist_s_2_mode, mcnist_s_2_shield (BlockDamage
// PHYSICAL_AND_MAGICAL), mcnist_s_3_projectile*, mcgraf_s_3_trigger / _aura / _mark, mcgraf_t_suicide).
// - Trait (哨戒铁卫): the profession default — ranged physical, blocks 3, hits air units; ground enemies target her.
// - Module SO-A 机械师特勤证章: only its attributes (ATK / DEF, in the stats) act here. Its trait ("在【沉沦者的黑流树海】中,攻击
// 被自身或召唤物阻挡的敌人时攻击力提升至150%") and its stage-2 / 3 talent ("在【沉沦者的黑流树海】中,结构性原理的持续时间无限,
// 5秒内未受到伤害后每秒获得4%最大生命的屏障") name a 集成战略 theme (沉沦者的黑流树海 = rogue_6, PRTS 精通 "希望", "临时招募"):
// N/A outside it (the brief's rule) — the token's merged module talents (skill@duration −1, the barrier regen) are ignored.
// - T1 结构性原理: her 结构性原理 is a hand piece the player places; it deploys with the board, lasts the token talent's
// skill@duration (30 s; the base variant — the SO-A override is IS-only) from each deployment, blocks 3 and strikes one
// ground enemy in melee for physical damage (its data), holds 禁疗 (PRTS; mcgraf_s_3_passive flag 7 — the data's
// `abnormal` lacks it [ASSUMED: given here]). 拥有独立的再部署时间: back on its tile its respawnTime (40 s) after it left,
// paying its cost (10 DP), only while she stands; her (re)deployment readies a waiting one at once (charge_token[born]);
// it falls when she leaves (die_to_kill_token) [ASSUMED: the 卫戍 auto redeploy of a placed summon, as 鸿雪's 打字机].
// - T2 生命方程: she and her 结构性原理, at each deployment, get a 屏障 of hp_ratio × their own max HP (infinite until spent).
// Every 屏障 that comes from her (this one, S2's, the token's — "视为来源于自身的持有者") absorbs physical and arts damage
// only (BlockDamage PHYSICAL_AND_MAGICAL: buff `shieldType` ['phys', 'arts']) and has 超额防护: the hit that breaks it is
// cancelled whole when no 屏障 of hers is left on that unit afterwards (the `hpDamage` hook — what passed the shields).
// - S1 聚类分析 (AUTO, attack SP, data DEFAULT — an AUTO "next attacks" skill waits for her attack): 3 bullets (`ammo`); attack
// interval +base_attack_time (1.3 s); each attack lands where its target is and strikes 5 times (attack@times), every
// attack@interval s, every enemy within attack@projectile_range (1.1, air units too) for attack@atk_scale × ATK physical —
// the first strike on impact through the engine's splash, the next four from the impact point with the ATK of the first.
// - S2 协防术式 (MANUAL, data DEFAULT): ATK +atk; she and her 结构性原理 (one deployed later during the skill too) get a 屏障 of
// hp_ratio × their max HP (a re-cast replaces it). When one breaks while the skill runs: every enemy within range_radius
// (1.5, 中点判定, air units too) of its holder takes atk_scale × her ATK arts and 战栗 not_combat s (none on a dodged hit
// [ASSUMED]); meanwhile the holder takes no damage; then, with a bullet left (trigger_time bullets — attacks spend none), one
// is spent and the holder gets a new 屏障; the last bullet ends the skill and leaves that 屏障 (infinite, with no break effect).
// - S3 工程学十字星 (MANUAL, data DEFAULT, 40 s): ATK +atk, attack interval +base_attack_time (2.3 s); each attack throws a
// cross at the centre of its target's tile: X6_DELAY s later every selectable enemy on the x-6 around it (attack@
// projectile_range "x-6"; air units too) takes attack@atk_scale × her ATK of the throw, arts; the enemies her 结构性原理
// blocks are her targets too (setExtraRange: their tiles while the skill runs). The cast (or, without a 结构性原理 on the
// field, its next deployment during the skill — and every later one) makes it charge along its facing to the farthest
// passable, deployable low tile before an enemy's tile or high ground: CHARGE_DELAY s + the distance at CHARGE_SPEED, then
// every enemy within projectile_range (1.5) of the stop tile takes atk_scale × her ATK physical, and the 结构性原理 is
// deployed anew there (free; a new deployment: full HP, 生命方程 again — not a 移动). While the skill runs, each deployment of
// it carries a weaken aura of range_radius (1.5, 中点判定): 虚弱 atk (50 %) on the enemies inside, 2.5 % weaker every second
// (atk_interval / interval), gone at 0 or when the skill ends.
import { num, skillRec, batMod, up } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { dirVec } from '../../../dir.js';
const S1 = 'skchr_mcnist_1';
const S2 = 'skchr_mcnist_2';
const S3 = 'skchr_mcnist_3';
/** Her summon 结构性原理 (talent 结构性原理). */
export const MCGRAF = 'token_10069_mcnist_mcgraf';
/** Her 屏障 absorb physical and arts damage only (BlockDamage PHYSICAL_AND_MAGICAL). */
const BARRIER_TYPES = Object.freeze(['phys', 'arts']);
const T2_KEY = 'mcnist:t2:barrier';
const S2_KEY = 'mcnist:s2:barrier';
/** S3 cross (range_table x-6: the plus of reach 2 around the landing tile) and its landing delay (PRTS 备注). */
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
const X6_DELAY = 0.8;
/** S3 charge (projectile_chr_mcnist_s3_token_*: `_delayToStart` 0.2, speed 20 tiles / s). */
const CHARGE_DELAY = 0.2;
const CHARGE_SPEED = 20;
/** Weaken aura refresh / buff life (s) and its fallback numbers (mcgraf hidden talent: atk −0.5, +0.025 per 1 s). */
const AURA = 0.2;
const AURA_DUR = 0.25;
/** 结构性原理's life when no source carries it (PRTS: 部署后持续30秒 at E2). */
const LIFE_FALLBACK = 30;
/** How often a waiting 结构性原理 tries to come back once ready. */
const RETRY = 0.1;
const TAG_CROSS = 'mcnist:cross';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const isGrafOf = (t, unit) => !!t && t.kind === 'token' && t.defId === MCGRAF && t.ownerUnit === unit;
const grafsOf = (battle, unit) => battle.allyUnits.filter((t) => isGrafOf(t, unit));
const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id);
/** First talent of a normalised token def holding `key` (token talents keep no index). */
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null)?.bb ?? null;
/** Every 屏障 that comes from `mcn` on `holder` (hers, or her token's: "视为来源于自身的持有者"). */
const barrierLeft = (holder, mcn) => {
let s = 0;
for (const b of holder.buffs) if (b.data && b.data.mcnist === mcn && b.shield > 0) s += b.shield;
return s;
};
/** A 屏障 from `mcn` on `holder`: `ratio` × the holder's max HP, physical and arts only, until spent. */
function grantBarrier(battle, mcn, holder, key, ratio) {
if (!(ratio > 0) || !holder.alive) return null;
battle.removeBuff(holder, key);
const buff = battle.addBuff(holder, {
key, shield: holder.s.maxHp * ratio, shieldType: BARRIER_TYPES, visible: true, source: mcn, tags: ['barrier'],
data: { mcnist: mcn },
// spent by a hit (damage.js absorbShields removes it at 0): S2's break effect waits for the hit to resolve (`damaged`)
onRemove: ({ buff: b }) => { if (key === S2_KEY && !(b.shield > 1e-9)) holder.mem.mcnistBroken = true; },
});
battle.fx('shield', { x: holder.x, y: holder.y, id: holder.id });
return buff;
}
/**
* Where the charge stops: from the token's tile along its facing, the farthest tile before a tile it cannot cross (outside
* the field, not ground-passable, high ground, an enemy on it); then, back towards the token, the first of those tiles it
* can be deployed on (free, standable for a melee unit, low). Its own tile when it cannot move.
*/
function chargeEnd(battle, t) {
const [fr, fc] = dirVec(t.dir);
const path = [[t.tileR, t.tileC]];
for (let k = 1; k < 32; k++) {
const r = t.tileR + fr * k, c = t.tileC + fc * k;
if (!battle.grid.inRect(r, c) || !battle.grid.groundPassable(r, c) || !battle.grid.isLow(r, c)) break;
if (battle.enemies.some((e) => e.alive && !e.hidden && Math.round(e.y) === r && Math.round(e.x) === c)) break;
path.push([r, c]);
}
const stop = path[path.length - 1];
for (let i = path.length - 1; i > 0; i--) {
const [r, c] = path[i];
if (!battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: false })) return { stop, land: [r, c] };
}
return { stop, land: path[0] };
}
/**
* A 结构性原理 that left the field comes back on its tile its respawnTime later (her (re)deployment: at once —
* charge_token[born] sets readyAt), paying its cost, only while she stands; retried until it deploys.
*/
function scheduleReturn(battle, owner, t) {
t.mem.readyAt = battle.time + Math.max(0, num(t.base.respawnTime));
if (t.mem.retry) return;
t.mem.retry = battle.every(RETRY, (b, sched) => {
const stop = () => { sched.cancel(); t.mem.retry = null; };
if (t.alive || t.removed || b.finished) { stop(); return; }
if (!up(owner) || b.time + 1e-9 < t.mem.readyAt) return;
if (b.redeploy(t, { free: false })) stop();
}, { owner: t });
}
/**
* The 结构性原理's kit (lifetime, 禁疗, 生命方程, S2's 屏障, the S3 charge and weaken aura, its redeploy). `owner` = 机械师,
* `life` = its lifetime, `t2` = 生命方程's hp_ratio, `b2` / `b3` = her S2 / S3 blackboards.
*/
function grafKit(owner, { life, t2, b2, b3, aura }) {
return {
skill: null,
talents: [],
install(battle, t) {
battle.addBuff(t, { key: 'mcnist:graf:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
battle.on('deploy', (c) => {
if (c.unit !== t) return;
const respawned = !!t.mem.chargeLanding;
t.mem.chargeLanding = false;
const seq = t.deploySeq;
if (life > 0) {
battle.after(life, () => { if (t.alive && t.deploySeq === seq) battle.retreat(t, { reason: 'expired', permanent: true }); }, { owner: t });
}
grantBarrier(battle, owner, t, T2_KEY, t2); // 生命方程
if (skillOn(owner, S2)) grantBarrier(battle, owner, t, S2_KEY, num(b2.hp_ratio)); // a deployment during S2
if (skillOn(owner, S3)) {
t.mem.weakenFrom = battle.time; // the weaken aura of this deployment
// a deployment during S3 (not the charge's own landing) charges — the cast's charge kept until then — once
// the deployment has resolved (the next tick: ON_OWNER_LOCATE)
if (!respawned) battle.after(0, () => { if (t.alive && t.deploySeq === seq && skillOn(owner, S3)) chargeGraf(battle, owner, t, b3); }, { owner: t });
} else t.mem.weakenFrom = null;
}, { owner: t });
// the weaken aura: 虚弱 on the enemies within its radius, 2.5 % weaker every second, gone at 0 or when S3 ends
battle.every(AURA, () => {
if (!up(t) || t.mem.weakenFrom == null || !skillOn(owner, S3)) return;
const steps = Math.floor((battle.time - t.mem.weakenFrom) / aura.interval + 1e-9);
const v = Math.max(0, aura.start - aura.step * steps);
if (!(v > 0)) return;
for (const e of battle.foesInRadius(t.x, t.y, aura.radius, true)) {
battle.applyStatus(e, 'weaken', { duration: AURA_DUR, value: v, source: t });
}
}, { owner: t });
battle.on('death', (c) => {
if (c.unit !== t || battle.finished) return;
t.removed = false; // the piece stays: back on its tile once ready (or on the charge's landing tile)
if (!t.mem.charging) scheduleReturn(battle, owner, t);
}, { owner: t, priority: -10 });
},
};
}
/** S3: the 结构性原理 charges along its facing, strikes around the stop tile and is deployed anew there. */
function chargeGraf(battle, mcn, t, b3) {
if (!up(t)) return;
const { stop, land } = chargeEnd(battle, t);
const dist = Math.hypot(stop[0] - t.tileR, stop[1] - t.tileC);
t.mem.charging = true;
battle.fx('charge', { x: t.x, y: t.y, id: t.id, tx: stop[1], ty: stop[0] });
battle.retreat(t, { reason: 'expired', permanent: true });
t.mem.charging = false;
const atk = mcn.s.atk, scale = num(b3.atk_scale, 3), radius = num(b3.projectile_range, 1.5);
battle.after(CHARGE_DELAY + dist / CHARGE_SPEED, () => {
battle.fx('aoe', { x: stop[1], y: stop[0], radius, id: mcn.id, skill: 'mcnist:charge' });
for (const e of battle.foesInRadius(stop[1], stop[0], radius)) {
if (e.alive) battle.dealDamage(mcn, e, { amount: atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'mcnist:charge'] });
}
if (t.alive || t.removed || battle.finished) return;
// she fell meanwhile (die_to_kill_token): no landing, it waits for her like any piece [ASSUMED]
if (!up(mcn)) { scheduleReturn(battle, mcn, t); return; }
t.mem.chargeLanding = true;
if (!battle.redeploy(t, { free: true, tile: land }) && !battle.redeploy(t, { free: true })) {
t.mem.chargeLanding = false;
scheduleReturn(battle, mcn, t); // nowhere to land: an ordinary wait for its redeploy [ASSUMED]
}
}, { owner: mcn });
}
/** S3's cross: every selectable enemy on the x-6 around (r, c) takes `atk` × scale arts, X6_DELAY s after the throw. */
function throwCross(battle, unit, r, c, atk, scale) {
battle.fx('aoe', { x: c, y: r, radius: 2, id: unit.id, skill: 'mcnist:cross', tiles: X6.map(([dr, dc]) => [r + dr, c + dc]) });
battle.after(X6_DELAY, () => {
const keys = X6.map(([dr, dc]) => (r + dr) * COLS + (c + dc)).filter((k) => k >= 0);
for (const e of battle.enemiesInKeys(keys, unit, { canHitFly: true })) {
if (e.alive) battle.dealDamage(unit, e, { amount: atk * scale, type: 'arts', isAttack: true, isSkill: true, tags: ['skill', TAG_CROSS] });
}
}, { owner: unit });
}
export default {
char_4230_mcnist: (bb, chess) => {
const t1 = (chess?.talents ?? []).find((t) => t && t.index === 1)?.bb ?? {}; // 生命方程: hp_ratio
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1Scale = num(b1['attack@atk_scale'], 1), s1Times = Math.max(1, Math.floor(num(b1['attack@times'], 5)));
const s1Iv = num(b1['attack@interval'], 0.2), s1Radius = num(b1['attack@projectile_range'], 1.1);
return {
skills: {
[S1]: {
kind: 'ammo', ammo: num(b1['attack@trigger_time'], 3),
mods: { batPct: batMod(b1.base_attack_time, chess) },
attack: {
atkScale: s1Scale, splashRadius: s1Radius,
// the four strikes after the impact's, from the impact point (the target dead meanwhile: where it was)
onHit({ battle, unit, x, y }) {
const atk = unit.s.atk;
for (let i = 1; i < s1Times; i++) {
battle.after(s1Iv * i, () => {
for (const e of battle.foesInRadius(x, y, s1Radius)) {
if (e.alive) battle.dealDamage(unit, e, { amount: atk * s1Scale, type: 'phys', isAttack: true, isSplash: true, isSkill: true, tags: ['skill', 'mcnist:cluster'] });
}
}, { owner: unit });
}
},
},
},
[S2]: {
kind: 'ammo', ammo: num(b2.trigger_time, 6),
mods: { atkPct: num(b2.atk) },
onAttack(ctx) { ctx.noAmmo = true; }, // her bullets go on 屏障 only (CharSkillManualTriggerCountEvent)
onStart({ battle, unit }) {
grantBarrier(battle, unit, unit, S2_KEY, num(b2.hp_ratio));
for (const t of grafsOf(battle, unit)) if (up(t)) grantBarrier(battle, unit, t, S2_KEY, num(b2.hp_ratio));
},
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
// the attack is the cross: no damage on the target itself (its 0.8 s later landing does it)
attack: { dmgType: 'arts', hitsFn: () => 0 },
onAttack({ battle, unit, targets }) {
const tg = (targets || []).find((e) => e && e.side === 'enemy');
if (tg) throwCross(battle, unit, Math.round(tg.y), Math.round(tg.x), unit.s.atk, num(b3['attack@atk_scale'], 1));
},
onStart({ battle, unit }) {
const t = grafsOf(battle, unit).find(up);
if (t) chargeGraf(battle, unit, t, b3); // without one: its next deployment during the skill charges
},
onEnd({ battle, unit }) { battle.setExtraRange(unit, null); },
},
},
talents: [
{ install(battle, unit) { // 结构性原理: her pieces run the token's kit (set up before the battle starts)
const raw = battle.data.rawToken?.(MCGRAF)?.variants?.[unit.def?.tokenOwner] ?? null;
for (const t of battle.allyUnits) {
if (!isGrafOf(t, unit) || t.alive || t.deployed) continue;
// its life: the base variant's (the SO-A stage-3 skill@duration −1 is 集成战略-only)
const base = num(tokTalent(raw ? { talents: raw.talents } : null, 'skill@duration')?.['skill@duration'], NaN);
const merged = num(tokTalent(t.def, 'skill@duration')?.['skill@duration'], NaN);
const life = base > 0 ? base : merged > 0 ? merged : LIFE_FALLBACK;
const w = tokTalent(t.def, 'atk_interval') ?? {};
const aura = { start: Math.abs(num(w.atk, -0.5)), step: Math.abs(num(w.atk_interval, 0.025)), interval: num(w.interval, 1) || 1, radius: num(w.range_radius, 1.5) };
const t2 = num(tokTalent(t.def, 'hp_ratio')?.hp_ratio, num(t1.hp_ratio));
if (t.kit) battle.offOwner(t);
battle._setupUnit(t, grafKit(unit, { life, t2, b2, b3, aura }));
}
// die_to_kill_token: it falls when she leaves; charge_token[born]: her (re)deployment readies a waiting one
battle.on('death', (c) => {
if (c.unit !== unit) return;
for (const t of grafsOf(battle, unit)) if (t.alive) battle.kill(t, null);
}, { owner: unit });
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
for (const t of grafsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time;
}, { owner: unit });
} },
{ install(battle, unit) { // 生命方程: her own 屏障 at each deployment (the token's: its kit)
battle.on('deploy', (c) => { if (c.unit === unit) grantBarrier(battle, unit, unit, T2_KEY, num(t1.hp_ratio)); }, { owner: unit });
} },
],
install(battle, unit) {
const holds = (h) => h === unit || isGrafOf(h, unit);
// 超额防护: the hit that breaks the last 屏障 of hers on a unit is cancelled whole (PRTS 生命方程 备注)
const before = new WeakMap();
battle.on('hit', (c) => {
const h = c.target;
if (!holds(h) || !c.dmg || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return;
before.set(c.dmg, barrierLeft(h, unit));
}, { owner: unit, priority: -1000 });
// S2's break effect is running: the holder takes nothing meanwhile ("冷却期间强制格挡受到的所有伤害")
battle.on('hit', (c) => { if (holds(c.target) && c.target.mem.mcnistLock && c.dmg) c.dmg.cancel = true; }, { owner: unit, priority: 1000 });
battle.on('hpDamage', (c) => {
if (!holds(c.target) || !(before.get(c.dmg) > 0)) return;
if (barrierLeft(c.target, unit) <= 1e-9) c.amount = 0;
}, { owner: unit });
// S2: a 屏障 broken while the skill runs — the burst around its holder, then a bullet buys a new one
battle.on('damaged', (c) => {
const h = c.target;
if (!holds(h) || !h.mem.mcnistBroken) return;
h.mem.mcnistBroken = false;
const sk = unit.skill;
if (!skillOn(unit, S2) || !h.alive) return;
h.mem.mcnistLock = true;
try {
const r = num(b2.range_radius, 1.5);
battle.fx('aoe', { x: h.x, y: h.y, radius: r, id: unit.id, skill: 'mcnist:shieldBurst' });
const dodged = new Set();
const dh = battle.on('dodge', (d) => { if (d.source === unit && d.dmg?.tags?.includes('mcnist:shieldBurst')) dodged.add(d.target); });
for (const e of battle.foesInRadius(h.x, h.y, r, true)) {
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 2), type: 'arts', isSkill: true, tags: ['skill', 'mcnist:shieldBurst'] });
if (e.alive && !dodged.has(e)) battle.applyStatus(e, 'tremble', { duration: num(b2.not_combat, 2), source: unit });
}
battle.off(dh);
} finally { h.mem.mcnistLock = false; }
if (!sk || !sk.active || sk.id !== S2 || !(sk.ammoLeft > 0) || !h.alive) return;
sk.ammoLeft--;
battle.emit('ammoUsed', { unit, left: sk.ammoLeft, skill: sk });
if (sk.ammoLeft <= 0) sk.end('ammo');
grantBarrier(battle, unit, h, S2_KEY, num(b2.hp_ratio)); // the last bullet's 屏障 outlives the skill
}, { owner: unit, priority: -50 });
// S3: the enemies her 结构性原理 blocks are her targets too
battle.on('tick', () => {
if (!skillOn(unit, S3)) return;
const keys = [];
for (const t of grafsOf(battle, unit)) {
if (!up(t)) continue;
for (const e of t.blocking) if (e.alive) keys.push(Math.round(e.y) * COLS + Math.round(e.x));
}
const cur = unit.extraRangeKeys ?? [];
if (keys.length !== cur.length || keys.some((k, i) => k !== cur[i])) battle.setExtraRange(unit, keys.length ? keys : null);
}, { owner: unit });
},
};
},
};
+11 -5
View File
@@ -184,14 +184,16 @@ export function leaderHitCancelled(battle, target, amount) {
/**
* Absorb damage with shields on `target`. Returns the remaining amount. `type` = the damage type: a shield buff with a
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害"); one without absorbs every type (PRTS 术语释义 屏障
* "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order: "优先消耗先生成的屏障").
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害") — or, a list of types, only those (机械师's 屏障:
* BlockDamage PHYSICAL_AND_MAGICAL, buff_template_data mcnist_t_2 / mcnist_s_2_shield: ['phys', 'arts']); one without
* absorbs every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order:
* "优先消耗先生成的屏障").
*/
export function absorbShields(battle, target, amount, type = null) {
if (amount <= 0) return 0;
let changed = false;
let rest = amount;
const absorbs = (b) => !b.shieldType || b.shieldType === type;
const absorbs = (b) => !b.shieldType || (Array.isArray(b.shieldType) ? b.shieldType.includes(type) : b.shieldType === type);
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shieldHits > 0 && absorbs(b)) {
@@ -526,13 +528,17 @@ export function reduceElement(target, amount, el = null) {
* with an hpRegen buff, PRTS 备注 "不受治疗加成和禁疗影响") is no 治疗: no 治疗加成 scales it — neither the healing
* multipliers nor a `heal` handler (handlers still see it, with `opts.regen`; a change they make to its amount is
* ignored).
* A healer whose profile names the target in `healThrough(healer, target)` heals it through its 禁疗 — the `noHeal` flag
* a summon's 禁疗 sets and the flag `healFree` (not a profile's `noHeal`, 无法被友方治疗): 凯尔希 on her Mon3tr (PRTS
* Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…无视)"; her heal selection takes it too, Battle.injuredAlliesInKeys).
*/
export function heal(battle, source, target, amount, opts = {}) {
if (!target || !target.alive || target.removed || !target.deployed || target.bossPool) return 0;
const self = source === target || !!opts.self;
if (!self && (target.s.flags.noHeal || (target.profile && target.profile.noHeal))) return 0;
const through = !!(source && source.profile && typeof source.profile.healThrough === 'function' && source.profile.healThrough(source, target));
if (!self && ((target.s.flags.noHeal && !through) || (target.profile && target.profile.noHeal))) return 0;
const regen = !!opts.regen;
if (target.s.flags.healFree && !regen && !opts.ignoreHealFree) return 0;
if (target.s.flags.healFree && !regen && !opts.ignoreHealFree && !through) return 0;
let amt = regen ? amount : amount * (source && source.s ? source.s.healingDealtMul : 1) * target.s.healingTakenMul;
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
if (battle._hooks.heal) {
+2
View File
@@ -22,6 +22,8 @@
// dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y})
// canAttack(battle, unit) → bool afterAttack(battle, unit, targets)
// skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位")
// healThrough(healer, ally) → bool (a healer that selects and heals that ally through its 禁疗 — Battle
// injuredAlliesInKeys, damage.js heal; 凯尔希 on her Mon3tr)
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait)
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)