The owner's decision (2026-10-03, after community feedback): 深巡 S2 行动能力剥夺, 雷蛇 S2 反击电弧, 号角 S2 暴风号令 and S3 终极防线, 灰毫 S1 攻击力强化·γ型 and S2 专注轰击 take the basic strategy (DEFAULT) instead of the 下半 TANK class row's TAKE_DAMAGE. Every other MANUAL 重装 skill, 深巡 S1 included, still waits for a hit. - tools/build-data.mjs TRIGGER_DEVIATIONS: a small table keyed by the normal chess id and the skill id (the elite follows through baseId), never by the skill id alone - 灰毫 S1 is the generic skcom_atk_up[3]. resolveTrigger applies it last; rawRule keeps the official TAKE_DAMAGE; validateAll fails the build when an entry no longer meets a TAKE_DAMAGE row. data/chess.json rebuilt with --offline: exactly the 12 records' rule changes. - PR #12's kit lines (trigger 'DEFAULT' on 深巡 / 雷蛇 S2) stay and agree with the data; their comments and tests now give this reason instead of the community summary. - Tests: test/sim/feedback1-tank-triggers.test.js (the data, kit = data, a real battle on 战场#01(下半) in which each of the six casts with an enemy in range before any hit and each of the other 46 MANUAL 重装 records casts on the first hit); data.test.js trigger expectations; the 灰毫 / 号角 kit tests follow; docs-consistency. - Docs: DESIGN §21.29 (new), §5.6, §19.2, §20.2, §21.20 (settled row), §21.23; DATA, SIM, PLAYING, CHANGELOG 0.1.1; research 03 §1.4 drops the arknights.wiki.gg "offensive / defensive" summary and records the activity_table history (上半 had no TANK row, 下半 added it) and this deviation; research 00-INDEX.
864 lines
49 KiB
JavaScript
864 lines
49 KiB
JavaScript
// server/sim/content/kits/tier2.js — Tier 2 operator kits.
|
||
//
|
||
// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). Shared helpers live in tier1.js (named
|
||
// exports). Numbers come from the skill / talent / trait blackboards (elite module upgrades are in the elite record's
|
||
// trait.bb and hidden index −1 talents). fx kinds: see the tier1.js header.
|
||
//
|
||
// Covered (normal + elite): 2_01 送葬人 2_02 赫默 2_03 崖心(H) 2_04 小满 2_05 哈洛德 2_06 莎草 2_07 幽灵鲨 2_08 泡泡
|
||
// 2_09 休谟斯 2_10 洛洛 2_11 风丸 2_12 砾 2_13 蒂比 2_14 调香师 2_15 协律(H) 2_16 拉普兰德 2_17 折桠 2_18 灰毫 2_19 锡人.
|
||
//
|
||
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skill in `skills`
|
||
// ({ [skillId]: SkillSpec }, official Lv4 / Lv7 blackboards — the kit function receives the SELECTED skill's `bb`, so
|
||
// every spec is built from `bb`; the trigger rule comes from that skill's data). Talent / install hooks that belong to
|
||
// the default skill only (target locks, counters, hit triggers) check onDefaultSkill(chess). Module choices need no
|
||
// code here: `chess` is the loadout-resolved record (trait.bb / talents of the selected module, or none).
|
||
|
||
import { COLS, ELEMENT_GAUGE_MAX } from '../../constants.js';
|
||
import { canTargetEnemy } from '../../targeting.js';
|
||
import { bodyInKeys } from '../../body.js';
|
||
import {
|
||
num, talentBb, moduleBb, traitBb, up, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
|
||
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, instantKind,
|
||
tinmanKit, batMod,
|
||
} from './tier1.js';
|
||
import { releaseSkillSummon } from '../tokens.js';
|
||
|
||
/** Largest element gauge of a unit (every element — 侵蚀 included: the bosses' attacks fill it on operators). */
|
||
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis, a.elem.erosion ?? 0);
|
||
|
||
/**
|
||
* Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the
|
||
* selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default.
|
||
*/
|
||
function onDefaultSkill(chess) {
|
||
const d = (chess?.skills ?? []).find((s) => s && s.isDefault);
|
||
return !d || !chess?.skill || d.skillId === chess.skill.skillId;
|
||
}
|
||
|
||
/**
|
||
* 起飞 of 蒂比's skills (gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人"): the skill's flags
|
||
* `liftoff` (no ground enemy blocked — Battle._blockerFor; 对地规避 — targeting.js evadesGround: no ground enemy selects
|
||
* her, while what selects nobody still lands — 无视无法选择 abilities, direct picks, flying units' blasts, 无来源 damage, the
|
||
* ticks of a debuff already on her) and `blockFly` (blocks flyers at the air radius). She stays a ground unit on her tile (PRTS 行动方式 "起飞的
|
||
* 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰, 地面干员 bonds / items still count her).
|
||
*/
|
||
const LIFTOFF_FLAGS = Object.freeze({ blockFly: true, liftoff: true });
|
||
/** 蒂比 take-off: the ground enemies she blocked walk on. */
|
||
function tippiTakeOff({ battle, unit }) {
|
||
battle.releaseBlocked(unit);
|
||
battle.fx('takeoff', { x: unit.x, y: unit.y, id: unit.id });
|
||
}
|
||
/** 蒂比 landing when the airborne skill ends: the flyers she held are released. */
|
||
function tippiLand({ battle, unit }) {
|
||
battle.releaseBlocked(unit);
|
||
}
|
||
|
||
export default {
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_01 送葬人 最终旅程: normal attacks become double hits, base attack time +base_attack_time s (−0.5 on 2.3 s
|
||
// "少量缩短"). 终结改装: ignore def_penetrate_fixed DEF. (Trait: every enemy in range, ×atk_scale on its front row —
|
||
// reaperrange profile.)
|
||
//
|
||
// S1 铳口收束 (alt): ATK +atk; "攻击时对攻击范围内的所有敌人应用特性加成" — every enemy in range takes the trait's
|
||
// front-row multiplier (profile frontScale: trait atk_scale 1.5, elite module 1.6, elite without module 1.5).
|
||
chess_char_2_01_a: (bb, chess) => ({
|
||
skill: { kind: 'duration', mods: { batPct: batMod(bb.base_attack_time, chess) }, attack: { hits: 2 } },
|
||
skills: {
|
||
skchr_excu_1: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: { dmgMul: (battle, u) => num(u.profile?.frontScale, 1.5) } },
|
||
},
|
||
talents: [{ install(battle, unit) { statBuff(battle, unit, 'talent:excu', { defIgnoreFlat: num(talentBb(chess, 0).def_penetrate_fixed) }); } }],
|
||
}),
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_02 赫默 医疗无人机 (AUTO, heal trigger): "获得一个医疗无人机 / 最多可库存1个无人机;无人机投入战场后治疗周围友军,
|
||
// 10秒后自动销毁" — the 医疗探机 is a hand piece the player places (user playtest #6; PRTS 卫戍协议/帮助): each cast gives
|
||
// one (stock ≤ cnt) and the placed piece takes the field on its own tile (tokens.js releaseSkillSummon: not at the
|
||
// battle start — user playtest #4; not placed ⇒ no drone); it heals around itself and self-destructs after 10 s (token
|
||
// kit). 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on ground units
|
||
// ×heal_scale (module 'none': no bonus).
|
||
// S1 治疗强化·γ型 (alt): ATK +atk for its duration (no drone).
|
||
chess_char_2_02_a: (bb, chess) => {
|
||
const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? [])[0] ?? 'token_10000_silent_healrb';
|
||
const cnt = Math.max(1, Math.floor(num(bb.cnt, 1)));
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
return {
|
||
skill: {
|
||
kind: 'instant', heal: true,
|
||
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokId, { cap: cnt }); },
|
||
},
|
||
skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
|
||
talents: [{ install(battle, unit) {
|
||
const v = num(t.attack_speed);
|
||
if (v) installAura(battle, unit, { key: 'talent:silent', value: v, select: (a) => a.kind === 'op' && a.def.profession === 'MEDIC', mods: { aspd: v } });
|
||
if (num(tb.heal_scale, 1) !== 1) {
|
||
battle.on('heal', (ctx) => { if (ctx.source === unit && ctx.target.ground) ctx.amount *= num(tb.heal_scale, 1); }, { owner: unit });
|
||
}
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中力; Battle.pullToFront:
|
||
// the official 力度 − 重量 pull — weight ≤ 1 all the way in front of her, 2 a third of the way, 3 barely, ≥ 4 not at all),
|
||
// take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking.
|
||
// Elite module (HOK-X, trait value/dist): dragged enemies take `value` arts per `dist` tiles travelled.
|
||
chess_char_2_03_a: (bb, chess, def) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
const force = num(bb.force, 1);
|
||
return {
|
||
skill: {
|
||
kind: 'instant',
|
||
onStart({ battle, unit }) {
|
||
const foes = enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n: Math.max(1, Math.floor(num(bb.max_target, 1))) });
|
||
for (const e of foes) {
|
||
const sx = e.x, sy = e.y;
|
||
const moved = battle.pullToFront(e, unit, force);
|
||
battle.fx('pull', { x: e.x, y: e.y, id: e.id, fromX: sx, fromY: sy });
|
||
if (moved > 0 && num(tb.value) > 0) {
|
||
battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(0.01, num(tb.dist, 1)), type: 'arts', isSkill: true, canDodge: false, tags: ['drag'] });
|
||
}
|
||
if (!e.alive) continue;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'true', isSkill: true, tags: ['skill'] });
|
||
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit });
|
||
}
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
if (num(t.atk) || num(t.def)) toggleBuff(battle, unit, 'slchan:hunter', () => unit.blocking.length === 0, { atkPct: num(t.atk), defPct: num(t.def) });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_04 小满 乡音沉沉: stops attacking and puts max_target enemies in range to sleep `sleep` s; afterwards ASPD
|
||
// +attack_speed and attacks max_target enemies. 好好听话: ASPD +attack_speed; vs 【野生动物】 (tag `infection`,
|
||
// "野生的被感染生物") the trait 停顿 lasts +sluggish_addition s. Elite module (DEC-X): +sp_recovery_per_sec SP/s with
|
||
// an enemy in range.
|
||
// S1 竹笛飞声 (alt, 2 charges): the next attack deals atk_scale × ATK arts and hits one more enemy (+1 target).
|
||
chess_char_2_04_a: (bb, chess, def) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
const n = Math.max(1, Math.floor(num(bb.max_target, 1)));
|
||
const sleep = num(bb.sleep);
|
||
return {
|
||
trait: {
|
||
onHitStatus: null,
|
||
canAttack: (battle, u) => !(u.skill?.active && battle.time < (u.mem.grainQuietUntil ?? -1)),
|
||
afterHit(battle, u, target) {
|
||
if (!target || !target.alive || target.side !== 'enemy') return;
|
||
const beast = (target.def?.tags || []).includes('infection');
|
||
battle.applyStatus(target, 'sluggish', { duration: num(tb.sluggish, 0.8) + (beast ? num(t.sluggish_addition) : 0), source: u });
|
||
},
|
||
},
|
||
skill: {
|
||
kind: 'duration', mods: { aspd: num(bb.attack_speed) },
|
||
targeting: { maxTargets: n, rangeGrid: def?.skill?.rangeGrid ?? null },
|
||
onStart({ battle, unit }) {
|
||
unit.mem.grainQuietUntil = battle.time + sleep;
|
||
for (const e of enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n })) {
|
||
if (battle.applyStatus(e, 'sleep', { duration: sleep, source: unit })) battle.fx('sleep', { x: e.x, y: e.y, id: e.id });
|
||
}
|
||
},
|
||
},
|
||
skills: {
|
||
skchr_grabds_1: { kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 1), dmgType: 'arts' }, mods: { maxTargets: 1 } },
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
statBuff(battle, unit, 'talent:grabds', { aspd: num(t.attack_speed) });
|
||
spTimeBonus(battle, unit, num(moduleBb(chess).sp_recovery_per_sec), () => enemyInRange(battle, unit));
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_05 哈洛德 重症优先: ASPD +attack_speed, heals the ally with the heaviest element damage first; on targets whose
|
||
// gauge is over half, the element recovery is trait_scale × (the wandermedic trait: ep_heal_ratio × ATK).
|
||
// 我即军营: allies in range whose gauge is over half take −ep_damage_resistance 元素损伤 — checked on the element hit
|
||
// itself (`elementHit`, a 元素损伤 multiplier: PRTS 元素 "…后续可应用元素损伤倍率提升/降低等效果"; not `elemTakenMul`,
|
||
// which is 元素伤害 / 元素脆弱); several 哈洛德 do not stack — the strongest cut applies.
|
||
// S1 治疗强化·γ型 (alt): ATK +atk; the 重症优先 target order belongs to S2 only.
|
||
chess_char_2_05_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const s2 = onDefaultSkill(chess);
|
||
const half = ELEMENT_GAUGE_MAX / 2;
|
||
const ts = num(bb.trait_scale, 1);
|
||
return {
|
||
skill: {
|
||
kind: 'duration', heal: true, mods: { aspd: num(bb.attack_speed) },
|
||
onHit({ battle, unit, target }) {
|
||
if (!target || !unit.mem.haroldHalf?.has(target.id) || !(ts > 1)) return;
|
||
battle.reduceElement(target, unit.s.atk * (unit.profile.heal?.elementHealRatio ?? 0) * (ts - 1));
|
||
},
|
||
},
|
||
skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
|
||
talents: [{ install(battle, unit) {
|
||
if (s2) battle.on('beforeAttack', (ctx) => {
|
||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||
const cands = battle.injuredAlliesInKeys(unit.rangeKeys, unit, true);
|
||
cands.sort((a, b) => elemLoad(b) - elemLoad(a) || a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq);
|
||
if (cands.length) ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
|
||
unit.mem.haroldHalf = new Set(ctx.targets.filter((a) => elemLoad(a) > half).map((a) => a.id));
|
||
}, { owner: unit });
|
||
const r = Math.max(0, Math.min(1, num(t.ep_damage_resistance)));
|
||
if (r > 0) {
|
||
battle.on('elementHit', (ctx) => {
|
||
const a = ctx.target, d = ctx.dmg;
|
||
if (!up(unit) || !a || a.side !== 'ally' || !d || d.type !== 'element' || !(elemLoad(a) > half)) return;
|
||
if (!(unit.rangeKeySet || new Set(unit.rangeKeys || [])).has(a.tileR * COLS + a.tileC)) return;
|
||
const prev = d.haroldCut || 0; // the strongest 我即军营 of the hit only
|
||
if (r <= prev) return;
|
||
d.mul *= (1 - r) / (1 - prev);
|
||
d.haroldCut = r;
|
||
}, { owner: unit, priority: 20 });
|
||
}
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_06 莎草 巧思乍现 (AUTO, 2 charges): the next heal is heal_scale × ATK (chain jumps included) and the talent
|
||
// barrier is shield_scale_skill × stronger. 博览古卷: every heal gives the target a barrier of attack@scale × ATK for
|
||
// attack@shield_duration s. (Chain falloff incl. the elite module comes from the chainhealer profile.)
|
||
// S2 临考发挥 (alt): on start lock the ally in range (not herself, no 禁疗; any profession, summons included) with the
|
||
// highest max HP; while it runs that ally is the only main heal target, base attack time +base_attack_time s (−1.1
|
||
// on 2.85), ATK +atk, chain jumps +attack@chain.extra_value. PRTS: "无法可选单位时无法开启技能" — with no lockable
|
||
// ally the skill never activates (the talent install guards the unit's skill.activate: no skillStart, no
|
||
// 萨尔贡 / 特质 "开启技能时" gains, SP kept); the skill stops at once when the locked ally leaves the field.
|
||
chess_char_2_06_a: (bb, chess, def) => {
|
||
const t = talentBb(chess, 0);
|
||
const sc = num(t['attack@scale']), sd = num(t['attack@shield_duration'], 8), boost = num(bb.shield_scale_skill, 1);
|
||
const extra = Math.max(0, Math.floor(num(bb['attack@chain.extra_value'])));
|
||
const lockOf = (battle, unit) => alliesInGridOf(battle, unit)
|
||
.filter((a) => a !== unit && !a.s.flags.noHeal && !a.profile?.noHeal)
|
||
.sort((a, b) => b.s.maxHp - a.s.maxHp || a.deploySeq - b.deploySeq)[0] ?? null;
|
||
const release = (battle, unit) => {
|
||
if (unit.mem.papyrsHeal) { unit.profile.heal = unit.mem.papyrsHeal; unit.mem.papyrsHeal = null; }
|
||
if (unit.mem.papyrsHook) battle.off(unit.mem.papyrsHook);
|
||
unit.mem.papyrsHook = null;
|
||
unit.mem.papyrsLock = null;
|
||
unit.mem.papyrsAbort = false;
|
||
};
|
||
return {
|
||
skill: { kind: instantKind(def), heal: true, attack: { healScale: num(bb.heal_scale, 1) } },
|
||
skills: {
|
||
skchr_papyrs_2: {
|
||
kind: 'duration', heal: true, mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) },
|
||
onStart({ battle, unit, skill }) {
|
||
release(battle, unit);
|
||
const lock = lockOf(battle, unit);
|
||
// (only reachable when a runtime bypasses the activate guard below, e.g. a copied skill: undo on the next tick)
|
||
if (!lock) { unit.mem.papyrsAbort = true; return; }
|
||
unit.mem.papyrsLock = lock;
|
||
const h = unit.profile.heal;
|
||
if (h && extra > 0) { unit.mem.papyrsHeal = h; unit.profile.heal = { ...h, count: Math.max(1, h.count || 3) + extra }; }
|
||
unit.mem.papyrsHook = battle.on('beforeAttack', (ctx) => {
|
||
if (ctx.attacker !== unit || !skill.active) return;
|
||
const L = unit.mem.papyrsLock;
|
||
if (L && up(L) && bodyInKeys(L, unit.rangeKeySet)) ctx.targets = [L];
|
||
}, { owner: unit, priority: 10 });
|
||
battle.fx('buff', { x: lock.x, y: lock.y, id: lock.id, kind: 'lock' });
|
||
},
|
||
onTick({ unit, skill }) {
|
||
if (unit.mem.papyrsAbort) {
|
||
// "无法可选单位时无法开启技能": undo the cast, keep the SP
|
||
unit.mem.papyrsAbort = false;
|
||
skill.end('noTarget');
|
||
skill.addCharge(1);
|
||
return;
|
||
}
|
||
const L = unit.mem.papyrsLock;
|
||
if (!L || !up(L)) skill.end('target');
|
||
},
|
||
onEnd({ battle, unit }) { release(battle, unit); },
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
const sk = unit.skill;
|
||
if (sk && sk.id === 'skchr_papyrs_2' && !Object.prototype.hasOwnProperty.call(sk, 'activate')) {
|
||
// "无法可选单位时无法开启技能": every activation path (DEFAULT heal trigger, carried skill, content) is refused
|
||
// while no ally can be locked — the charge stays, nothing starts (non-enumerable: never serialised)
|
||
const activate = sk.activate;
|
||
Object.defineProperty(sk, 'activate', {
|
||
configurable: true, writable: true, enumerable: false,
|
||
value(reason, opts) { return lockOf(battle, unit) ? activate.call(this, reason, opts) : false; },
|
||
});
|
||
}
|
||
if (!(sc > 0)) return;
|
||
battle.on('heal', (ctx) => {
|
||
if (ctx.source !== unit || ctx.target === unit && ctx.opts?.regen || !(ctx.amount > 0)) return;
|
||
const bySkill = !!(unit.skill?.active && unit.skill.pending);
|
||
battle.addBuff(ctx.target, { key: 'papyrs:shield', shield: unit.s.atk * sc * (bySkill ? boost : 1), duration: sd, source: unit, visible: true, tags: ['talent'] });
|
||
}, { owner: unit, priority: -10 });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_07 幽灵鲨 肉斩骨断: ATK +atk and HP never drops below 1 while active; stunned `stun` s afterwards.
|
||
// Talent: max HP +max_hp (elite: + hp_recovery_per_sec_by_max_hp_ratio × max HP per s).
|
||
// Elite module (CEN-X, trait atk_scale): vs enemies it blocks ATK ×atk_scale.
|
||
// S1 攻击力强化·γ型 (alt): ATK +atk for its duration.
|
||
chess_char_2_07_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
return {
|
||
skill: {
|
||
kind: 'duration', mods: { atkPct: num(bb.atk) },
|
||
onStart({ battle, unit }) {
|
||
if (unit.mem.undying) battle.off(unit.mem.undying);
|
||
unit.mem.undying = battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 });
|
||
},
|
||
onEnd({ battle, unit, reason }) {
|
||
if (unit.mem.undying) battle.off(unit.mem.undying);
|
||
unit.mem.undying = null;
|
||
if (reason !== 'death' && unit.alive && num(bb.stun) > 0) battle.applyStatus(unit, 'stun', { duration: num(bb.stun), source: unit });
|
||
},
|
||
},
|
||
skills: { 'skcom_atk_up[3]': { kind: 'duration', mods: { atkPct: num(bb.atk) } } },
|
||
talents: [{ install(battle, unit) {
|
||
statBuff(battle, unit, 'talent:ghost', { hpPct: num(t.max_hp), hpRegenRatio: num(t.hp_recovery_per_sec_by_max_hp_ratio) });
|
||
if (num(tb.atk_scale, 1) !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.blockedBy) dmg.amount *= num(tb.atk_scale, 1); });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_08 泡泡 “挨打”: stops attacking; DEF +def, taunt +taunt_level, each attack received returns atk_scale × DEF phys
|
||
// damage to the attacker. 尖刺盾: attackers get ATK −atk for `duration` s. Elite module (PRO-X): DEF +def while blocking.
|
||
// S1 防御力强化·β型 (alt, TAKE_DAMAGE from data): DEF +def for its duration; the counter belongs to “挨打” only.
|
||
chess_char_2_08_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
const s2 = onDefaultSkill(chess);
|
||
return {
|
||
skill: { kind: 'duration', mods: { defPct: num(bb.def), taunt: num(bb.taunt_level) }, attack: { noAttack: true } },
|
||
skills: { 'skcom_def_up[2]': { kind: 'duration', mods: { defPct: num(bb.def) } } },
|
||
talents: [{ install(battle, unit) {
|
||
onDamagedOn(battle, unit, (ctx) => {
|
||
if (!byEnemyAttack(ctx) || !unit.alive) return;
|
||
const src = ctx.source;
|
||
if (s2 && unit.skill?.active && src.alive) {
|
||
battle.dealDamage(unit, src, { amount: unit.s.def * num(bb.atk_scale), type: 'phys', isSkill: true, canDodge: false, tags: ['counter'] });
|
||
battle.fx('counter', { x: src.x, y: src.y, id: unit.id });
|
||
}
|
||
if (num(t.atk) < 0 && src.alive) battle.addBuff(src, { key: 'bubble:spike', duration: num(t.duration, 5), mods: { atkPct: num(t.atk) }, refresh: 'extend', source: unit });
|
||
});
|
||
if (num(tb.def)) toggleBuff(battle, unit, 'bubble:guard', () => unit.blocking.length > 0, { defPct: num(tb.def) });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_09 休谟斯 高效处理: block +block_cnt; above peak_1.hp_ratio HP ATK +peak_1.atk (精力充沛), above peak_2.hp_ratio
|
||
// +peak_2.atk. 再回收: healing beyond max HP becomes a barrier, at most max_hp_ratio × max HP.
|
||
// S1 固废切割 (alt, attack SP): the next attack hits at atk_scale × ATK (every enemy it reaches) and heals her `value`
|
||
// HP once (a self heal: her trait bars only healing by others; overflow feeds 再回收).
|
||
chess_char_2_09_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const peaks = [];
|
||
for (const [k, v] of Object.entries(bb)) {
|
||
const m = k.match(/\[(peak_\d+)\]\.peak_performance\.(atk|hp_ratio)$/);
|
||
if (!m) continue;
|
||
let p = peaks.find((x) => x.id === m[1]);
|
||
if (!p) peaks.push((p = { id: m[1], atk: 0, hp: 1 }));
|
||
if (m[2] === 'atk') p.atk = num(v); else p.hp = num(v, 1);
|
||
}
|
||
peaks.sort((a, b) => b.hp - a.hp);
|
||
const key = 'humus:peak';
|
||
const peak = ({ battle, unit }) => {
|
||
const lvl = peaks.find((p) => unit.hpRatio > p.hp)?.atk ?? 0;
|
||
const cur = unit.findBuff(key);
|
||
if ((cur?.data?.v ?? 0) === lvl) return;
|
||
if (lvl > 0) battle.addBuff(unit, { key, mods: { atkPct: lvl }, data: { v: lvl }, visible: true, tags: ['skill'] });
|
||
else battle.removeBuff(unit, key);
|
||
};
|
||
return {
|
||
skill: {
|
||
kind: 'duration', mods: { blockCnt: num(bb.block_cnt) },
|
||
onStart: peak, onTick: peak,
|
||
onEnd({ battle, unit }) { battle.removeBuff(unit, key); },
|
||
},
|
||
skills: {
|
||
skchr_humus_1: {
|
||
kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) },
|
||
onAttack({ battle, unit }) {
|
||
if (num(bb.value) > 0 && unit.alive) battle.heal(unit, unit, num(bb.value), { self: true, tags: ['skill'] });
|
||
},
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
const ratio = num(t.max_hp_ratio);
|
||
if (!(ratio > 0)) return;
|
||
battle.on('heal', (ctx) => {
|
||
if (ctx.target !== unit || !(ctx.amount > 0)) return;
|
||
const over = ctx.amount - (unit.s.maxHp - unit.hp);
|
||
if (!(over > 0)) return;
|
||
const cur = unit.findBuff('humus:recycle');
|
||
const have = cur ? cur.shield : 0;
|
||
const val = Math.min(unit.s.maxHp * ratio, have + over);
|
||
if (val > have + 1e-6) battle.addBuff(unit, { key: 'humus:recycle', shield: val, source: unit, visible: true, tags: ['talent'] });
|
||
}, { owner: unit, priority: -50 });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_10 洛洛 自负此轭: ASPD +attack_speed; the drones lock on their target until it dies or the skill ends.
|
||
// 过载 (term ba.overdrive "技能进行到一半时触发额外效果"): the second half of the duration — ATK +atk and the funnel cap
|
||
// ×scale; when the skill ends she is stunned as long as the overload lasted. 立于磐石: ATK +atk per `interval` s on the
|
||
// field, up to max_stack_cnt stacks.
|
||
// S1 战术咏唱·γ型 (alt): ASPD +attack_speed; the target lock belongs to 自负此轭 only.
|
||
chess_char_2_10_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const s2 = onDefaultSkill(chess);
|
||
const endOverload = (battle, unit) => {
|
||
if (unit.mem.rockFunnel) { unit.profile.funnel = unit.mem.rockFunnel; unit.mem.rockFunnel = null; }
|
||
battle.removeBuff(unit, 'rockr:overload');
|
||
};
|
||
return {
|
||
skill: {
|
||
kind: 'duration', mods: { aspd: num(bb.attack_speed) },
|
||
onStart({ battle, unit, skill }) {
|
||
unit.mem.rockOverAt = battle.time + skill.duration / 2;
|
||
unit.mem.rockOver = false;
|
||
unit.mem.rockLock = null;
|
||
},
|
||
onTick({ battle, unit }) {
|
||
if (unit.mem.rockOver || battle.time + 1e-9 < unit.mem.rockOverAt) return;
|
||
unit.mem.rockOver = true;
|
||
const f = unit.profile.funnel;
|
||
if (f) { unit.mem.rockFunnel = f; unit.profile.funnel = { ...f, max: f.max * num(bb.scale, 1) }; }
|
||
battle.addBuff(unit, { key: 'rockr:overload', mods: { atkPct: num(bb.atk) }, visible: true, tags: ['skill'] });
|
||
battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id });
|
||
},
|
||
onEnd({ battle, unit, reason }) {
|
||
const over = unit.mem.rockOver ? battle.time - unit.mem.rockOverAt : 0;
|
||
endOverload(battle, unit);
|
||
unit.mem.rockOver = false;
|
||
unit.mem.rockLock = null;
|
||
if (over > 0 && reason !== 'death' && unit.alive) battle.applyStatus(unit, 'stun', { duration: over, source: unit });
|
||
},
|
||
},
|
||
skills: { 'skcom_magic_rage[3]': { kind: 'duration', mods: { aspd: num(bb.attack_speed) } } },
|
||
talents: [{ install(battle, unit) {
|
||
if (s2) battle.on('beforeAttack', (ctx) => {
|
||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||
const L = unit.mem.rockLock;
|
||
if (L && L.alive && bodyInKeys(L, unit.rangeKeySet) && canTargetEnemy(unit, L, ctx.profile || unit.profile)) ctx.targets = [L];
|
||
else unit.mem.rockLock = ctx.targets[0] ?? null;
|
||
}, { owner: unit });
|
||
const iv = num(t.interval), max = Math.floor(num(t.max_stack_cnt)), v = num(t.atk);
|
||
if (!(iv > 0) || !(max > 0) || !v) return;
|
||
battle.on('tick', () => {
|
||
if (!up(unit)) return;
|
||
const n = Math.min(max, Math.floor((battle.time - unit.deployedAt + 1e-9) / iv));
|
||
const cur = unit.findBuff('rockr:rock');
|
||
if ((cur ? cur.stacks : 0) === n) return;
|
||
if (n > 0) battle.addBuff(unit, { key: 'rockr:rock', mods: { atkPct: v }, stacks: n, maxStacks: max, tags: ['talent'] });
|
||
else battle.removeBuff(unit, 'rockr:rock');
|
||
}, { owner: unit });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_11 风丸 纸艺·双影: lose hp_ratio of current HP, ATK +atk, summon the <替身> (overrideTokenKey) on a free melee tile
|
||
// around her for the skill's duration [ASSUMED lifetime]. 折纸生花: whenever a <替身> appears (the summon, and the
|
||
// dollkeeper substitution) enemies on the 8 surrounding tiles take damage_scale × its ATK arts — air units too [ASSUMED:
|
||
// no 对空 note on PRTS; the <替身> itself "可对空"].
|
||
// Trait substitution: she fights with the <替身>'s stats (the engine already swaps in its HP; the kit swaps ATK/DEF
|
||
// with flat deltas so %-buffs still apply). Elite module (PUM-X, trait atk): while substituted, ATK +atk.
|
||
// S1 纸艺·迅击 (alt, attack SP): the next attack hits at atk_scale × ATK and she loses hp_ratio of her MAX HP (PRTS:
|
||
// ten casts knock her out — the loss can be fatal and so sets off the <替身> substitution).
|
||
chess_char_2_11_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? []).find((x) => /shadow|doll/.test(String(x))) ?? 'token_10022_kazema_shadow';
|
||
const ds = num(t.damage_scale);
|
||
const origami = (battle, src, atk) => {
|
||
battle.fx('aoe', { x: src.x, y: src.y, radius: 1.5, id: src.id, skill: 'origami' });
|
||
if (!(ds > 0)) return;
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.hidden || e.s.flags.untargetable || cheb(src, e) > 1) continue;
|
||
battle.dealDamage(src, e, { amount: atk * ds, type: 'arts', isSkill: true, canDodge: false, tags: ['talent'] });
|
||
}
|
||
};
|
||
return {
|
||
skill: {
|
||
kind: 'duration', mods: { atkPct: num(bb.atk) },
|
||
onStart({ battle, unit }) {
|
||
const loss = unit.hp * num(bb.hp_ratio);
|
||
if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit });
|
||
const tile = freeTileAround(battle, unit, (r, c) => battle.grid.canStand(r, c));
|
||
if (!tile) return;
|
||
const doll = battle.spawnToken(unit, tokId, tile[0], tile[1], {});
|
||
if (!doll) return;
|
||
unit.mem.doll = doll;
|
||
battle.fx('summon', { x: doll.x, y: doll.y, id: doll.id, token: tokId });
|
||
origami(battle, doll, doll.s.atk);
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
const d = unit.mem.doll;
|
||
if (d && d.alive) battle.retreat(d, { reason: 'expired', permanent: true });
|
||
unit.mem.doll = null;
|
||
},
|
||
},
|
||
skills: {
|
||
skchr_kazema_1: {
|
||
kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) },
|
||
onAttack({ battle, unit }) {
|
||
const loss = unit.s.maxHp * num(bb.hp_ratio);
|
||
if (loss > 0 && unit.alive) battle.loseHp(unit, loss, { source: unit, tags: ['skill'] });
|
||
},
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
const bonus = num(tb.atk);
|
||
battle.on('tick', () => {
|
||
const sub = up(unit) && !!unit.findBuff('trait:substitute');
|
||
if (sub && !unit.mem.kzSub) {
|
||
unit.mem.kzSub = true;
|
||
const ts = battle.tokenDef(tokId, unit)?.stats ?? null; // the owner's module variant (DESIGN §16)
|
||
const mods = {};
|
||
if (num(ts?.atk) > 0) mods.atkFlat = num(ts.atk) - unit.base.atk;
|
||
if (ts && Number.isFinite(ts.def)) mods.defFlat = ts.def - unit.base.def;
|
||
if (bonus > 0) mods.atkPct = bonus;
|
||
battle.addBuff(unit, { key: 'kazema:doll', mods, tags: ['trait'] });
|
||
origami(battle, unit, unit.s.atk);
|
||
} else if (!sub && unit.mem.kzSub) {
|
||
unit.mem.kzSub = false;
|
||
battle.removeBuff(unit, 'kazema:doll');
|
||
}
|
||
}, { owner: unit });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_12 砾 鼠群 (passive): at each deployment a barrier of hp_ratio × max HP that decays to 0 over `duration` s
|
||
// (capacity stepped once per second; a damaged barrier shrinks in proportion — PRTS).
|
||
// 快速部署: own deployment cost +cost (−1). Elite 小个子支援: every unit whose initial deployment cost ≤ cond.cost
|
||
// DEF +def while 砾 is on the field. (Elite module withdraw refund: no manual retreat in battle ⇒ no effect.)
|
||
// S1 影袭 (alt, passive): at each deployment DEF +def, decaying linearly to 0 over `duration` s, updated once per
|
||
// second (PRTS: "防御力加成每秒更新一次").
|
||
chess_char_2_12_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
return {
|
||
skill: {
|
||
kind: 'passive',
|
||
onStart({ battle, unit }) {
|
||
const total = unit.s.maxHp * num(bb.hp_ratio);
|
||
const dur = num(bb.duration, 10);
|
||
if (!(total > 0) || !(dur > 0)) return;
|
||
let k = 0;
|
||
battle.addBuff(unit, {
|
||
key: 'gravel:rats', shield: total, duration: dur, interval: 1, visible: true, tags: ['skill'],
|
||
// PRTS: "屏障最大容量每秒更新一次;受到伤害后,剩余容量根据屏障最大容量等比变化" — the capacity steps down
|
||
// once per second and what is left of the barrier scales with it (never a plain cap)
|
||
onTick: ({ unit: u, buff }) => {
|
||
const prev = Math.max(0, dur - k) / dur;
|
||
k++;
|
||
const next = Math.max(0, dur - k) / dur;
|
||
if (prev > 0 && buff.shield > 0) { buff.shield *= next / prev; u.markDirty(); }
|
||
},
|
||
});
|
||
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
|
||
},
|
||
},
|
||
skills: {
|
||
skchr_gravel_1: {
|
||
kind: 'passive',
|
||
onStart({ battle, unit }) {
|
||
const v = num(bb.def), dur = num(bb.duration);
|
||
if (!(v > 0) || !(dur > 0)) return;
|
||
let k = 0;
|
||
battle.addBuff(unit, {
|
||
key: 'gravel:shadow', duration: dur, mods: { defPct: v }, interval: 1, visible: true, tags: ['skill'],
|
||
onTick: ({ unit: u, buff }) => {
|
||
k++;
|
||
buff.mods = { defPct: v * Math.max(0, dur - k) / dur };
|
||
u.markDirty();
|
||
},
|
||
});
|
||
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'def' });
|
||
},
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
const c = num(t.cost);
|
||
if (c) unit.base.cost = Math.max(0, unit.base.cost + c);
|
||
const d = num(t.def), lim = t['cond.cost'];
|
||
if (d && lim != null) installAura(battle, unit, { key: 'talent:gravel', value: d, select: (a) => num(a.def?.stats?.cost, Infinity) <= num(lim), mods: { defPct: d } });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_13 蒂比 紧急赶场通知 (AUTO): "受到攻击后触发" is officially "受到伤害前触发" (PRTS 修正 原因 6; 备注 "在受到伤害前自动
|
||
// 触发技能…若本次伤害为物理或法术,再将本次伤害闪避") — any incoming damage instance (an attack, a zone tick, the 源石溶剂
|
||
// drain; never a 流失, which has no `hit`) sets it off (the kit is the only trigger: the engine rule is disabled) and a
|
||
// physical/arts one is dodged; takes off for the duration: skill range, ATK +atk, attacks become 3 shots, blocks flying
|
||
// (not ground) enemies. Trait "起飞后能够阻挡2个飞行敌人": flying enemies are blocked only while airborne. 片场工作指南
|
||
// ("若最近N秒内未受伤害" — 修正 原文 攻击): if no damage for stack_time s, the next physical/arts damage is dodged (prob);
|
||
// every damage instance restarts that timer (dodged or not; a 流失 does not — PRTS 备注).
|
||
// S1 专业喷绘技巧 (alt, DEFAULT trigger from data): takes off at once for its duration — skill range, ATK +atk, blocks
|
||
// flyers only; no triple shot, and incoming attacks never set it off.
|
||
// Both take off as 起飞 (LIFTOFF_FLAGS): she blocks no ground enemy and none selects her (对地规避), so a ground enemy's
|
||
// selected damage refused on the way never reaches her `hit` and neither sets off S2 nor restarts 片场工作指南.
|
||
chess_char_2_13_a: (bb, chess, def) => {
|
||
const t = talentBb(chess, 0);
|
||
const s2 = onDefaultSkill(chess);
|
||
return {
|
||
trait: { blockFly: false },
|
||
skill: {
|
||
kind: 'duration', trigger: { rule: 'CUSTOM_RANGE', grid: [] }, // never by the engine: the hit handler below
|
||
mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS,
|
||
targeting: { rangeGrid: def?.skill?.rangeGrid ?? null },
|
||
attack: { hits: 3 },
|
||
onStart: tippiTakeOff,
|
||
onEnd: tippiLand,
|
||
},
|
||
skills: {
|
||
skchr_tippi_1: {
|
||
kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS,
|
||
targeting: { rangeGrid: def?.skill?.rangeGrid ?? null },
|
||
onStart: tippiTakeOff,
|
||
onEnd: tippiLand,
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
battle.removeBuff(unit, 'trait:skywalker'); // the profession grants blockFly permanently; here only airborne
|
||
const st = num(t.stack_time), p = num(t.prob, 1);
|
||
let last = -Infinity;
|
||
battle.on('deploy', ({ unit: u }) => { if (u === unit) last = battle.time - st; }, { owner: unit });
|
||
onHitOn(battle, unit, (ctx) => {
|
||
const { dmg } = ctx;
|
||
if (!dmg || dmg.cancel || !up(unit)) return;
|
||
const dodgeable = dmg.type === 'phys' || dmg.type === 'arts';
|
||
const sk = unit.skill;
|
||
let dodged = false;
|
||
if (s2 && sk && sk.ready && !sk.active && unit.canAct && !unit.s.flags.silence && sk.activate('TAKE_DAMAGE')) dodged = dodgeable;
|
||
else if (dodgeable && st > 0 && battle.time - last + 1e-9 >= st && battle.rng.chance(p)) dodged = true;
|
||
last = battle.time;
|
||
if (dodged) { dmg.cancel = true; battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); }
|
||
}, 5);
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_14 调香师 精调: ASPD +attack_speed (−50), ATK +atk. 熏衣草: every ally on the field recovers
|
||
// atk_to_hp_recovery_ratio × ATK HP per second. Elite module (RIN-Y, hidden attack@max_target): heals 4 allies.
|
||
// S1 治疗强化·β型 (alt): ATK +atk for its duration.
|
||
chess_char_2_14_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const cnt = Math.floor(num(moduleBb(chess)['attack@max_target']));
|
||
return {
|
||
trait: cnt > 0 ? { heal: { mode: 'multi', count: cnt } } : undefined,
|
||
skill: { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } },
|
||
skills: { 'skcom_heal_up[2]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
|
||
talents: [{ install(battle, unit) {
|
||
const r = num(t.atk_to_hp_recovery_ratio);
|
||
if (!(r > 0)) return;
|
||
battle.every(1, () => {
|
||
if (!up(unit)) return;
|
||
const amt = unit.s.atk * r;
|
||
for (const a of battle.alliesFor(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] });
|
||
}, { owner: unit });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_15 协律 (hidden) 震爆调谐: ATK +atk; each attack sets off a sonic boom at every other operator in range:
|
||
// attack@aoe_atk_scale × ATK arts within attack@range_radius. 律脉同构: ATK +atk with another operator in range.
|
||
// Elite module (BLA-X): damage grows with distance, up to +damage_scale at max_dist tiles.
|
||
chess_char_2_15_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
const rad = num(bb['attack@range_radius'], 0.9), sc = num(bb['attack@aoe_atk_scale']);
|
||
return {
|
||
skill: {
|
||
kind: 'duration', mods: { atkPct: num(bb.atk) },
|
||
onAttack({ battle, unit }) {
|
||
if (!(sc > 0)) return;
|
||
for (const a of alliesInGridOf(battle, unit)) {
|
||
if (a === unit || a.kind !== 'op') continue;
|
||
battle.fx('sonic', { x: a.x, y: a.y, radius: rad, id: unit.id });
|
||
for (const e of battle.foesInRadius(a.x, a.y, rad)) {
|
||
if (!e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, isSplash: true, tags: ['sonic'] });
|
||
}
|
||
}
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
const v = num(t.atk);
|
||
if (v) toggleBuff(battle, unit, 'akkord:sync', () => alliesInGridOf(battle, unit).some((a) => a !== unit && a.kind === 'op'), { atkPct: v });
|
||
if (tb.damage_scale != null) {
|
||
const mn = num(tb.min_dist), mx = num(tb.max_dist, 4), ds = num(tb.damage_scale);
|
||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||
if (!dmg.isAttack) return;
|
||
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
|
||
dmg.amount *= 1 + ds * (mx > mn ? Math.max(0, Math.min(1, (d - mn) / (mx - mn))) : 1);
|
||
});
|
||
}
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_16 拉普兰德 狼魂 (AUTO, attack SP): ATK +atk, arts damage, one extra target, ranged attacks no longer reduced.
|
||
// 精神摧毁: attacks disable the target's abilities (silence) for `duration` s. Elite module (LOR-X, trait
|
||
// atk_scale_m): attacks add atk_scale_m × ATK arts damage.
|
||
// S1 日晷 (alt, attack SP, 持续时间无限 ⇒ toggle until she falls): ATK +atk and a `prob` chance to block (抵挡: the
|
||
// damage instance is negated) each physical damage an enemy deals her.
|
||
chess_char_2_16_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
const trait = {};
|
||
if (num(t.duration) > 0) trait.onHitStatus = { key: 'silence', duration: num(t.duration) };
|
||
if (num(tb.atk_scale_m) > 0) {
|
||
trait.afterHit = (battle, u, target) => {
|
||
if (target && target.alive && target.side === 'enemy') battle.dealDamage(u, target, { amount: u.s.atk * num(tb.atk_scale_m), type: 'arts', tags: ['module'] });
|
||
};
|
||
}
|
||
return {
|
||
trait,
|
||
skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, targeting: { maxTargets: 2 }, attack: { dmgType: 'arts', dmgMul: () => 1 } },
|
||
skills: {
|
||
skchr_whitew_1: {
|
||
kind: 'toggle', mods: { atkPct: num(bb.atk) },
|
||
onStart({ battle, unit, skill }) {
|
||
if (unit.mem.sundial) battle.off(unit.mem.sundial);
|
||
const p = num(bb.prob);
|
||
unit.mem.sundial = p > 0 ? onHitOn(battle, unit, ({ source, credit, dmg }) => { // 抵挡 is target-side: a 无来源 burst counts via its credit
|
||
const src = source || credit;
|
||
if (!skill.active || dmg.cancel || dmg.type !== 'phys' || !src || src.side !== 'enemy') return;
|
||
if (battle.rng.chance(p)) { dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); }
|
||
}, 5) : null;
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
if (unit.mem.sundial) battle.off(unit.mem.sundial);
|
||
unit.mem.sundial = null;
|
||
},
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_17 折桠 生存决心: on start, ground enemies around her (skill grid) 战栗 (tremble: "被阻挡后无法进行普通攻击") for
|
||
// not_combat s; ATK +atk, DEF +def, hits every blocked enemy. 简易包扎: heals hp_ratio × max HP when the skill ends.
|
||
// Elite module (UNY-X, trait damage_scale): damage from enemies she blocks ×damage_scale.
|
||
// S1 绝境抵抗 (alt, TAKE_DAMAGE / hurt SP from data): DEF +def and 抵抗 (−one_minus_status_resistance: control
|
||
// statuses last half as long) for its duration; 简易包扎 heals at its end too.
|
||
chess_char_2_17_a: (bb, chess, def) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
return {
|
||
skill: {
|
||
kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, attack: { hitAllBlocked: true },
|
||
onStart({ battle, unit }) {
|
||
const grid = def?.skill?.rangeGrid;
|
||
const foes = grid ? enemiesInGrid(battle, unit, grid, { canHitFly: false, groundOnly: true }) : battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !e.isFlying);
|
||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'resolve' });
|
||
for (const e of foes) battle.applyStatus(e, 'tremble', { duration: num(bb.not_combat), source: unit });
|
||
},
|
||
},
|
||
skills: {
|
||
skchr_branch_1: {
|
||
kind: 'duration', mods: { defPct: num(bb.def) },
|
||
onStart({ battle, unit, skill }) {
|
||
const v = Math.min(0.95, Math.max(0, -num(bb.one_minus_status_resistance)));
|
||
if (v > 0) battle.applyStatus(unit, 'resist', { duration: skill.timeLeft, value: v, source: unit });
|
||
},
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
const hr = num(t.hp_ratio);
|
||
if (hr > 0) {
|
||
battle.on('skillEnd', ({ unit: u, reason }) => {
|
||
if (u === unit && reason !== 'death' && unit.alive) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true, tags: ['talent'] });
|
||
}, { owner: unit });
|
||
}
|
||
if (tb.damage_scale != null) onHitOn(battle, unit, ({ source, dmg }) => { if (source && source.blockedBy === unit) dmg.mul *= num(tb.damage_scale, 1); });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_18 灰毫 攻击力强化·γ型 (DEFAULT, like S2: the owner's deliberate deviation from the 重装 TAKE_DAMAGE row, data
|
||
// TRIGGER_DEVIATIONS, DESIGN §21.29): ATK +atk. 炮术研习: ATK +atk, or +ashlok_t_1.atk when the `cnt` orthogonal
|
||
// tiles around her are all ground (LOW). Elite module (FOR-X, trait atk_scale): vs blocked enemies ATK ×atk_scale.
|
||
// S2 专注轰击 (alt): block count 0 (noBlock: releases what she holds), only ranged (splash) attacks, base attack time
|
||
// +base_attack_time s (−0.4 / −0.5 on 2.8), ATK +atk.
|
||
chess_char_2_18_a: (bb, chess) => {
|
||
const t = talentBb(chess, 0);
|
||
const tb = traitBb(chess);
|
||
return {
|
||
skill: { kind: 'duration', mods: { atkPct: num(bb.atk) } },
|
||
skills: {
|
||
skchr_ashlok_2: {
|
||
kind: 'duration', mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) }, flags: { noBlock: true },
|
||
attack: { fortress: false, _fortressMelee: false },
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) {
|
||
battle.on('deploy', ({ unit: u }) => {
|
||
if (u !== unit) return;
|
||
const r = unit.tileR, c = unit.tileC;
|
||
const low = [[1, 0], [-1, 0], [0, 1], [0, -1]].filter(([dr, dc]) => battle.grid.isLow(r + dr, c + dc)).length;
|
||
const ground = low >= num(t.cnt, 4);
|
||
const v = ground ? num(t['ashlok_t_1.atk'], num(t.atk)) : num(t.atk);
|
||
if (v) battle.addBuff(unit, { key: 'talent:ashlok', mods: { atkPct: v }, data: { ground }, tags: ['talent'] });
|
||
}, { owner: unit });
|
||
if (num(tb.atk_scale, 1) !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.blockedBy) dmg.amount *= num(tb.atk_scale, 1); });
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 2_19 锡人 “大拉里”: see tinmanKit (tier1.js).
|
||
// S1 “老科利” (alt, attack SP, needs a target): throws an alchemy unit at the target (ground first); for
|
||
// projectile_delay_time s ground enemies within projectile_range tiles are 虚弱 (weaken −atk) and take atk_scale ×
|
||
// ATK arts per second (ATK cached at the cast; damage over time, so the elite 凋敝魂灵 ×skill@damage_scale applies;
|
||
// the zone counts for the module's SP bonus and outlives her).
|
||
chess_char_2_19_a: (bb, chess, def) => {
|
||
const k = tinmanKit(bb, chess, def);
|
||
const dur = num(bb.projectile_delay_time, 8), radius = num(bb.projectile_range, 1), dmgScale = num(bb.atk_scale);
|
||
const weak = Math.min(1, Math.max(0, -num(bb.atk)));
|
||
const wither = num(talentBb(chess, 1)['skill@damage_scale'], 1);
|
||
const STEP = 0.25; // weaken refresh cadence (damage every 1 s)
|
||
return {
|
||
...k,
|
||
skills: {
|
||
...(k.skills || {}),
|
||
skchr_tinman_1: {
|
||
kind: instantKind(def),
|
||
onStart({ battle, unit }) {
|
||
const tgt = enemiesInGrid(battle, unit, null, { n: 1, groundOnly: true })[0] ?? enemiesInGrid(battle, unit, null, { n: 1 })[0];
|
||
const x = tgt ? tgt.x : unit.x + unit.fwd[1], y = tgt ? tgt.y : unit.y + unit.fwd[0];
|
||
const atk = unit.s.atk;
|
||
const n = Math.max(1, Math.round(dur / STEP)), per = Math.max(1, Math.round(1 / STEP));
|
||
let i = 0;
|
||
unit.mem.tinZones = (unit.mem.tinZones ?? 0) + 1;
|
||
battle.fx('zone', { x, y, radius, dur, id: unit.id, skill: 'tinman1' });
|
||
battle.every(STEP, (b, sc) => {
|
||
const pulse = i % per === 0;
|
||
for (const e of b.foesInRadius(x, y, radius)) {
|
||
if (e.isFlying || e.s.flags.untargetable) continue;
|
||
if (weak > 0) b.applyStatus(e, 'weaken', { duration: STEP + 0.05, value: weak, source: unit });
|
||
if (wither > 1) b.addBuff(e, { key: 'tinman:wither', duration: STEP + 0.05, data: { mul: wither }, source: unit });
|
||
if (pulse && dmgScale > 0) b.dealDamage(unit, e, { amount: atk * dmgScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] });
|
||
}
|
||
if (++i >= n) { sc.cancel(); unit.mem.tinZones = Math.max(0, (unit.mem.tinZones ?? 1) - 1); }
|
||
}, { immediate: true });
|
||
},
|
||
},
|
||
},
|
||
};
|
||
},
|
||
};
|