The contributor's code (server/sim/content/traitMods.js, the battle/players.js _setupUnit wiring after kit.install, the crowdAspd removal from shared/tier5.js and the two kits, test/sim/trait_attack_speed.test.js), applied from PR #293, so they are the author of this commit. Maintainer additions: the coverage lock also scans the 补位 stand-ins (266) and every legal 自选 combination (pool operator × slot form × skill × non-other-mode module) — none gains the rule; 黑键 / 维伊 MSC-Y 「拥有已储存的攻击能量时,攻击速度+30」 (their 自选 kits own it) is listed as kit-owned instead of 'unknown'; the comment's history note named a commit this repository does not have, and its line-number references are now names; docs/ARCHITECTURE.md and kits/README.md name the file. Official: battle_equip_table uniequip_003_excu2 (display trait + hidden talent attack_speed 12, trigger_cnt[equip] 2) and uniequip_003_etlchi (trait attack_speed 12, cnt 2), both 「攻击范围内存在2名及以上敌人时攻击速度+12」; 仇白's uniequip_003_qiubai carries the same sentence on her named talent, which her kit keeps reading (not seen by the engine). Before: shared/tier5.js crowdAspd polled every 0.2 s and gave a 0.3 s buff — measured +12 0.167 s after the second enemy arrived and 0.267 s after it died (scratchpad repro); now one tick each. Golden: unchanged (no sampled snapshot moves).
186 lines
12 KiB
JavaScript
186 lines
12 KiB
JavaScript
// server/sim/content/kits/ops/chess_char_5_06-etlchi.js — 隐德来希 (char_4010_etlchi) kit, tier 5.
|
||
// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md.
|
||
|
||
import { mitigate } from '../../../damage.js';
|
||
import {
|
||
RING1, num, on, talent, talentRec, skillGrid, mods, dist, lazySkills, instantKind,
|
||
} from '../shared/tier5.js';
|
||
|
||
/** 隐德来希 S3 "每次攻击对心烛至少造成35%攻击力的伤害". */
|
||
const CANDLE_MIN_ATK = 0.35;
|
||
/** 隐德来希 S3 心烛 enemy key (hidden talent bbStr take_extra_enemy_key; not in data/enemies.json). */
|
||
const CANDLE_KEY = 'enemy_5601_entlec';
|
||
|
||
export default {
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 隐德来希 — S3 灵与欲的惜别 (20 s): skill range, ATK +, ASPD +100; summons a 心烛 for up to 3 highest-HP ground enemies
|
||
// in range (60 % of their current HP, same DEF/RES); his hits on a candle deal at least 35 % ATK and the original loses
|
||
// the same HP; only he can damage candles (other allies' attacks are redirected to the original).
|
||
// T1 萃血: each hit steals 75 max HP (≤ 1350) and applies 200 arts/s for 5 s. T2 重盈: once below 25 % HP, heal 50 %
|
||
// max HP and take −10 % phys damage afterwards. Reaper trait (heal per hit, module 60) comes from the profession.
|
||
// S1 玫影觅迹 (instant, attack SP): next attack atk_scale × ATK, twice. S2 绯红壁合 (duration): no attacks; blood sickles
|
||
// on herself and on one other ground unit (the ally with the most enemies around it) cut every enemy around them (RING1)
|
||
// for atk_scale × ATK phys every `interval` s — ground enemies only unless the carrier has taken off (PRTS 备注).
|
||
// Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
|
||
chess_char_5_06_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1);
|
||
const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1);
|
||
const nCandles = Math.max(1, num(bb['attack@max_target'], 3));
|
||
const candleKey = talentRec(chess, 2)?.bbStr?.take_extra_enemy_key ?? CANDLE_KEY;
|
||
const aroundN = (battle, a) => battle.foesInRadius(a.x, a.y, RING1).length;
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_etlchi_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), hits: 2 } }),
|
||
skchr_etlchi_2: () => ({
|
||
kind: 'duration', attack: { noAttack: true },
|
||
onStart({ battle, unit }) {
|
||
const other = battle.alliesFor(unit).filter((a) => a !== unit && a.ground && a.hp > 0)
|
||
.sort((a, b) => aroundN(battle, b) - aroundN(battle, a) || dist(a, unit) - dist(b, unit) || a.id - b.id)[0] ?? null;
|
||
unit.mem.sickles = other ? [unit, other] : [unit];
|
||
unit.mem.sickleAcc = 0;
|
||
for (const a of unit.mem.sickles) battle.fx('aoe', { x: a.x, y: a.y, id: unit.id, r: RING1, skill: 'etlchiSickle' });
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
const iv = Math.max(0.1, num(bb.interval, 0.5));
|
||
unit.mem.sickleAcc += dt;
|
||
while (unit.mem.sickleAcc >= iv - 1e-9) {
|
||
unit.mem.sickleAcc -= iv;
|
||
for (const a of unit.mem.sickles || []) {
|
||
if (!a.alive || !a.deployed) continue;
|
||
// PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空);
|
||
// 起飞 = an airborne skywalker (蒂比's skills: flag `liftoff`; still a 地面单位, so she can carry one)
|
||
const air = !!a.s.flags.liftoff;
|
||
for (const e of battle.foesInRadius(a.x, a.y, RING1)) {
|
||
if (e.isFlying && !air) continue;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] });
|
||
}
|
||
}
|
||
}
|
||
},
|
||
onEnd({ unit }) { unit.mem.sickles = null; },
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
onStart({ battle, unit }) {
|
||
unit.mem.candles = [];
|
||
const picks = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false })
|
||
.filter((e) => !e.isFlying && !e.mem?.candleOwner && !e.isBoss)
|
||
.sort((a, b) => b.hp - a.hp || a.spawnSeq - b.spawnSeq).slice(0, nCandles);
|
||
for (const e of picks) {
|
||
const r = Math.round(e.y), c = Math.round(e.x);
|
||
// 心烛 is not in data/enemies.json: an inline record (neutral, never attacks, never moves, not counted)
|
||
const def = {
|
||
name: '心烛', rank: 'NORMAL', applyWay: 'NONE', motion: 'WALK', notCountInTotal: true, lifePointReduce: 0,
|
||
stats: { maxHp: Math.max(1, e.hp * hpScale), atk: 0, def: e.s.def * defScale, magicResistance: e.s.res * resScale, moveSpeed: 0, massLevel: num(e.base.massLevel, 1) },
|
||
};
|
||
const cd = battle.spawnEnemy(candleKey, {
|
||
def, pos: [e.y, e.x], route: { motion: 'WALK', start: [r, c], end: [r, c], checkpoints: [] },
|
||
countInTotal: false, ownerPlayerId: e.ownerId,
|
||
});
|
||
if (!cd) continue;
|
||
cd.name = '心烛';
|
||
Object.assign(cd.base, { maxHp: Math.max(1, e.hp * hpScale), atk: 0, def: e.s.def * defScale, res: e.s.res * resScale, moveSpeed: 0 });
|
||
cd.profile = { ...(cd.profile || {}), noAttack: true };
|
||
cd.markDirty();
|
||
cd.hp = cd.s.maxHp;
|
||
cd.mem.candleOwner = unit;
|
||
cd.mem.candleOf = e;
|
||
cd.mem.noLeak = true; // never a leak at the time limit (Battle._timeout)
|
||
battle.addBuff(cd, { key: 'etlchi:candle', flags: { unblockable: true, noMove: true } });
|
||
unit.mem.candles.push(cd);
|
||
battle.fx('candle', { x: cd.x, y: cd.y, id: cd.id, of: e.id });
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
for (const cd of unit.mem.candles || []) if (cd.alive) battle.kill(cd, null);
|
||
unit.mem.candles = [];
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 萃血
|
||
const steal = num(t0['attack@steal_hp']), cap = num(t0['attack@steal_hp_max']);
|
||
const dot = num(t0.magic_value), dotDur = num(t0.dot_duration, 5), iv = num(t0.interval, 1);
|
||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.stolen = 0; }, { owner: unit });
|
||
battle.on('damaged', (c) => {
|
||
if (c.source !== unit || !c.dmg?.isAttack || c.type === 'element') return;
|
||
const e = c.target;
|
||
if (e.side !== 'enemy' || !e.alive || e.mem?.candleOwner || !on(unit)) return;
|
||
if (dot > 0) {
|
||
battle.addBuff(e, { key: `etlchi:dot:${unit.id}`, duration: dotDur, interval: iv, refresh: 'extend',
|
||
onTick: ({ unit: x }) => battle.dealDamage(unit, x, { amount: dot, type: 'arts', tags: ['talent', 'dot'] }) });
|
||
}
|
||
const stolen = num(unit.mem.stolen);
|
||
// (a boss sharing the match HP pool keeps its max HP: the pool is owned by the match)
|
||
const n = Math.min(steal, cap - stolen, Math.floor(e.s.maxHp - 1)); // never below 1 max HP
|
||
if (n > 0 && !e.bossPool) {
|
||
unit.mem.stolen = stolen + n;
|
||
// the victim keeps an explicit running total (a 'stack' refresh would rescale every stack by the last n)
|
||
const key = `etlchi:steal:${unit.id}`;
|
||
const lost = num(e.findBuff(key)?.data?.total) + n;
|
||
const hp0 = e.hp;
|
||
battle.addBuff(e, { key, mods: { hpFlat: -lost }, data: { total: lost } });
|
||
// 偷取生命上限: the victim's current HP only drops where it exceeds the new maximum (the engine keeps ratios)
|
||
if (e.alive) e.hp = Math.min(hp0, e.s.maxHp);
|
||
battle.addBuff(unit, { key: 'etlchi:gain', mods: { hpFlat: unit.mem.stolen } });
|
||
}
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 重盈
|
||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.reborn = false; }, { owner: unit });
|
||
battle.on('damaged', (c) => {
|
||
if (c.target !== unit || unit.mem.reborn || !on(unit) || unit.hp <= 0 || !(unit.hpRatio < num(t1.hp_ratio))) return;
|
||
unit.mem.reborn = true;
|
||
battle.heal(unit, unit, unit.s.maxHp * num(t1['etlchi_t_2[heal].hp_ratio']), { self: true });
|
||
if (num(t1.damage_resistance) > 0) battle.addBuff(unit, { key: 'etlchi:reborn', mods: { physTakenMul: 1 - num(t1.damage_resistance) } });
|
||
battle.fx('reborn', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
// REA-Y "攻击范围内存在2名及以上敌人时攻击速度+12": the ENGINE owns this trait line now
|
||
// (server/sim/content/traitMods.js, applied from battle/players.js _setupUnit for every operator) — a second
|
||
// implementation here would count the same 12 points twice.
|
||
const mine = (e) => e && e.mem && e.mem.candleOwner === unit;
|
||
// "心烛只受隐德来希攻击的影响": other sources' element gauges never fill a candle either
|
||
battle.on('elementHit', (c) => { if (mine(c.target) && c.source !== unit) c.dmg.cancel = true; }, { owner: unit });
|
||
battle.on('beforeStatus', (c) => { if (mine(c.target) && c.source !== unit) c.cancel = true; }, { owner: unit });
|
||
battle.on('hit', (c) => {
|
||
if (!mine(c.target)) return;
|
||
if (c.source !== unit) { c.dmg.cancel = true; return; }
|
||
if (c.dmg.type !== 'phys' && c.dmg.type !== 'arts') return;
|
||
const ss = unit.s;
|
||
const mit = mitigate(c.dmg.amount, c.dmg.type, c.target.s, {
|
||
defIgnorePct: c.dmg.defIgnorePct + ss.defIgnorePct, defIgnoreFlat: c.dmg.defIgnoreFlat + ss.defIgnoreFlat,
|
||
resIgnorePct: c.dmg.resIgnorePct + ss.resIgnorePct, resIgnoreFlat: c.dmg.resIgnoreFlat + ss.resIgnoreFlat,
|
||
});
|
||
const floor = ss.atk * CANDLE_MIN_ATK;
|
||
if (mit < floor) { c.dmg.type = 'true'; c.dmg.amount = floor; c.dmg.canDodge = false; }
|
||
}, { owner: unit, priority: -20 });
|
||
battle.on('damaged', (c) => {
|
||
if (!mine(c.target) || !(c.amount > 0) || c.type === 'element') return;
|
||
const o = c.target.mem.candleOf;
|
||
if (o && o.alive) battle.loseHp(o, c.amount, { source: unit });
|
||
}, { owner: unit });
|
||
battle.on('death', (c) => { // the original fell or leaked: its candle goes out
|
||
if (c.unit.side !== 'enemy' || c.unit.mem?.candleOwner) return;
|
||
for (const cd of unit.mem.candles || []) if (cd.alive && cd.mem.candleOf === c.unit) battle.kill(cd, null);
|
||
}, { owner: unit });
|
||
battle.on('beforeAttack', (c) => { // other allies hit the original instead of the candle
|
||
if (c.attacker === unit || c.attacker.side !== 'ally' || !(unit.mem.candles && unit.mem.candles.length)) return;
|
||
if (!c.targets.some(mine)) return;
|
||
const out = [];
|
||
for (const t of c.targets) {
|
||
if (!mine(t)) { if (!out.includes(t)) out.push(t); continue; }
|
||
const o = t.mem.candleOf;
|
||
if (o && o.alive && !out.includes(o) && !c.targets.includes(o)) out.push(o);
|
||
}
|
||
c.targets = out;
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
};
|