Files
ark-stronghold-protocol/server/sim/content/kits/tier5.js
T
sganggs 2dba6da598 feedback1 final: six 重装 skills cast with an enemy in range, a deliberate deviation from the official TAKE_DAMAGE row (GitHub issue #4, PR #12)
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.
2026-10-03 19:13:35 +08:00

2562 lines
155 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/kits/tier5.js — hand-authored kits for every tier-5 chess (DESIGN §7, docs/SIM.md §7.2).
//
// export default { [baseChessId]: (bb, chess, def) => Kit }. `bb` = skill blackboard at the chess's level (normal Lv4 /
// elite Lv7), `chess` = data/chess.json record (talents with their own bb — elite records already carry the module talent
// upgrades — trait.bb incl. the module trait upgrade, stats, tokens), `def` = normalised def (skill.rangeGrid, …).
// Every number comes from those blackboards; the few constants below exist only in the skill TEXT (no blackboard key).
//
// Conventions shared by the kits of this file:
// - `base_attack_time` in skill blackboards is an absolute delta in seconds (AK attribute ADDITION: 烛煌 1.6 s − 1.3 s,
// 号角 2.8 s − 1.2 s, 白面鸮 2.85 s − 1.8 s, 寒檀 2.9 s − 2.4 s); it is converted into `batPct` of the chess's base BAT.
// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge,
// × elementalTakenMul = 元素脆弱), with `element` set for the client colour.
// - Mechanics missing from the chess text follow the PRTS 备注 of the base operator (verified 2026-09-28): 号角 S3 overload
// is the second half of the 24 s duration; 圣约送葬人's extra attack consumes no ammo; 夕 S1 splash 1.7; 烛煌 revive stun
// radius 1.7, S3 splash 1.7 and its refilled ammo capped at the skill's ammo; 寒檀 icicles splash 1.5 and cycle left
// row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit at 1.15 (30°/s, hit radius 0.4); 铃兰 T2 is an
// aura (sluggish enemies in range are 脆弱 while sluggish); 缇缇's chain sleep picks the highest-aggro enemy within
// 1.5; 乌尔比安 lands on the anchor tile > the tile beyond > his own tile; 引星棘刺 throws at the farthest forward tile
// when no enemy is in range; 夕 T2 summons only on a deployable target tile.
// - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field.
// SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins.
// - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an
// empty grid) plus their own activation — 塞雷娅 S2 (`autoCast`: an injured ally in the heal area of the skill; her
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal). 塞雷娅 S1 (自动触发)
// uses the engine's DEFAULT rule with `allies` / `hpAtMost` (about to attack + an ally of the skill area at ≤ half HP:
// the heal replaces that attack). Every other
// kit keeps the data rule (tools/build-data.mjs resolveTrigger, the official 技能策略: DEFAULT = about to attack + an
// enemy in the INITIAL range; SKILL_RANGE for a MANUAL skill's own 技能范围; the class rows — 重装 TAKE_DAMAGE … — for
// every MANUAL skill; AUTO skills never take a class row).
// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor'
// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r},
// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'knockout', 'crit',
// 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'.
import { COLS } from '../../constants.js';
import { bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js';
import { frontOf, rotateOffset, toLocal } from '../../dir.js';
import { mitigate, hasHp, isHpLoss } from '../../damage.js';
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */
const HALF_HP = 0.5;
/** 夕 S1 "下一次攻击溅射范围扩大" — expanded splash radius (tiles; the splash-caster default is 1.1). PRTS: "溅射半径扩大至1.7". */
const DUSK_SPLASH_RADIUS = 1.7;
/** 隐德来希 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';
/** 烛煌 绝处重燃 "使附近的敌人晕眩" radius (tiles). PRTS 备注: 1.7. */
const NEARBY_RADIUS = 1.7;
/** 烛煌 S3 "攻击变为群体攻击": splash radius around the target (tiles). PRTS 技能3 备注 "攻击溅射半径1.7". */
const BLAZE_S3_SPLASH = 1.7;
/** 玛恩纳 游侠 "周围存在3名及以上敌人" radius (8-neighbourhood). */
const AROUND_RADIUS = 1.5;
/** 安洁莉娜 S3 失重: weight (massLevel) reduction while weightless. PRTS: "重量下降一个等级". */
const WEIGHTLESS_MASS = 1;
/** 寒檀 S2 icicle splash radius (PRTS 备注 "冰凌溅射半径1.5"). */
const ICICLE_RADIUS = 1.5;
/** 寒檀 S2 icicle fall time (s) between the attack and the impact. [ASSUMED] */
const ICICLE_DELAY = 0.3;
/** 寒檀 S2 icicle lines, lateral offsets in her facing-RIGHT frame: ① the line on her left, ② on her right, ③ her own, then ①. */
const ICICLE_ROWS = [1, -1, 0];
/** 魔王 T1 微尘: collision radius of one mote (PRTS 备注 0.4); orbit radius / speed come from the talent bb. */
const MOTE_HIT_RADIUS = 0.4;
/**
* Abyssal Hunters (【深海猎人】 = character_table groupId `abyssal`; data/chess.json has no groupId, the charIds come from
* docs/research/03-operators.json): 幽灵鲨, 斯卡蒂, 歌蕾蒂娅, 乌尔比安, 归溟幽灵鲨. 浊心斯卡蒂 (char_1012_skadi2) has groupId
* null in the official data — she is NOT an Abyssal Hunter for these talents.
*/
const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']);
/** Never auto-fires (CUSTOM_RANGE with an empty trigger grid): the kit activates the skill itself. */
const NEVER = Object.freeze({ rule: 'CUSTOM_RANGE', grid: Object.freeze([]) });
const AURA_IV = 0.25;
const AURA_DUR = 0.5;
// alternate skills (loadouts) — text-only numbers
/** "周围" (8-neighbourhood) as a radius: 隐德来希 S2 血镰, 缇缇 S2 sleep ward, 乌尔比安 S1 anchor, 引星棘刺 S3 area. */
const RING1 = 1.5;
/** 夕 S3 "攻击范围与溅射范围扩大" — the expanded splash radius, the same 1.7 as S1 (PRTS). [ASSUMED for S3] */
const DUSK_S3_SPLASH = DUSK_SPLASH_RADIUS;
/** 号角 S2 过载: the second half of the ammo (PRTS 备注 of S3 "技能进行到一半时"; S2 counts shots). [ASSUMED] */
const HORN_S2_OVERLOAD_AT = 0.5;
// ---- helpers ------------------------------------------------------------------------------------------------------
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const on = (u) => !!u && u.alive && u.deployed && !u.removed;
/** In the unit's current range: an ally by its tile, an enemy by its body (a huge one's every tile — sim/body.js). */
const inRange = (unit, x) => !!unit.rangeKeySet && bodyInKeys(x, unit.rangeKeySet);
const talent = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i)?.bb ?? {};
const talentRec = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i) ?? null;
const traitBb = (chess) => chess?.trait?.bb ?? {};
const skillGrid = (chess, def) => def?.skill?.rangeGrid ?? chess?.skill?.rangeGrid ?? null;
const maxCharges = (chess, def) => num(def?.skill?.maxCharges, num(chess?.skill?.maxChargeTime, 1));
/** Blackboard `base_attack_time` (seconds) → batPct of this chess's base attack time. */
const batPct = (sec, chess) => { const b = num(chess?.stats?.bat, 1) || 1; const v = num(sec) / b; return v ? Math.max(-0.9, v) : 0; };
/** Drop zero / non-finite entries (a zero mod is noise in the buff list). */
const mods = (m) => { const o = {}; for (const k of Object.keys(m)) { const v = m[k]; if (typeof v === 'number' && Number.isFinite(v) && v !== 0) o[k] = v; } return o; };
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const nationOf = (u) => u?.def?.raw?.nationId ?? null;
const inFaction = (u, bond, nations) => !!u?.def && ((u.def.bonds || []).includes(bond) || nations.includes(nationOf(u)));
const isLaterano = (u) => inFaction(u, 'lateranoShip', ['laterano']);
const isKazimierz = (u) => inFaction(u, 'kazimierzShip', ['kazimierz']);
const isKjerag = (u) => inFaction(u, 'kjeragShip', ['kjerag']);
const isSargonMinos = (u) => inFaction(u, 'sargonShip', ['sargon', 'minos']);
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId);
const isOp = (u) => u && u.kind === 'op';
// ---- operator loadouts (DESIGN §16) -------------------------------------------------------------------------------
// A kit function is resolved per SELECTED skill: `bb` / `chess.skill` / `def.skill` are the selected skill's. The default
// skill keeps its `skill` spec; every other selectable skill is a `skills[skillId]` entry. Kit `install` / talents run
// under every skill, so default-skill machinery in them is guarded by the selected skill id (`sid`).
/** Id of the selected skill of a chess record / def. */
const selectedId = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null;
/** `skills` map whose entries are built only when read (each spec is a function of the SELECTED skill's bb). */
function lazySkills(builders) {
const o = {};
for (const [id, build] of Object.entries(builders)) Object.defineProperty(o, id, { enumerable: true, get: build });
return o;
}
/** Spec kind of an instant skill (charges when the selected skill can charge). */
const instantKind = (chess, def) => (maxCharges(chess, def) > 1 ? 'charges' : 'instant');
/** Skill-range targeting override of the selected skill (undefined without a skill range). */
const skillRange = (chess, def, extra = null) => {
const g = skillGrid(chess, def);
return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined;
};
/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */
function crowdAspd(battle, unit, key, aspd, cnt) {
if (!aspd || !(cnt > 0)) return;
whileOn(battle, unit, 0.2, () => {
if (battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).length >= cnt) battle.addBuff(unit, { key, duration: 0.3, mods: { aspd } });
});
}
/** "攻击范围内存在元素损伤爆发的敌人时,技力自然恢复速度+X/秒" (modules PRI-Y). */
function burstSpUp(battle, unit, key, sp) {
if (!(sp > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).some((e) => e.s.flags.burstLock)) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
});
}
/** "攻击范围内存在敌人时,技力自然恢复速度+X/秒" (modules DEC-X). */
function enemySpUp(battle, unit, key, sp) {
if (!(sp > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
});
}
/** First living deployed ally with the same operator (charId) — used to keep same-name talents from stacking. */
function leaderOf(battle, unit) {
const cid = unit.def?.charId;
for (const a of battle.allyUnits) if (on(a) && a.def?.charId === cid) return a;
return null;
}
/** Enemy `e` attacks `t` in melee (not a ranged shot), mirroring ai.js enemyAttack. */
function meleeAttacker(e, t) {
const melee = e.profile?.melee ?? e.def?.applyWay === 'MELEE';
const r = num(e.base?.rangeRadius, 0);
return melee || !(r > 0) || (e.blockedBy === t && r < 1);
}
/** Enemy not moving along its route (blocked, stunned, rooted, waiting…). */
const isStill = (e) => !!(e.blockedBy || !e.moving || e.s.flags.stun || e.s.flags.noMove || !(e.s.moveSpeed > 0));
/** Elemental HP damage (元素伤害): engine type 'elemental' (× elementalTakenMul in the pipeline, never trueTakenMul). */
function elementHit(battle, src, tgt, amount, tag, element = null) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
return battle.dealDamage(src, tgt, { amount, type: 'elemental', element, canDodge: false, tags: ['element', tag] });
}
/**
* Activate the unit's skill whenever it is ready and `cond()` holds (NEVER-trigger skills). Consecutive casts of a
* multi-charge skill are spaced by at least one attack interval (like the attack-driven DEFAULT rule).
*/
function autoCast(battle, unit, cond) {
battle.on('tick', () => {
const sk = unit.skill;
if (!sk || sk.noSkill || !on(unit) || !unit.canAct || !sk.ready || unit.s.flags.silence) return;
if (sk.active && sk.isTimed) return;
if (battle.time - sk.lastStart < unit.s.interval - 1e-9) return;
if (cond()) sk.activate('DEFAULT');
}, { owner: unit });
}
/** Periodic check while the unit is on the field. */
function whileOn(battle, unit, sec, fn) {
battle.every(sec, () => { if (on(unit)) fn(); }, { owner: unit });
}
/** "同类效果取最高" SP-recovery aura: refresh `aura:spRecovery` on matching allies unless a higher one is present. */
function spAura(battle, unit, value, filter) {
if (!(value > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.alliesFor(unit)) {
if (!filter(a)) continue;
const cur = a.findBuff('aura:spRecovery');
if (cur && num(cur.mods?.spRecoveryFlat) > value + 1e-9) continue;
battle.addBuff(a, { key: 'aura:spRecovery', duration: AURA_DUR, mods: { spRecoveryFlat: value } });
}
});
}
/** Module PRI-X (本源术师): damage vs enemies in an element burst ×damage_scale. */
function burstDamageUp(battle, unit, mul) {
if (!(mul > 1)) return;
battle.on('hit', (c) => {
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return;
if (c.target.s.flags.burstLock) c.dmg.mul *= mul;
}, { owner: unit });
}
/** Modules PHY-X / GUA-X: heals on allies below hp_ratio ×heal_scale. */
function lowHpHealUp(battle, unit, tb) {
const mul = num(tb.heal_scale), thr = num(tb.hp_ratio);
if (!(mul > 1) || !(thr > 0)) return;
battle.on('heal', (c) => {
if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally') return;
if (c.target.hpRatio < thr) c.amount *= mul;
}, { owner: unit });
}
/**
* Modules "攻击范围扩大" (夕 SPC-X, 白面鸮 RIN-X): the range becomes the SELECTED module's own grid — the data's
* range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + ONE centre tile [0,3] — like tier4's
* 莫斯提马 / 莱恩哈特 / 白面鸮 (integration review: a flat forward +1 added a whole column, 3 tiles, and widened the skill
* ranges too). It replaces the unit's own range, so the DEFAULT trigger's initial range follows; skills with their own
* range keep it. Without such a grid in the data: one extra forward tile [ASSUMED].
*/
function moduleRangeUp(battle, unit, chess) {
if (!chess?.isGolden || !/攻击范围扩大/.test(String(chess?.trait?.moduleDesc ?? ''))) return;
const m = chess.module;
const rec = m && m.active && m.id ? (chess.modules || []).find((x) => x && x.uniEquipId === m.id) : null;
const g = (rec?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
if (g) {
unit.rangeGrid = g.map((p) => [p[0], p[1]]);
battle.refreshRange(unit);
return;
}
// a permanent (persist, never-expiring) rangeExtend: the engine counts it in the initial range of the DEFAULT skill
// trigger ("敌人进入初始攻击范围") as well
battle.addBuff(unit, { key: 't5:moduleRange', mods: { rangeExtend: 1 }, persist: true, allowDead: true });
}
/** Permanent (talent) stat buff that survives death/redeploy. */
function permBuff(battle, unit, key, m) {
const mm = mods(m);
if (Object.keys(mm).length) battle.addBuff(unit, { key, mods: mm, persist: true, allowDead: true });
}
/** Enemies inside an absolute-key grid of `grid` offsets around `unit` (best targets first). */
function enemiesInGrid(battle, unit, grid, n = 0, { ignoreStealth = false } = {}) {
const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], unit.tileR, unit.tileC, unit.dir, 0);
let list;
if (ignoreStealth) {
const set = new Set(keys);
list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && bodyInKeys(e, set));
} else list = battle.enemiesInKeys(keys, unit, { canHitFly: true });
sortEnemyTargets(battle, unit, list, null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
/** Straight-road length through each tile (max of the horizontal and vertical runs of ground-passable tiles). */
const ROAD_CACHE = new WeakMap();
function straightRun(battle, r, c) {
let m = ROAD_CACHE.get(battle);
if (!m) {
m = new Map();
const g = battle.grid, R = battle.rect;
const pass = (rr, cc) => g.groundPassable(rr, cc, true);
for (let rr = R.r0; rr <= R.r1; rr++) {
for (let cc = R.c0; cc <= R.c1; cc++) {
if (!pass(rr, cc)) continue;
let h = 1, v = 1;
for (let k = cc - 1; k >= R.c0 && pass(rr, k); k--) h++;
for (let k = cc + 1; k <= R.c1 && pass(rr, k); k++) h++;
for (let k = rr - 1; k >= R.r0 && pass(k, cc); k--) v++;
for (let k = rr + 1; k <= R.r1 && pass(k, cc); k++) v++;
m.set(rr * COLS + cc, Math.max(h, v));
}
}
ROAD_CACHE.set(battle, m);
}
return m.get(r * COLS + c) ?? 0;
}
// ==================================================================================================================
// kits
const KITS = {
// ---------------------------------------------------------------------------------------------------------------
// 圣约送葬人 — S2 近身铳斗 (ammo, attack SP): ATK/DEF +, block +1, melee hits dodged with prob (+ammo refill).
// T1 受选之人: extra attack chance (+prob_add per ammo spent in the skill). T2 铳弹共感: +ammo per Laterano op.
// S1 遗嘱执行 (ammo 8, attack SP): skill range, ATK +, ignores def_penetrate_fixed DEF.
// S3 圣约决裁 (ammo 16): skill range, BAT +0.5 s, ATK +; +attack@atk ATK per bullet spent (≤ attack@max_stack_cnt),
// reaper self-heal ×trait_ratio; at the end every enemy attacked during the skill takes attack@final_atk_scale × ATK phys.
// Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
chess_char_5_01_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1);
const sid = selectedId(chess, def);
const prob = num(bb.prob), refill = num(bb.recover_cnt, 1);
return {
skills: lazySkills({
skchr_excu2_1: () => ({
kind: 'ammo', ammo: num(bb['attack@trigger_time'], 8),
mods: mods({ atkPct: num(bb.atk), defIgnoreFlat: num(bb.def_penetrate_fixed) }),
targeting: skillRange(chess, def),
}),
skchr_excu2_3: () => ({
kind: 'ammo', ammo: num(bb['attack@trigger_time'], 16),
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
targeting: skillRange(chess, def),
attack: { onEachHit({ unit, target }) { if (target && target.side === 'enemy') unit.mem.verdict?.add(target); } },
onStart({ unit }) {
unit.mem.verdict = new Set();
// 特性的回复生命效果提高至2倍 (the reaper profile reads profile.selfHeal on every attack)
if (unit.profile) { unit.mem.verdictHeal0 ??= num(unit.profile.selfHeal, 50); unit.profile.selfHeal = unit.mem.verdictHeal0 * num(bb.trait_ratio, 1); }
},
onEnd({ battle, unit }) {
const hit = unit.mem.verdict;
unit.mem.verdict = null;
if (unit.profile && unit.mem.verdictHeal0 != null) unit.profile.selfHeal = unit.mem.verdictHeal0;
if (hit && unit.alive) {
for (const e of hit) {
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@final_atk_scale']), type: 'phys', isSkill: true, tags: ['skill', 'verdict'] });
}
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1.5, skill: 'excu2' });
}
battle.removeBuff(unit, 'excu2:verdict');
},
}),
}),
skill: {
kind: 'ammo', ammo: num(bb['attack@trigger_time'], 12),
mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt) }),
},
// REA-Y: its trait part is DISPLAY-only (official battle_equip_table uniequip_003_excu2: target DISPLAY, the ASPD
// text "{value}" = 12; the ASPD rider itself is the talent −1 part). data/chess.json merges that display blackboard
// into trait.bb ({value: 12}), which the reaper profile would read as its per-hit heal: the heal stays the base
// trait's (50).
trait: tm['trigger_cnt[equip]'] != null && chess?.traitBase?.bb?.value != null ? { selfHeal: num(chess.traitBase.bb.value, 50) } : undefined,
talents: [
{ install(battle, unit) { // 受选之人
let spent = 0;
const reset = (c) => { if (c.unit === unit) spent = 0; };
battle.on('skillStart', reset, { owner: unit });
battle.on('skillEnd', reset, { owner: unit });
battle.on('ammoUsed', (c) => { if (c.unit === unit) spent++; }, { owner: unit });
// PRTS 备注: "触发时可额外回复1点攻击回复技力,不额外消耗弹药" — the extra attack is an attack (attack SP, trait
// heal) but consumes no ammo (engine forceAttack `noAmmo`: no bullet, no ammoUsed for any listener — the
// 7-bullet 特质 counter, items, bonds and the prob_add counter above)
battle.on('attack', (c) => {
if (c.attacker !== unit || unit.mem.extraAttack || !on(unit)) return;
const p = num(t0.prob) + (unit.skill?.active ? num(t0.prob_add) * spent : 0);
if (!(p > 0) || !battle.rng.chance(Math.min(1, p))) return;
const dep = unit.deploySeq;
battle.after(0, () => {
if (!on(unit) || unit.deploySeq !== dep || !unit.canAct || unit.s.flags.disarm) return;
unit.mem.extraAttack = true;
let hit = false;
try { hit = battle.forceAttack(unit, null, { noAmmo: true }); } finally { unit.mem.extraAttack = false; }
if (hit) battle.fx('extraAttack', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
}, { owner: unit });
} },
{ install(battle, unit) { // 铳弹共感
battle.on('skillStart', (c) => {
if (c.unit !== unit || c.skill.kind !== 'ammo') return;
const n = battle.allies().filter((a) => isOp(a) && isLaterano(a)).length;
const stacks = Math.min(num(t1.add_count_max_stack, 4), n);
if (stacks > 0) c.skill.addAmmo(stacks * num(t1.add_count, 1));
}, { owner: unit });
} },
],
install(battle, unit) {
crowdAspd(battle, unit, 'excu2:module', num(tm.attack_speed), num(tm['trigger_cnt[equip]']));
if (sid === 'skchr_excu2_3') { // S3: +attack@atk ATK per bullet spent
battle.on('ammoUsed', (c) => {
if (c.unit !== unit || !unit.skill?.active) return;
battle.addBuff(unit, { key: 'excu2:verdict', refresh: 'stack', maxStacks: Math.max(1, num(bb['attack@max_stack_cnt'], 30)), mods: mods({ atkPct: num(bb['attack@atk']) }) });
}, { owner: unit });
}
if (sid && sid !== 'skchr_excu2_2') return;
// S2: melee attacks dodged with `prob`, each dodge refills ammo
battle.on('hit', (c) => {
if (c.target !== unit || !unit.skill?.active || !c.source || c.source.side !== 'enemy' || !c.dmg.isAttack) return;
if (!meleeAttacker(c.source, unit) || !(prob > 0) || !battle.rng.chance(prob)) return;
c.dmg.cancel = true;
battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id });
// a dodge like the engine's (listeners of the `dodge` hook — items/bonds — see it too)
if (battle.hasHook('dodge')) battle.emit('dodge', { source: c.source, target: unit, dmg: c.dmg });
unit.skill.addAmmo(refill);
}, { owner: unit });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 缇缇 — S3 旧日绽放 (25 s): ATK +, 2 targets, sleeps non-sleeping targets; during the skill every enemy waking up (or
// knocked out asleep) takes arts damage growing with the sleep time (linear min_atk_scale → max_atk_scale over `sleep` s,
// PRTS 备注) and sends the highest-aggro enemy within range_radius to sleep; other allies in range fall asleep (小睡)
// instead of dying. T1 凝固的时光: can attack sleeping enemies, +15 % ATK arts vs non-moving
// enemies, sleeping enemies take 30 % ATK arts/s. T2 勇气的报偿: Sargon/Minos ops above 50 % HP get +20 ASPD.
// S1 缓蚀 (duration): ATK +, attack@prob chance per attack to sleep the target attack@sleep s.
// S2 封护 (duration): no attacks, ATK +; she and the lowest-HP-ratio operator in her range sleep until the skill ends
// (沉睡 = invulnerable), enemies around either of them keep falling asleep; T1 ×talent_scale.
chess_char_5_02_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1);
const sid = selectedId(chess, def);
const hitSleep = num(bb['attack@sleep'], num(bb.sleep, 5));
const minS = num(bb.min_atk_scale, 1), maxS = num(bb.max_atk_scale, minS);
const chainSleep = num(bb.sleep, hitSleep), radius = num(bb.range_radius, 1.5), chainN = Math.max(1, num(bb.max_target, 1));
// S2 封护: "第一天赋效果提升至N倍" while it runs
const t0Scale = (unit) => (sid === 'skchr_titi_2' && unit.skill?.active ? num(bb.talent_scale, 1) : 1);
const ward = (battle, unit, a) => {
const entered = !a.buffs.some((b) => (b.status ?? b.key) === 'sleep');
battle.addBuff(a, { key: 'titi:ward', flags: { sleep: true }, visible: true, status: 'sleep', data: { src: unit } });
battle.releaseBlocked(a);
if (battle.hasHook('statusApplied')) battle.emit('statusApplied', { source: unit, target: a, status: 'sleep', duration: Infinity, entered });
};
return {
skills: lazySkills({
skchr_titi_1: () => ({
kind: 'duration', mods: mods({ atkPct: num(bb.atk) }),
attack: {
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.s.flags.sleep || !battle.rng.chance(num(bb['attack@prob']))) return;
battle.applyStatus(target, 'sleep', { duration: num(bb['attack@sleep']), source: unit });
},
},
}),
skchr_titi_2: () => ({
kind: 'duration', mods: mods({ atkPct: num(bb.atk) }),
attack: { noAttack: true },
onStart({ battle, unit }) {
const pick = battle.alliesInGrid(unit).filter((a) => a !== unit && isOp(a) && a.hp > 0)
.sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0] ?? null;
unit.mem.titiWard = pick ? [unit, pick] : [unit];
for (const a of unit.mem.titiWard) ward(battle, unit, a);
unit.mem.titiWardAcc = AURA_IV;
battle.fx('sleepGuard', { x: unit.x, y: unit.y, id: unit.id });
},
onTick({ battle, unit, dt }) {
unit.mem.titiWardAcc += dt;
if (unit.mem.titiWardAcc < AURA_IV - 1e-9) return;
unit.mem.titiWardAcc = 0;
for (const a of unit.mem.titiWard || []) {
if (!a.alive || !a.deployed || !a.findBuff('titi:ward')) continue;
for (const e of battle.foesInRadius(a.x, a.y, RING1)) if (e.alive) battle.applyStatus(e, 'sleep', { duration: AURA_DUR, source: unit });
}
},
onEnd({ battle, unit }) {
for (const a of unit.mem.titiWard || []) {
const b = a.findBuff('titi:ward');
if (b && b.data.src === unit) battle.removeBuff(a, b);
}
unit.mem.titiWard = null;
},
}),
}),
trait: { hitSleep: true }, // 凝固的时光 (a): she targets and damages sleeping enemies (沉睡 = 无敌 for everyone else)
skill: {
kind: 'duration',
mods: mods({ atkPct: num(bb.atk) }),
attack: {
maxTargets: Math.max(1, num(bb['attack@max_target'], 2)),
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.s.flags.sleep) return;
battle.applyStatus(target, 'sleep', { duration: hitSleep, source: unit });
},
},
onEnd({ battle, unit }) {
for (const a of battle.allyUnits) {
const b = a.findBuff('titi:allySleep');
if (b && b.data.src === unit) battle.removeBuff(a, b);
}
},
},
talents: [
{ install(battle, unit) { // 凝固的时光 — (a) is the `hitSleep` trait above
// (b) +extra_atk_scale arts vs non-moving enemies
battle.on('damaged', (c) => {
if (c.source !== unit || !c.dmg?.isAttack || c.dmg.isSplash || c.type === 'element') return;
const e = c.target;
if (e.side !== 'enemy' || !e.alive || !isStill(e)) return;
battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.extra_atk_scale) * t0Scale(unit), type: 'arts', tags: ['talent', 'titiStill'] });
}, { owner: unit });
// (c) sleeping enemies take damage_atk_scale × ATK arts per second
whileOn(battle, unit, 1, () => {
const amount = unit.s.atk * num(t0.damage_atk_scale) * t0Scale(unit);
for (const e of battle.enemies) {
if (e.alive && !e.hidden && e.s.flags.sleep) battle.dealDamage(unit, e, { amount, type: 'arts', tags: ['talent', 'titiDream'] });
}
});
} },
{ install(battle, unit) { // 勇气的报偿: 精力充沛
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.allies()) {
if (isOp(a) && isSargonMinos(a) && a.hpRatio > num(t1.hp_ratio, 0.5)) battle.addBuff(a, { key: 'titi:vigor', duration: AURA_DUR, mods: { aspd: num(t1.attack_speed) } });
}
});
} },
],
install(battle, unit) {
if (sid && sid !== 'skchr_titi_3') return;
// S3: wake-up burst chain (sleep applied by anyone counts)
const asleep = new Map();
// PRTS 备注: "向1.5半径内除该单位外仇恨值最高的敌方单位施加沉睡" — highest aggro (taunt) first, then the nearest
const sleepOthers = (from) => {
const cands = battle.foesInRadius(from.x, from.y, radius).filter((o) => o !== from && !o.s.flags.sleep && !o.mem?.candleOwner)
.sort((a, b) => (b.s.taunt || 0) - (a.s.taunt || 0) || dist(a, from) - dist(b, from) || a.spawnSeq - b.spawnSeq);
let n = 0;
for (const o of cands) {
if (n >= chainN) break;
if (battle.applyStatus(o, 'sleep', { duration: chainSleep, source: unit })) n++;
}
};
battle.on('statusApplied', (c) => {
if (c.status === 'sleep' && c.target.side === 'enemy' && !asleep.has(c.target)) asleep.set(c.target, battle.time);
}, { owner: unit });
battle.on('tick', () => {
if (!asleep.size) return;
for (const [e, t0s] of asleep) {
if (!e.alive) { asleep.delete(e); continue; }
if (e.s.flags.sleep) continue;
asleep.delete(e);
if (!unit.skill?.active || !on(unit)) continue;
const scale = minS + (maxS - minS) * Math.min(1, (battle.time - t0s) / Math.max(0.1, chainSleep));
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'titiWake'] });
battle.fx('wake', { x: e.x, y: e.y, id: e.id, scale: Math.round(scale * 100) / 100 });
sleepOthers(e);
}
}, { owner: unit });
battle.on('kill', (c) => {
const v = c.victim;
if (v.side !== 'enemy' || !asleep.has(v)) return;
asleep.delete(v);
if (unit.skill?.active && on(unit)) sleepOthers(v);
}, { owner: unit });
// S3: other allies in range fall asleep instead of dying, until healed to full or the skill ends
battle.on('fatal', (c) => {
const a = c.unit;
if (c.prevented || a.side !== 'ally' || a === unit) return;
const guard = a.findBuff('titi:allySleep');
if (guard) { if (guard.data.src === unit) c.prevented = true; return; }
if (!isOp(a) || !unit.skill?.active || !on(unit) || !unit.rangeKeySet?.has(a.tileR * COLS + a.tileC)) return;
c.prevented = true;
const entered = !a.buffs.some((b) => (b.status ?? b.key) === 'sleep');
battle.addBuff(a, {
key: 'titi:allySleep', flags: { sleep: true }, visible: true, status: 'sleep', data: { src: unit }, interval: 0.2,
onTick: ({ unit: x, buff }) => { if (x.hp >= x.s.maxHp - 1e-6) battle.removeBuff(x, buff); },
});
// other content (特质 "干员进入沉睡时") observes it like any sleep status
if (battle.hasHook('statusApplied')) battle.emit('statusApplied', { source: unit, target: a, status: 'sleep', duration: Infinity, entered });
battle.fx('sleepGuard', { x: a.x, y: a.y, id: a.id });
}, { owner: unit, priority: 5 });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 烛煌 — S3 众恶的焚场 (ammo): skill range 4-11 (the targets; the DEFAULT cast still needs an enemy in 3-1), ATK +,
// BAT −1.3 s, "攻击变为群体攻击" = one target + a BLAZE_S3_SPLASH (1.7) splash around it (PRTS 备注 "攻击溅射半径1.7",
// 中点判定 — so the fire reaches past the diamond; it used to hit every enemy inside it instead, community report E1
// after 0.1.0), +attack@atk_scale ATK elemental damage to every enemy of the attack in a burn burst (main and splash),
// dealt BEFORE the attack's own damage ("于攻击造成伤害前判定元素爆发并造成元素伤害": a 'hit' hook — so a hit that kills
// or starts the burst keeps / does not get it); loses 3 % max HP/s; any burn burst on the field refills ammo_recover
// bullets, never above the skill's ammo ("补充后的弹药数量无法超过上限"). Each landed bolt shows its splash (fx 'splash' r 1.7).
// T1 熔点引爆: burn burst anywhere → 350 % ATK elemental damage to it + heal 12 % max HP. T2 绝处重燃: downed instead of
// dying (6000 shield, no attack, no heal, 3 %/s regen) → revives at full HP and stuns nearby enemies.
// Module (elite): ×damage_scale vs enemies in an element burst.
// S1 炙手之援 (instant): the operator with the highest max HP in her range carries a fire aura for max_duration s —
// every `interval` s the enemies within range_radius of it take atk_scale × her ATK arts + element_multiplier × that
// damage as 灼燃损伤. S2 沸血燎原 (duration): skill range, BAT +0.9 s, ATK +, 3 targets; each attack costs
// attack@hp_ratio of her max HP and leaves a burning tile under every target: ground enemies on it move −50 % and take
// atk_scale × ATK arts + element_damage_scale × that as 灼燃损伤 per second (the tiles burn until the skill ends
// [ASSUMED: no duration in the data]). Module PRI-Y (elite): SP +0.2/s while an enemy in range is in an element burst.
chess_char_5_03_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1);
const sid = selectedId(chess, def);
const lose = num(bb.lose_hp_scale);
const burnTiles = (battle, unit) => {
const tiles = unit.mem.blazeTiles;
if (!tiles || !tiles.size) return;
unit.mem.blazeAcc2 = (unit.mem.blazeAcc2 ?? 0) + AURA_IV;
const dmgTick = unit.mem.blazeAcc2 >= 1 - 1e-9;
if (dmgTick) unit.mem.blazeAcc2 -= 1;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, tiles)) continue;
battle.addBuff(e, { key: `blaze2:ground:${unit.id}`, duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + num(bb.move_speed)) } });
if (!dmgTick) continue;
const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeGround'] });
if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_damage_scale), tags: ['skill', 'blazeGround'] });
}
};
return {
skills: lazySkills({
skchr_blaze2_1: () => ({
kind: instantKind(chess, def),
onStart({ battle, unit }) {
const t = battle.alliesInGrid(unit).filter((a) => isOp(a)).sort((a, b) => b.s.maxHp - a.s.maxHp || a.id - b.id)[0];
if (!t) return;
const r = num(bb.range_radius, 1.5);
battle.addBuff(t, {
key: `blaze2:aid:${unit.id}`, duration: num(bb.max_duration, 20), interval: Math.max(0.1, num(bb.interval, 1)), visible: true, data: { src: unit },
onTick: ({ unit: x }) => {
for (const e of battle.foesInRadius(x.x, x.y, r)) {
const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeAid'] });
if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_multiplier), tags: ['skill', 'blazeAid'] });
}
},
});
battle.fx('aoe', { x: t.x, y: t.y, id: unit.id, r, skill: 'blaze2Aid' });
},
}),
skchr_blaze2_2: () => ({
kind: 'duration',
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 3)) }),
attack: {
onEachHit({ unit, target }) {
if (target && target.side === 'enemy') unit.mem.blazeTiles?.add(Math.round(target.y) * COLS + Math.round(target.x));
},
},
onStart({ unit }) { unit.mem.blazeTiles = new Set(); unit.mem.blazeAcc2 = 0; },
onAttack({ battle, unit }) { battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio']), { source: unit }); },
onEnd({ unit }) { unit.mem.blazeTiles = null; },
}),
}),
skill: {
kind: 'ammo', ammo: num(bb['attack@trigger_time'], 18),
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
attack: {
splashRadius: BLAZE_S3_SPLASH,
// the fire of each landed bolt (the screen rings splash attacks by sub-profession; 本源术师 is not one): once
// per attack at the main target / its spot — from the profile it was fired with, so the last bolt too
onHit({ battle, unit, target, x, y }) {
battle.fx('splash', { x, y, ...(target && target.alive ? { id: target.id } : {}), src: unit.id, r: BLAZE_S3_SPLASH, element: 'burn' });
},
},
onStart({ unit }) { unit.mem.blazeAcc = 0; },
onTick({ battle, unit, dt }) {
if (!(lose > 0)) return;
unit.mem.blazeAcc = (unit.mem.blazeAcc ?? 0) + dt;
while (unit.mem.blazeAcc >= 1 && unit.alive) {
unit.mem.blazeAcc -= 1;
battle.loseHp(unit, unit.s.maxHp * lose, { source: unit });
}
},
},
talents: [
{ install(battle, unit) { // 熔点引爆
battle.on('elementBurst', (c) => {
if (c.element !== 'burn' || !on(unit) || c.target.side !== 'enemy') return;
elementHit(battle, unit, c.target, unit.s.atk * num(t0.ep_damage_scale), 'blazeMelt', 'burn');
// 绝处重燃: "倒地期间…无法被治疗" — the meltdown heal does not reach her while downed
if (!unit.mem.downed) battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio), { self: true });
battle.fx('meltdown', { x: c.target.x, y: c.target.y, id: c.target.id });
}, { owner: unit });
} },
{ install(battle, unit) { // 绝处重燃
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.downed = false; }, { owner: unit });
battle.on('fatal', (c) => {
if (c.unit !== unit || c.prevented || unit.mem.downed) return;
c.prevented = true;
unit.mem.downed = true;
if (unit.skill?.active) unit.skill.end('downed');
battle.releaseBlocked(unit);
const dep = unit.deploySeq;
battle.addBuff(unit, {
key: 'blaze2:downed', visible: true, shield: num(t1.dynamic), interval: 0.2,
flags: { disarm: true, noHeal: true },
mods: mods({ hpRegenRatio: num(t1.hp_recovery_per_sec_by_max_hp_ratio), blockCnt: -99 }),
onTick: ({ unit: u, buff }) => {
if (u.deploySeq !== dep || u.hp < u.s.maxHp - 1e-6) return;
battle.removeBuff(u, buff);
u.mem.downed = false;
for (const e of battle.foesInRadius(u.x, u.y, NEARBY_RADIUS)) if (e.alive) battle.applyStatus(e, 'stun', { duration: num(t1.stun), source: u });
battle.fx('revive', { x: u.x, y: u.y, id: u.id, r: NEARBY_RADIUS });
},
});
battle.fx('downed', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit, priority: -50 });
} },
],
install(battle, unit) {
burstDamageUp(battle, unit, num(tb.damage_scale));
burstSpUp(battle, unit, 'blaze2:module', num(tm.sp_recovery_per_sec));
if (sid === 'skchr_blaze2_2') whileOn(battle, unit, AURA_IV, () => burnTiles(battle, unit));
if (sid && sid !== 'skchr_blaze2_3') return;
// S3: each hit of her skill attack (main and splash) on an enemy in a burn burst deals the bonus first (PRTS 备注).
// Gated on the attack's own isSkill (captured when the bolt was fired), not on the live skill: her bolts land after
// the last bullet's end('ammo') / an early end (downed), and those still carry it. Only S3 runs here (sid gate).
battle.on('hit', (c) => {
const d = c.dmg, t = c.target;
if (c.source !== unit || !d.isAttack || !d.isSkill || d.cancel) return;
if (t.side === 'enemy' && t.alive && t.findBuff('burnBurst')) elementHit(battle, unit, t, unit.s.atk * num(bb['attack@atk_scale']), 'blazeBurn', 'burn');
}, { owner: unit });
const maxAmmo = num(bb['attack@trigger_time'], 18);
battle.on('elementBurst', (c) => { // S3: burn bursts refill ammo, up to the skill's ammo
const sk = unit.skill;
if (c.element !== 'burn' || !sk?.active || sk.kind !== 'ammo' || !on(unit)) return;
const n = Math.min(num(bb.ammo_recover), maxAmmo - sk.ammoLeft);
if (n > 0) sk.addAmmo(n);
}, { owner: unit });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 华法琳 — S1 紧急包扎 (charges, attack SP): the next heal on a target below half HP adds hp_ratio × its max HP.
// T1 血液样本回收: an enemy dying in range → +2 SP to herself and to a random ally in range.
// Module (elite): heals on allies below 50 % ×1.15.
chess_char_5_04_a: (bb, chess) => {
const t0 = talent(chess, 0), tb = traitBb(chess);
const ratio = num(bb.hp_ratio);
return {
skill: {
kind: 'charges', heal: true, trigger: NEVER,
onStart({ unit }) { unit.mem.bldskBonus = unit.mem.bldskTarget ?? null; },
},
talents: [{ install(battle, unit) { // 血液样本回收
battle.on('kill', (c) => {
const v = c.victim;
if (v.side !== 'enemy' || !on(unit) || !inRange(unit, v)) return;
unit.skill?.gainSp(num(t0['bldsk_t_1[self].sp']), 'talent');
const others = battle.alliesInGrid(unit).filter((a) => a !== unit && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive');
const pick = battle.rng.pick(others);
if (pick) pick.skill.gainSp(num(t0['bldsk_t_1[rand].sp']), 'talent');
}, { owner: unit });
} }],
install(battle, unit) {
// the skill only fires for a heal target below half HP: checked right before the heal
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit) return;
unit.mem.bldskNoSp = false;
const sk = unit.skill;
if (!sk || !sk.ready || unit.s.flags.silence) return;
const t = c.targets[0];
if (!t || t.side !== 'ally' || !(t.hpRatio < HALF_HP)) return;
unit.mem.bldskTarget = t;
// the heal made by the skill recovers no attack SP (engine rule for skill attacks: cost N ⇒ every N+1 heals)
if (sk.activate('DEFAULT')) unit.mem.bldskNoSp = true;
unit.mem.bldskTarget = null;
}, { owner: unit });
battle.on('spGain', (c) => {
if (c.unit !== unit || c.reason !== 'attack' || !unit.mem.bldskNoSp) return;
unit.mem.bldskNoSp = false;
c.amount = 0;
}, { owner: unit, priority: 100 });
battle.on('heal', (c) => { // merged into the same heal
if (c.source !== unit || !unit.mem.bldskBonus || c.target !== unit.mem.bldskBonus) return;
unit.mem.bldskBonus = null;
// the bonus is part of the same heal: the healer's and the target's healing multipliers apply to it too
c.amount += c.target.s.maxHp * ratio * num(unit.s.healingDealtMul, 1) * num(c.target.s.healingTakenMul, 1);
battle.fx('healAoe', { x: c.target.x, y: c.target.y, id: c.target.id, r: 0.5 });
}, { owner: unit, priority: 10 });
lowHpHealUp(battle, unit, tb);
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 乌尔比安 — S3 必须开辟的通路 (25 s, CUSTOM_RANGE row ahead): max HP/ATK +, throws an anchor forward that stops on the
// first enemy or at max distance: 135 % ATK phys + 6 s stun around it (projectile_range); moves onto the anchor tile when
// deployable (a 从不混淆的方向 marker keeps his tile) and returns at skill end.
// T1 本性的坚守: heal 100 (160 below 50 %) on every hit taken. T2 血脉的哺养: per kill +120 max HP / +30 ATK (×9),
// other Abyssal Hunters +50 %. Module (elite): healing received ×1.2.
// S1 必须促成的接触 (instant): the anchor lands on the best enemy of the skill range (beyond his own range, unblocked
// first) and drags up to max_target enemies around it (RING1) in front of him (中等力度), atk_scale × ATK phys each;
// a 捕网 — ground enemies only ([ASSUMED] like 雪雉's "不对空" net). S3's anchor blast has no such note: it hits air
// units too [ASSUMED].
// S2 必须维系的界限 (toggle, 持续时间无限): T1 ×talent_scale, block +1, max HP +, ATK +.
chess_char_5_05_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
const sid = selectedId(chess, def);
const t0Scale = sid === 'skchr_ulpia_2' ? num(bb.talent_scale, 1) : 1;
const tokenId = chess?.skill?.overrideTokenKey ?? def?.skill?.raw?.overrideTokenKey ?? 'token_10039_ulpia_block';
const trig = def?.skill?.trigger?.grid ?? chess?.skill?.trigger?.customRangeGrid ?? [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6]];
const reach = Math.max(1, ...trig.filter((p) => p[0] === 0).map((p) => p[1]));
const radius = num(bb.projectile_range, 1.5);
return {
skills: lazySkills({
skchr_ulpia_1: () => ({
kind: instantKind(chess, def),
onStart({ battle, unit }) {
// "向前方扔出船锚": the anchor goes beyond his own reach first (pulling what he already hits is moot), then
// unblocked ground enemies, then the usual target order
const list = enemiesInGrid(battle, unit, skillGrid(chess, def) ?? unit.rangeGrid);
const reach = (e) => inRange(unit, e);
// the anchor is a 捕网 (PRTS 备注 "实际效果为捕网而非拖拽"); 雪雉's 捕网 is "不对空" (PRTS 雪雉 备注) — [ASSUMED] the same
// for this one: it never lands on or catches air units (FLY, 近地悬浮, 浮空)
const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying);
if (!main) return;
const near = battle.foesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep)
.sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq)
.slice(0, Math.max(1, num(bb.max_target, 2)));
const force = num(bb.force, 1);
battle.fx('anchor', { x: main.x, y: main.y, id: unit.id, fromX: unit.x, fromY: unit.y, r: RING1 });
for (const e of near) {
// "中等力度地拖拽至面前": the 捕网's pull uses the 拖拽 rules (PRTS 推与拉 §捕网 "力的大小:同拖拽") —
// Battle.pullToFront, the official 力度 − 重量 pull
battle.pullToFront(e, unit, force);
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchorPull'] });
}
},
}),
skchr_ulpia_2: () => ({
kind: 'toggle',
mods: mods({ atkPct: num(bb.atk), hpPct: num(bb.max_hp), blockCnt: num(bb.block_cnt) }),
}),
}),
skill: {
kind: 'duration',
mods: mods({ hpPct: num(bb.max_hp), atkPct: num(bb.atk) }),
onStart({ battle, unit }) {
// the anchor flies straight ahead along his direction
let stop = null;
for (let d = 1; d <= reach; d++) {
const [r, c] = frontOf(unit.tileR, unit.tileC, unit.dir, d);
// the anchor stops at the field edge and in front of a ground obstacle (crates, roadblocks)
if (!battle.grid.inRect(r, c) || battle.grid.isObstacle(r, c)) break;
stop = d;
if (battle.enemiesInKeys([r * COLS + c], unit, { canHitFly: true }).length) break;
}
const [sr, sc] = frontOf(unit.tileR, unit.tileC, unit.dir, stop ?? 0);
const fromX = unit.x, fromY = unit.y;
battle.fx('anchor', { x: sc, y: sr, id: unit.id, fromX, fromY, r: radius });
for (const e of battle.foesInRadius(sc, sr, radius)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchor'] });
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit });
}
if (stop == null || !unit.alive) return;
// PRTS 备注: landing tile > the tile one beyond it > his own tile (a deployable, free, unreserved melee tile)
const ok = ([r, c]) => (r !== unit.tileR || c !== unit.tileC) && battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c);
const dest = [[sr, sc], frontOf(unit.tileR, unit.tileC, unit.dir, stop + 1)].find(ok);
if (dest == null) return;
const home = [unit.tileR, unit.tileC];
if (!battle.relocate(unit, dest[0], dest[1])) return;
const marker = battle.spawnToken(unit, tokenId, home[0], home[1], { untargetable: true, kit: { skill: null, trait: { noAttack: true } } });
unit.mem.anchorHome = { r: home[0], c: home[1], marker };
battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY });
},
onEnd({ battle, unit }) {
const h = unit.mem.anchorHome;
unit.mem.anchorHome = null;
if (!h) return;
if (h.marker && h.marker.alive) battle.retreat(h.marker, { reason: 'expired', permanent: true });
if (unit.alive && unit.deployed) {
const fromX = unit.x, fromY = unit.y;
if (battle.relocate(unit, h.r, h.c)) battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY });
}
},
},
talents: [
{ install(battle, unit) { // 本性的坚守
battle.on('damaged', (c) => {
if (c.target !== unit || !on(unit) || unit.hp <= 0 || !(c.amount > 0) || c.type === 'element' || isHpLoss(c.dmg)) return; // (not a 流失)
const v = (unit.hpRatio < num(t0.hp_ratio, 0.5) ? num(t0.value2) : num(t0.value1)) * (unit.skill?.active ? t0Scale : 1);
if (v > 0) battle.heal(unit, unit, v, { self: true });
}, { owner: unit });
} },
{ install(battle, unit) { // 血脉的哺养
battle.on('kill', (c) => {
if (c.killer !== unit || c.victim.side !== 'enemy' || !on(unit)) return;
battle.addBuff(unit, { key: 'ulpia:blood', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 9)), mods: mods({ hpFlat: num(t1.max_hp), atkFlat: num(t1.atk) }) });
const share = mods({ hpFlat: num(t1['ulpia_t_1[abyssal].max_hp']), atkFlat: num(t1['ulpia_t_1[abyssal].atk']) });
if (!Object.keys(share).length) return;
for (const a of battle.allies()) {
if (a !== unit && isOp(a) && isAbyssal(a)) battle.addBuff(a, { key: 'ulpia:bloodShare', refresh: 'stack', maxStacks: Math.max(1, num(t1['ulpia_t_1[abyssal].max_stack_cnt'], 9)), mods: share });
}
}, { owner: unit });
} },
],
install(battle, unit) {
if (num(tb.heal_scale) > 0) permBuff(battle, unit, 'ulpia:module', { healingTakenMul: num(tb.heal_scale) });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 隐德来希 — 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), tb = traitBb(chess);
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) {
crowdAspd(battle, unit, 'etlchi:module', num(tb.attack_speed), num(tb.cnt));
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 });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 史尔特尔 — S3 黄昏 (toggle, 持续时间无限): full heal, ATK +, range +2, 3 targets, max HP +5000 (flat), HP loss ramping
// to 20 % max HP/s over 60 s. T1 熔火: ignores 20 RES. T2 余烬: lethal damage keeps HP ≥ 1 for 8 s, then she withdraws.
// Module (elite): ASPD +8 while not blocking.
// S1 烈焰魔剑 (instant, attack SP): next attack atk_scale × ATK; a kill refills all SP at once.
// S2 熔核巨影 (duration): ATK +, range +1, 2 targets; an attack that hits a single enemy is ×critical atk_scale.
// Module AFT-Y (elite): the enemies she blocks are 法术脆弱 +10 %.
chess_char_5_07_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
const sid = selectedId(chess, def);
const iv = Math.max(0.05, num(bb.interval, 0.2)), peak = num(bb.hp_ratio), ramp = Math.max(0.1, num(bb.duration, 60));
const maxHp = num(bb.max_hp);
return {
skills: lazySkills({
skchr_surtr_1: () => ({
kind: instantKind(chess, def),
attack: {
atkScale: num(bb.atk_scale, 1),
onHit({ unit, target }) {
// 将目标击倒则立即恢复所有技力 (the skill is still pending here: the refill is the next charge)
if (target && target.side === 'enemy' && !target.alive && unit.skill) unit.skill.gainSp(unit.skill.spCost, 'skill');
},
},
}),
skchr_surtr_2: () => ({
kind: 'duration', mods: mods({ atkPct: num(bb.atk) }),
targeting: mods({ rangeExtend: Math.round(num(bb.ability_range_forward_extend)), maxTargets: num(bb['attack@max_target']) }),
}),
}),
skill: {
kind: 'toggle',
mods: mods({ atkPct: num(bb.atk), ...(Math.abs(maxHp) > 5 ? { hpFlat: maxHp } : { hpPct: maxHp }) }),
targeting: mods({ rangeExtend: Math.round(num(bb.ability_range_forward_extend)), maxTargets: num(bb['attack@max_target']) }),
onStart({ battle, unit }) {
unit.mem.twilightT = 0;
unit.mem.twilightAcc = 0;
battle.heal(unit, unit, unit.s.maxHp, { self: true });
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'surtr' });
},
onTick({ battle, unit, dt }) {
if (!(peak > 0)) return;
unit.mem.twilightT += dt;
unit.mem.twilightAcc += dt;
while (unit.mem.twilightAcc >= iv && unit.alive) {
unit.mem.twilightAcc -= iv;
const rate = peak * Math.min(1, unit.mem.twilightT / ramp);
if (rate > 0) battle.loseHp(unit, unit.s.maxHp * rate * iv, { source: unit, silent: true });
}
},
},
talents: [
{ install(battle, unit) { permBuff(battle, unit, 'surtr:magma', { resIgnoreFlat: num(t0.magic_resist_penetrate_fixed) }); } },
{ install(battle, unit) { // 余烬
const wait = num(t1['surtr_t_2[withdraw].interval'], 8);
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.ember = false; }, { owner: unit });
battle.on('fatal', (c) => {
if (c.unit !== unit || c.prevented) return;
c.prevented = true;
if (unit.mem.ember) return;
unit.mem.ember = true;
const dep = unit.deploySeq;
battle.fx('ember', { x: unit.x, y: unit.y, id: unit.id });
battle.after(wait, () => { if (unit.alive && unit.deploySeq === dep) battle.retreat(unit, { reason: 'retreat' }); }, { owner: unit });
}, { owner: unit, priority: -60 });
} },
],
install(battle, unit) {
const aspd = num(tb.attack_speed);
if (aspd) {
whileOn(battle, unit, 0.2, () => {
if (!unit.blocking.length) battle.addBuff(unit, { key: 'surtr:module', duration: 0.3, mods: { aspd } });
else battle.removeBuff(unit, 'surtr:module');
});
}
const fragile = num(tb.damage_scale, 1) - 1; // AFT-Y: 自身阻挡的敌人受到10%的法术脆弱效果
if (fragile > 0) {
whileOn(battle, unit, 0.2, () => {
for (const e of unit.blocking) if (e.alive) battle.applyStatus(e, 'artsFragile', { duration: 0.3, value: fragile, source: unit });
});
}
if (sid === 'skchr_surtr_2') { // S2: 仅攻击到一个敌人时对其攻击力提升至140%
const solo = num(bb['attack@surtr_s_2[critical].atk_scale'], 1);
battle.on('beforeAttack', (c) => { if (c.attacker === unit) unit.mem.surtrSolo = !!unit.skill?.active && c.targets.filter((t) => t && t.alive).length === 1; }, { owner: unit, priority: -100 });
battle.on('hit', (c) => {
if (c.source === unit && unit.mem.surtrSolo && unit.skill?.active && c.dmg.isAttack && !c.dmg.isSplash) c.dmg.amount *= solo;
}, { owner: unit });
}
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 号角 — S3 终极防线 (24 s in total, a two-segment gauge — PRTS 备注: "技能进行到一半时触发额外效果"): ATK +, BAT −1.2 s
// for the first 12 s, then 过载 for the remaining damage_duration (12) s: ATK changes to +50 %, HP loss ramping to 12 %
// max HP/s at the end. T1 军事要塞: all Defenders ATK +20 % while she is on the field.
// T2 血战: once per deployment, lethal damage → full heal, max HP −50 %, ASPD +18, DEF +18 %.
// Module (elite): ×1.1 vs blocked enemies.
// S1 照明榴弹 (instant / 2 charges elite, 自动触发 ⇒ DEFAULT): next attack atk_scale × ATK; a ranged (unblocked) one also
// splashes projectile_range and lights the impact for projectile_delay_time s (enemies within projectile_range lose
// 隐匿). S2 暴风号令 (ammo 10): every attack attack@s2.atk_scale × ATK phys splash; 过载 for the second half of the
// ammo [ASSUMED like S3]: + attack@s2.magic_atk_scale × ATK arts to every enemy hit (manual close never happens in the
// auto battle). Module FOR-Y (elite): ASPD +10 while not blocking.
// S2 and S3 cast with an enemy in range (data DEFAULT, rawRule TAKE_DAMAGE): the owner's deliberate deviation from the
// official 重装 TAKE_DAMAGE row (DESIGN §21.29, tools/build-data.mjs TRIGGER_DEVIATIONS).
chess_char_5_08_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1);
const sid = selectedId(chess, def);
const s2Ammo = Math.max(1, num(bb['attack@s2.trigger_time'], 10));
const total = Math.max(0.1, num(def?.skill?.duration, num(chess?.skill?.duration, 24)));
const ovAtk = num(bb['horn_s_3[overload_start].atk'], num(bb.atk));
const ovIv = Math.max(0.05, num(bb['horn_s_3[overload_start].interval'], 0.2));
const ovPeak = num(bb['horn_s_3[overload_start].hp_ratio']);
const ovRamp = Math.max(0.1, Math.min(total, num(bb['horn_s_3[overload_start].damage_duration'], total / 2)));
const main = total - ovRamp;
const blockedMul = num(tb.atk_scale);
return {
skills: lazySkills({
skchr_horn_1: () => ({
kind: instantKind(chess, def), // 自动触发 "下次攻击": the data rule DEFAULT (an AUTO skill takes no 技能策略)
attack: {
atkScale: num(bb.atk_scale, 1),
onHit({ battle, unit, x, y }) {
if (!unit.mem.hornFlare) return;
unit.mem.hornFlare = false;
(unit.mem.flares ??= []).push({ x, y, until: battle.time + num(bb.projectile_delay_time, 6) });
battle.fx('zone', { x, y, id: unit.id, r: num(bb.projectile_range, 1.7), duration: num(bb.projectile_delay_time, 6) });
},
},
}),
skchr_horn_2: () => ({
kind: 'ammo', ammo: s2Ammo,
attack: { atkScale: num(bb['attack@s2.atk_scale'], 1) },
onStart({ unit }) { unit.mem.hornS2Over = false; },
onEnd({ unit }) { unit.mem.hornS2Over = false; },
}),
}),
skill: {
kind: 'duration', duration: total,
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
onStart({ unit }) { unit.mem.hornT = 0; unit.mem.hornAcc = 0; unit.mem.overload = false; },
onTick({ battle, unit, dt }) {
unit.mem.hornT += dt;
if (!unit.mem.overload && unit.mem.hornT >= main - 1e-9) {
unit.mem.overload = true;
const delta = ovAtk - num(bb.atk);
battle.addBuff(unit, { key: 'horn:overload', visible: true, mods: mods({ atkPct: delta }) });
battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id });
}
if (!unit.mem.overload || !(ovPeak > 0)) return;
unit.mem.hornAcc += dt;
while (unit.mem.hornAcc >= ovIv && unit.alive) {
unit.mem.hornAcc -= ovIv;
const rate = ovPeak * Math.min(1, (unit.mem.hornT - main) / ovRamp);
if (rate > 0) battle.loseHp(unit, unit.s.maxHp * rate * ovIv, { source: unit, silent: true });
}
},
onEnd({ battle, unit }) { unit.mem.overload = false; battle.removeBuff(unit, 'horn:overload'); },
},
trait: blockedMul > 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedMul : 1) } : undefined,
talents: [
{ install(battle, unit) { // 军事要塞
const atk = num(t0.atk);
if (!atk) return;
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.allies()) if (isOp(a) && a.def?.profession === 'TANK') battle.addBuff(a, { key: 'horn:fortress', duration: AURA_DUR, mods: { atkPct: atk } });
});
} },
{ install(battle, unit) { // 血战
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.bloodBattle = false; }, { owner: unit });
battle.on('fatal', (c) => {
if (c.unit !== unit || c.prevented || unit.mem.bloodBattle) return;
c.prevented = true;
unit.mem.bloodBattle = true;
battle.addBuff(unit, { key: 'horn:bloodBattle', visible: true, mods: mods({ hpMul: 1 - num(t1.max_hp), aspd: num(t1.attack_speed), defPct: num(t1.def) }) });
unit.hp = Math.max(1, unit.hp);
battle.heal(unit, unit, unit.s.maxHp * num(t1.hp_ratio, 1), { self: true });
battle.fx('bloodBattle', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit, priority: -40 });
} },
],
install(battle, unit) {
const aspd = num(tm.attack_speed); // FOR-Y: 不阻挡敌人时…攻击速度+10
if (aspd) whileOn(battle, unit, 0.2, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'horn:moduleY', duration: 0.3, mods: { aspd } }); });
if (sid === 'skchr_horn_1') {
// the pending shot: a ranged (unblocked) attack widens its splash and lights the impact
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !unit.skill?.pending || !c.profile) return;
unit.mem.hornFlare = !c.profile._fortressMelee;
if (unit.mem.hornFlare) c.profile.splashRadius = Math.max(num(c.profile.splashRadius), num(bb.projectile_range, 1.7));
}, { owner: unit, priority: -100 });
battle.every(AURA_IV, () => {
const fl = unit.mem.flares;
if (!fl || !fl.length) return;
unit.mem.flares = fl.filter((f) => f.until > battle.time + 1e-9);
const r = num(bb.projectile_range, 1.7);
for (const f of unit.mem.flares) for (const e of battle.enemies) {
if (e.alive && !e.hidden && bodyInRadius(e, f.x, f.y, r)) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit });
}
}, { owner: unit });
}
if (sid === 'skchr_horn_2') {
const magic = num(bb['attack@s2.magic_atk_scale']);
battle.on('beforeAttack', (c) => { // 过载: the second half of the ammo
if (c.attacker !== unit || !unit.skill?.active || !c.profile) return;
const shot = s2Ammo - num(unit.skill.ammoLeft) + 1;
const over = shot > s2Ammo * HORN_S2_OVERLOAD_AT + 1e-9;
if (over && !unit.mem.hornS2Over) battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id });
unit.mem.hornS2Over = over;
if (!over || !(magic > 0)) return;
// decided at the shot (a shell still in flight when the skill ends keeps it): the effective profile of this
// attack (a per-attack copy while the skill runs) carries the extra arts splash on every enemy it hits
const prev = c.profile.onEachHit;
c.profile.onEachHit = (b, u, victim, hc) => {
if (prev) prev(b, u, victim, hc);
if (victim && victim.alive && victim.side === 'enemy') b.dealDamage(unit, victim, { amount: unit.s.atk * magic, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'hornOverload'] });
};
}, { owner: unit, priority: -100 });
}
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 魔王 — bard (aura heal 10 % ATK/s). S3 编织重构现世 (30 s): skill range, aura 65 %, motes never vanish, other allies in
// range get 鼓舞 = +65 % of her max HP, HP of everyone in range is equalised every 2 s.
// T1 过往尘埃: 3 motes orbit her; one colliding with an operator vanishes and gives it ×1.5 aura healing for 6 s, then
// respawns after 6 s.
// T2 魔王残响: allies take −10 % damage from Sarkaz enemies. Module (elite): ≥2 other ops in range → ATK +8 %.
chess_char_5_09_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1);
const baseRatio = num(tb['attack@atk_to_hp_recovery_ratio'], 0.1);
const skillRatio = num(bb['attack@atk_to_hp_recovery_ratio'], baseRatio);
const inspire = num(bb.max_hp);
const shareIv = Math.max(0.1, num(bb['attack@cetsyr_s_3[cal_hp_ratio].interval'], 2));
const inspireAll = (battle, unit) => {
if (!(inspire > 0)) return;
const hp = unit.s.maxHp * inspire;
for (const a of battle.alliesInGrid(unit)) {
if (a !== unit) battle.addBuff(a, { key: 'cetsyr:inspire', duration: AURA_DUR, visible: true, status: 'inspire', mods: { hpFlat: hp }, data: { src: unit } });
}
};
return {
skill: {
kind: 'duration',
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
onStart({ battle, unit }) {
if (unit.profile) unit.profile.auraRatio = skillRatio;
unit.mem.shareAcc = 0;
unit.mem.inspireAcc = 0;
inspireAll(battle, unit);
battle.fx('healAoe', { x: unit.x, y: unit.y, id: unit.id, r: 2.5 });
},
onTick({ battle, unit, dt }) {
unit.mem.inspireAcc += dt;
if (unit.mem.inspireAcc >= AURA_IV) { unit.mem.inspireAcc = 0; inspireAll(battle, unit); }
unit.mem.shareAcc += dt;
if (unit.mem.shareAcc < shareIv - 1e-9) return;
unit.mem.shareAcc -= shareIv;
const group = battle.alliesInGrid(unit).filter((a) => a.alive && a.kind !== 'device');
let hp = 0, max = 0;
for (const a of group) { hp += a.hp; max += a.s.maxHp; }
if (!(max > 0) || group.length < 2) return;
const ratio = hp / max;
for (const a of group) a.hp = Math.max(1, Math.min(a.s.maxHp, a.s.maxHp * ratio));
battle.fx('hpShare', { x: unit.x, y: unit.y, id: unit.id, ratio: Math.round(ratio * 1000) / 1000 });
},
onEnd({ battle, unit }) {
if (unit.profile) unit.profile.auraRatio = baseRatio;
for (const a of battle.allyUnits) {
const b = a.findBuff('cetsyr:inspire');
if (b && b.data.src === unit) battle.removeBuff(a, b);
}
},
},
talents: [
{ install(battle, unit) { // 过往尘埃: cnt motes orbit her (radius range_radius, dynamic_spd °/s, PRTS hit radius 0.4)
const cnt = Math.max(0, Math.floor(num(t0.cnt, 3))), orbit = num(t0.range_radius, 1.15);
const spd = (num(t0.dynamic_spd, 30) * Math.PI) / 180;
const cd = num(t0.cooldown, 6), dur = num(t0.talent_duration, 6), mul = num(t0['attack@trait_mul'], 1.5);
const motes = new Array(cnt).fill(0); // per orbit slot: the time its mote is back (vanished → respawn after cd)
const reach = orbit + MOTE_HIT_RADIUS + 0.01;
battle.on('deploy', (c) => { if (c.unit === unit) motes.fill(0); }, { owner: unit });
whileOn(battle, unit, AURA_IV, () => {
if (!cnt) return;
const keep = !!unit.skill?.active; // S3: "微尘"不再消失
// "微尘"碰撞友方干员: operators only; a mote never collides with an operator already under a mote effect
const cands = battle.alliesInRadius(unit.x, unit.y, reach, null).filter((a) => a !== unit && isOp(a) && !a.findBuff('cetsyr:mote'));
if (!cands.length) return;
const t = battle.time - num(unit.deployedAt);
for (let k = 0; k < cnt; k++) {
if (motes[k] > battle.time + 1e-9) continue;
const ang = spd * t + (2 * Math.PI * k) / cnt;
// the orbit turns with his direction: angle 0 = straight ahead, π/2 = his left hand
const [fr, fc] = unit.fwd, [lr, lc] = rotateOffset(1, 0, unit.dir);
const mx = unit.x + orbit * (fc * Math.cos(ang) + lc * Math.sin(ang)), my = unit.y + orbit * (fr * Math.cos(ang) + lr * Math.sin(ang));
let hit = null, hd = Infinity;
for (const a of cands) {
if (a.findBuff('cetsyr:mote')) continue;
const d = Math.hypot(a.x - mx, a.y - my);
if (d <= MOTE_HIT_RADIUS + 1e-9 && d < hd) { hd = d; hit = a; }
}
if (!hit) continue;
battle.addBuff(hit, { key: 'cetsyr:mote', duration: dur, visible: true, data: { src: unit, mul } });
if (!keep) motes[k] = battle.time + cd;
battle.fx('mote', { x: hit.x, y: hit.y, id: hit.id });
}
});
battle.on('heal', (c) => {
if (c.source !== unit || !c.opts?.aura) return;
const b = c.target.findBuff('cetsyr:mote');
if (b) c.amount *= num(b.data.mul, 1);
}, { owner: unit });
} },
{ install(battle, unit) { // 魔王残响
const dr = num(t1.damage_resistance);
if (!(dr > 0)) return;
battle.on('hit', (c) => {
if (!c.target || c.target.side !== 'ally' || !c.source || c.source.side !== 'enemy') return;
if (!(c.source.def?.tags || []).includes('sarkaz') || leaderOf(battle, unit) !== unit) return;
c.dmg.mul *= 1 - dr;
}, { owner: unit });
} },
],
install(battle, unit) { // module: ≥ cnt other ops in the (base) range → ATK +8 %
const atk = num(tm.atk), need = num(tm.cnt, 2);
if (!chess?.isGolden || !atk) return;
whileOn(battle, unit, AURA_IV, () => {
const keys = new Set(unit.baseRangeKeys || []);
let n = 0;
for (const a of battle.allies()) if (a !== unit && isOp(a) && keys.has(a.tileR * COLS + a.tileC)) n++;
if (n >= need) battle.addBuff(unit, { key: 'cetsyr:module', duration: AURA_DUR, mods: { atkPct: atk } });
});
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 铃兰 — S3 狐火渺然 (27/29 s): no attacks, skill range, every enemy in range is sluggish, allies in range heal 9/11 %
// ATK per second, T2 ×scale_delta_to_one. T1 技力光环·辅助: Supporters +0.4 SP/s (highest wins).
// T2 画地为牢: sluggish enemies in range also take +20 % damage for the same time. Module (elite): +0.2 SP/s with an
// enemy in range.
chess_char_5_10_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1);
const boost = num(bb.scale_delta_to_one, 1);
const healRatio = num(bb['attack@atk_to_hp_recovery_ratio']);
const fragile = num(t1.damage_scale, 1) - 1;
return {
// S1 全力以赴 (duration): ATK +, ASPD +. S2 儿时的舞乐 (toggle, 持续时间无限): ATK +, 2 targets.
skills: lazySkills({
skchr_lisa_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }),
skchr_lisa_2: () => ({ kind: 'toggle', mods: mods({ atkPct: num(bb.atk) }), targeting: { maxTargets: Math.max(1, num(bb['attack@max_target'], 2)) } }),
}),
skill: {
kind: 'duration',
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
attack: { noAttack: true },
onStart({ unit }) { unit.mem.foxSlow = AURA_IV; unit.mem.foxHeal = 0; },
onTick({ battle, unit, dt }) {
unit.mem.foxSlow += dt;
if (unit.mem.foxSlow >= AURA_IV) {
unit.mem.foxSlow = 0;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) battle.applyStatus(e, 'sluggish', { duration: AURA_DUR, source: unit });
}
unit.mem.foxHeal += dt;
if (unit.mem.foxHeal >= 1) {
unit.mem.foxHeal -= 1;
if (!(healRatio > 0)) return;
for (const a of battle.alliesInGrid(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, unit.s.atk * healRatio, { aura: true });
}
},
},
talents: [
{ install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec), (a) => isOp(a) && a.def?.profession === 'SUPPORT'); } },
{ install(battle, unit) { // 画地为牢 — PRTS 备注: an aura; every enemy in her range that is 停顿 (whoever caused it)
// carries the talent's 脆弱 while it stays 停顿 and in range. It is the standard 脆弱 status (same-name: highest wins).
if (!(fragile > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
const v = fragile * (unit.skill?.active ? boost : 1);
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) {
if (e.alive && e.findBuff('sluggish')) battle.applyStatus(e, 'fragile', { duration: AURA_DUR, value: v, source: unit });
}
});
} },
],
install(battle, unit) { // module
const sp = num(tm.sp_recovery_per_sec);
if (!chess?.isGolden || !(sp > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) battle.addBuff(unit, { key: 'lisa:module', duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
});
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 塞雷娅 — S2 药物配置 (instant, time SP): heals every ally in the skill range for heal_scale × ATK (cast when one is
// injured). T1 莱茵充能护服: every 20 s on the field ATK +5 % / DEF +4 % (×5). T2 精神回复: +1 SP to every ally she heals.
// Module (elite): heals on allies below 50 % ×1.15.
// S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack when an ally of the skill area 周围 is at ≤ half HP):
// that attack is instead a heal of the lowest such ally for heal_scale × ATK.
// S3 钙质化 (duration; the 重装 strategy TAKE_DAMAGE): allies in the skill area heal
// attack@heal_scale × ATK per second; enemies there take arts ×demkni_s_3.damage_scale and move −60 %.
// Module GUA-Y (elite): damage taken −15 %.
chess_char_5_11_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
const sid = selectedId(chess, def);
const grid = skillGrid(chess, def) ?? [[0, 0]];
const zone = (battle, unit) => {
const at = unit.tileR * COLS + unit.tileC;
if (unit.mem.sariaAt !== at) { unit.mem.sariaAt = at; unit.mem.sariaKeys = new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)); }
const keys = unit.mem.sariaKeys;
return battle.allies().filter((a) => keys.has(a.tileR * COLS + a.tileC) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal)));
};
const healable = (a, unit) => a.kind !== 'device' && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal));
return {
skills: lazySkills({
skchr_demkni_1: () => ({
// 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次
// 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic
// rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮,
// no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack)
kind: instantKind(chess, def),
trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP },
targeting: { rangeGrid: grid },
attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(bb.heal_scale, 1), projectile: 'none' },
onHit({ battle, target }) { if (target) battle.fx('healAoe', { x: target.x, y: target.y, id: target.id, r: 0.5 }); },
}),
skchr_demkni_3: () => ({
kind: 'duration',
onStart({ unit }) { unit.mem.calcAcc = 0; unit.mem.calcAura = AURA_IV; },
onTick({ battle, unit, dt }) {
unit.mem.calcAura += dt;
if (unit.mem.calcAura >= AURA_IV - 1e-9) {
unit.mem.calcAura = 0;
const m = { artsTakenMul: num(bb['demkni_s_3.damage_scale'], 1), moveMul: Math.max(0, 1 + num(bb['demkni_s_3.move_speed'])) };
for (const e of battle.unitsInGrid(unit, grid, { side: 'enemy' })) battle.addBuff(e, { key: 'saria:calcify', duration: AURA_DUR, visible: true, mods: m });
}
unit.mem.calcAcc += dt;
if (unit.mem.calcAcc < 1 - 1e-9) return;
unit.mem.calcAcc -= 1;
const amt = unit.s.atk * num(bb['attack@heal_scale']);
if (amt > 0) for (const a of battle.unitsInGrid(unit, grid, { side: 'ally' })) if (healable(a, unit) && a.hp < a.s.maxHp - 1e-6) battle.heal(unit, a, amt);
},
}),
}),
skill: {
kind: 'instant', heal: true, trigger: NEVER,
onStart({ battle, unit }) {
const amt = unit.s.atk * num(bb.heal_scale, 1);
for (const a of zone(battle, unit)) if (a.hp < a.s.maxHp - 1e-6) battle.heal(unit, a, amt);
battle.fx('healAoe', { x: unit.x, y: unit.y, id: unit.id, r: 2.5 });
},
},
talents: [
{ install(battle, unit) { // 莱茵充能护服
const iv = Math.max(1, num(t0.interval, 20)), max = Math.max(1, num(t0.max_stack_cnt, 5));
whileOn(battle, unit, 1, () => {
const n = Math.min(max, Math.floor((battle.time - unit.deployedAt + 1e-6) / iv));
const cur = unit.findBuff('saria:suit');
if (n > 0 && (!cur || cur.stacks !== n)) battle.addBuff(unit, { key: 'saria:suit', stacks: n, maxStacks: max, mods: mods({ atkPct: num(t0.atk), defPct: num(t0.def) }) });
});
} },
{ install(battle, unit) { // 精神回复
const sp = num(t1.sp);
if (!(sp > 0)) return;
battle.on('heal', (c) => {
if (c.source !== unit || c.opts?.regen || !(c.amount > 0) || !c.target.skill) return;
c.target.skill.gainSp(sp, 'talent');
}, { owner: unit, priority: -10 });
} },
],
install(battle, unit) {
// S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured
if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6));
lowHpHealUp(battle, unit, tb);
if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 夕 — S1 工笔入化 (2 charges): next attack 180 % ATK with an expanded splash. T1 化境: a kill by 夕 or 小自在 → ATK +2 %
// (×15). T2 点睛: first attack after deploying summons 小自在 (25 s) on the target's tile (deployable ground).
// Module (elite): 攻击范围扩大.
// S2 泼墨淋漓 (duration): skill range, ATK +, ASPD +, hits every enemy in range (one hit each: no splash on top);
// arts damage ×damage_scale on enemies below hp_ratio. S3 写意胜形 (duration): skill range, BAT +0.4 s, prefers
// unblocked enemies, splash 1.7, ATK +; every attack summons 小自在 on the target's tile (deployable ground) or moves /
// refreshes the one already out (deployLimit 1: "召唤/刷新一个"), 25 s (the 点睛 token duration).
chess_char_5_12_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1);
const sid = selectedId(chess, def);
const tokenId = talentRec(chess, 1)?.tokenKey ?? (chess?.tokens || [])[0] ?? 'token_10015_dusk_drgn';
const tokenDur = num(t1['attack@tokenduration'], 25);
const freeGround = (battle, r, c) => battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c);
// 小自在 expiry follows tk.mem.duskUntil (a refresh pushes it back)
const expire = (battle, tk) => {
if (!tk.alive) return;
const left = num(tk.mem.duskUntil) - battle.time;
if (left > 1e-6) battle.after(left, () => expire(battle, tk), { owner: tk });
else battle.retreat(tk, { reason: 'expired', permanent: true });
};
/** Summon 小自在 on (r, c), or move / refresh hers when one is out. */
const summon = (battle, unit, r, c) => {
const mine = battle.allyUnits.find((x) => x.kind === 'token' && x.ownerUnit === unit && x.defId === tokenId && x.alive && x.deployed);
if (mine) {
if (mine.tileR !== r || mine.tileC !== c) {
const fromX = mine.x, fromY = mine.y;
if (!freeGround(battle, r, c) || !battle.relocate(mine, r, c)) return null;
battle.fx('teleport', { x: mine.x, y: mine.y, id: mine.id, fromX, fromY });
}
mine.mem.duskUntil = battle.time + tokenDur;
return mine;
}
if (!freeGround(battle, r, c)) return null;
const tk = battle.spawnToken(unit, tokenId, r, c);
if (!tk) return null;
tk.mem.duskUntil = battle.time + tokenDur;
battle.after(tokenDur, () => expire(battle, tk), { owner: tk });
battle.fx('summon', { x: tk.x, y: tk.y, id: tk.id, token: tokenId });
return tk;
};
return {
skills: lazySkills({
skchr_dusk_2: () => ({
kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
targeting: skillRange(chess, def, { allInRange: true }),
attack: { splashRadius: 0 },
}),
skchr_dusk_3: () => ({
kind: 'duration', mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
targeting: skillRange(chess, def),
attack: { splashRadius: DUSK_S3_SPLASH },
}),
}),
skill: { kind: 'charges', attack: { atkScale: num(bb.atk_scale, 1), splashRadius: DUSK_SPLASH_RADIUS } },
talents: [
{ install(battle, unit) { // 化境
battle.on('kill', (c) => {
const k = c.killer;
if (!k || c.victim.side !== 'enemy' || !(k === unit || (k.kind === 'token' && k.ownerUnit === unit)) || !on(unit)) return;
battle.addBuff(unit, { key: 'dusk:realm', refresh: 'stack', maxStacks: Math.max(1, num(t0.max_stack_cnt, 15)), mods: { atkPct: num(t0.atk) } });
}, { owner: unit });
} },
{ install(battle, unit) { // 点睛
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.duskSummoned = false; }, { owner: unit });
battle.on('attack', (c) => {
if (c.attacker !== unit || unit.mem.duskSummoned) return;
const t = c.targets.find((x) => x.side === 'enemy');
if (!t) return;
unit.mem.duskSummoned = true;
// "在目标位置(可部署地面)召唤": only on the target's own tile when it is free deployable ground — PRTS 备注:
// otherwise nothing is summoned, and the talent is spent for this deployment either way
summon(battle, unit, Math.round(t.y), Math.round(t.x));
}, { owner: unit });
} },
],
install(battle, unit) {
moduleRangeUp(battle, unit, chess);
if (sid === 'skchr_dusk_2') {
const cut = num(bb.hp_ratio, HALF_HP), mul = num(bb.damage_scale, 1);
battle.on('hit', (c) => {
if (c.source !== unit || !unit.skill?.active || c.dmg.type !== 'arts' || !c.target || c.target.side !== 'enemy') return;
if (c.target.hpRatio < cut) c.dmg.mul *= mul;
}, { owner: unit });
}
if (sid === 'skchr_dusk_3') {
// 优先攻击未阻挡的敌人
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !unit.skill?.active || !c.targets.length || !c.targets[0].blockedBy) return;
const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
sortEnemyTargets(battle, unit, list, null);
const alt = list.find((e) => !e.blockedBy);
if (alt) c.targets = [alt, ...c.targets.slice(1).filter((x) => x !== alt)];
}, { owner: unit, priority: 10 });
battle.on('attack', (c) => {
if (c.attacker !== unit || !unit.skill?.active) return;
const t = c.targets.find((x) => x.side === 'enemy');
if (t) summon(battle, unit, Math.round(t.y), Math.round(t.x));
}, { owner: unit });
}
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 归溟幽灵鲨 — dollkeeper. S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1; afterwards she counts as knocked out
// (→ substitute, or death if already one). T1 拥抱自我: the substitute slows nearby enemies −40 % and deals 40 % ATK
// arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only). T2 阿戈尔的深邃: Abyssal Hunters in the team
// max HP +20 %. Module (elite): substitute ATK +15 %.
// S1 生存的技巧 (duration): swaps HP ratios with the other operator of the skill area (周围) with the lowest HP ratio,
// ATK +. S3 生存的重压 (duration): BAT +1 s, hits every blocked enemy, ATK +, max HP +; an attacked enemy whose HP ratio
// is ≥ hers takes attack@atk_scale_ex × ATK phys more, otherwise she loses attack@hp_ratio of her max HP.
// Module PUM-Y (elite): substitute max HP +20 %.
chess_char_5_13_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
const sid = selectedId(chess, def);
const aroundGrid = chess?.trait?.rangeGrid ?? [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
return {
skills: lazySkills({
skchr_ghost2_1: () => ({
kind: 'duration', mods: mods({ atkPct: num(bb.atk) }),
onStart({ battle, unit }) {
const t = battle.unitsInGrid(unit, skillGrid(chess, def) ?? aroundGrid, { side: 'ally' }).filter((a) => a !== unit && isOp(a) && a.hp > 0)
.sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0];
if (!t) return;
const mine = unit.hpRatio, theirs = t.hpRatio;
unit.hp = Math.max(1, Math.min(unit.s.maxHp, unit.s.maxHp * theirs));
t.hp = Math.max(1, Math.min(t.s.maxHp, t.s.maxHp * mine));
battle.fx('hpShare', { x: t.x, y: t.y, id: unit.id, to: t.id });
},
}),
skchr_ghost2_3: () => ({
kind: 'duration',
mods: mods({ atkPct: num(bb.atk), hpPct: num(bb.max_hp), batPct: batPct(bb.base_attack_time, chess) }),
attack: {
hitAllBlocked: true,
onEachHit({ battle, unit, target, kind }) {
if (kind !== 'main' || !target || target.side !== 'enemy') return;
if (unit.mem.ghostHeavy?.get(target)) {
if (target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb['attack@atk_scale_ex']), type: 'phys', isSkill: true, tags: ['skill', 'ghost2Weight'] });
} else if (unit.alive) battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio']), { source: unit });
},
},
}),
}),
skill: {
kind: 'duration',
mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
onEnd({ battle, unit, reason }) {
if (reason === 'death' || !unit.alive || !unit.deployed) return;
battle.fx('knockout', { x: unit.x, y: unit.y, id: unit.id });
battle.loseHp(unit, unit.hp + 1, { source: unit });
},
},
talents: [
{ install(battle, unit) { // 拥抱自我
const slow = num(t0.move_speed), scale = num(t0.atk_scale);
whileOn(battle, unit, AURA_IV, () => {
if (!unit.trait.doll || !slow) return;
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } });
});
whileOn(battle, unit, 1, () => {
if (!unit.trait.doll || !(scale > 0)) return;
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] });
});
} },
{ install(battle, unit) { // 阿戈尔的深邃
const hp = num(t1.max_hp);
if (!hp) return;
battle.on('battleStart', () => {
for (const a of battle.getPlayer(unit.ownerId)?.units ?? []) if (isOp(a) && isAbyssal(a)) battle.addBuff(a, { key: 'ghost2:abyss', mods: { hpPct: hp }, persist: true, allowDead: true });
}, { owner: unit });
} },
],
install(battle, unit) {
// S2: HP never below 1 while it runs
if (!sid || sid === 'skchr_ghost2_2') battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 });
if (sid === 'skchr_ghost2_3') { // S3: 生命比例高于或等于自身 — judged when the attack starts
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !unit.skill?.active) return;
unit.mem.ghostHeavy = new Map(c.targets.filter(Boolean).map((t) => [t, t.hpRatio >= unit.hpRatio - 1e-9]));
}, { owner: unit, priority: -100 });
}
const hpUp = num(tb.max_hp); // PUM-Y: 替身…生命值提升
if (hpUp) whileOn(battle, unit, 0.2, () => { if (unit.trait.doll) battle.addBuff(unit, { key: 'ghost2:moduleY', duration: 0.3, mods: { hpPct: hpUp } }); });
const atk = num(tb.atk);
if (atk) {
whileOn(battle, unit, 0.2, () => {
if (unit.trait.doll) battle.addBuff(unit, { key: 'ghost2:module', duration: 0.3, mods: { atkPct: atk } });
});
}
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 凛御银灰 — S2 御敌的锋锐 (2 charges; PRTS 备注 "可对空"): the 6 front-most enemies in the skill range take 260 % ATK
// phys, cold + reveal for 4 s; the waiting (knocked-out) op nearest to 风雪之眼 by cost gets −11 redeploy cost (right
// side guard/caster/sniper first); waiting ops left of the eye (cost < eye cost) cast the AoE with their own ATK when
// they redeploy (≤2).
// T1 开放性开局: ops left of the eye and himself: +4 SP at deployment, redeploy time −20 %.
// T2 雪境先驱: Kjerag ops freeze-immune, DEF +60, 1.5 % max HP/s regen; doubled after 15 s on the field.
// The eye follows the selected skill (its overrideTokenKey: S1 eagle1 cost 16, S2 eagle2 14, S3 eagle3 19).
// S1 周旋的谋略 (instant): +cost DP at once; the waiting op nearest to the eye gets −[deck].cost redeploy cost (same pick
// as S2) and a barrier of [deck].shield × his max HP when it redeploys.
// S3 变革已至 (duration 48 s): skill range; enemies in it lose 隐匿; every attack hits every enemy of the range on the
// target's line (his facing) for bird_atk_scale × ATK phys + 脆弱 (damage_scale − 1, weak[limit] s [ASSUMED duration]);
// +[start_cost] DP at once, then +[cost].cost DP every [cost].interval s; the first activation of the battle swaps the
// base costs of the most (guard/caster/sniper first) and least expensive waiting ops. "风雪之眼变为可部署" is not
// modelled (nobody deploys by hand in the auto battle).
chess_char_5_14_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1);
const grid = skillGrid(chess, def) ?? [[0, 0], [0, 1]];
const eyeId = chess?.skill?.overrideTokenKey ?? talentRec(chess, 0)?.tokenKey ?? 'token_10057_svash2_eagle2';
const n = Math.max(1, num(bb.max_target, 6)), maxStacks = Math.max(1, num(bb.max_stack_cnt, 2));
const PREF = ['WARRIOR', 'CASTER', 'SNIPER'];
/** Waiting (knocked-out) ops of his player split around the eye cost, and the one nearest to the eye (right side first). */
const waitingArea = (battle, unit) => {
const eye = unit.mem.eyeCost ?? 0;
const waiting = battle.allyUnits.filter((a) => isOp(a) && a !== unit && a.ownerId === unit.ownerId && !a.alive && !a.removed);
const right = waiting.filter((a) => a.base.cost >= eye).sort((a, b) => a.base.cost - b.base.cost || a.id - b.id);
const left = waiting.filter((a) => a.base.cost < eye).sort((a, b) => b.base.cost - a.base.cost || a.id - b.id);
const pick = right.find((a) => PREF.includes(a.def?.profession)) ?? right[0] ?? left[0] ?? null;
return { waiting, right, left, pick };
};
const cutCost = (a, v) => {
if (!a || !(v > 0)) return;
a.mem.svashCostBase ??= a.base.cost;
a.base.cost = Math.max(0, a.base.cost - v);
};
const slash = (battle, caster, atk) => {
// PRTS 备注: the slash ignores 隐匿 and hits flying enemies (and its reveal makes them targetable afterwards)
for (const e of enemiesInGrid(battle, caster, grid, n, { ignoreStealth: true })) {
battle.dealDamage(caster, e, { amount: atk * num(bb.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'svash2'] });
if (!e.alive) continue;
battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: caster });
battle.applyStatus(e, 'reveal', { duration: num(bb.cold), source: caster });
}
battle.fx('aoe', { x: caster.x + caster.fwd[1] * 1.5, y: caster.y + caster.fwd[0] * 1.5, id: caster.id, r: 2, skill: 'svash2' });
};
return {
skills: lazySkills({
// (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1)
skchr_svash2_1: () => ({
kind: instantKind(chess, def),
trigger: 'SP_FULL',
onStart({ battle, unit }) {
battle.addDp(unit.ownerId, num(bb.cost));
const { pick } = waitingArea(battle, unit);
cutCost(pick, num(bb['svash2_s_1[deck].cost']));
if (pick) pick.mem.svashShield = unit.s.maxHp * num(bb['svash2_s_1[deck].shield']);
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'svash2Plan' });
},
}),
skchr_svash2_3: () => ({
kind: 'duration',
targeting: skillRange(chess, def),
attack: {
atkScale: num(bb.bird_atk_scale, 1),
onEachHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy') return;
const v = num(bb.damage_scale, 1) - 1;
if (v > 0) battle.applyStatus(target, 'fragile', { duration: num(bb['weak[limit]'], 2), value: v, source: unit });
},
},
onStart({ battle, unit }) {
battle.addDp(unit.ownerId, num(bb['svash2_s_3[start_cost].cost']));
unit.mem.svashDpAcc = 0;
unit.mem.svashReveal = AURA_IV;
if (!unit.mem.svashSwapped) { // 首次开启时交换…基础部署费用
unit.mem.svashSwapped = true;
const { waiting } = waitingArea(battle, unit);
const baseCost = (a) => a.mem.svashCostBase ?? a.base.cost;
const byCost = waiting.slice().sort((a, b) => baseCost(b) - baseCost(a) || a.id - b.id);
const hi = byCost.find((a) => PREF.includes(a.def?.profession)) ?? byCost[0];
const lo = byCost.filter((a) => a !== hi).pop();
if (hi && lo && baseCost(hi) !== baseCost(lo)) {
const ch = baseCost(hi), cl = baseCost(lo);
for (const [a, v] of [[hi, cl], [lo, ch]]) {
if (a.mem.svashCostBase != null) { a.base.cost = Math.max(0, v - (a.mem.svashCostBase - a.base.cost)); a.mem.svashCostBase = v; } else a.base.cost = v;
}
}
}
},
onTick({ battle, unit, dt }) {
const iv = Math.max(0.1, num(bb['svash2_s_3[cost].interval'], 2));
unit.mem.svashDpAcc += dt;
while (unit.mem.svashDpAcc >= iv - 1e-9) { unit.mem.svashDpAcc -= iv; battle.addDp(unit.ownerId, num(bb['svash2_s_3[cost].cost'], 1)); }
unit.mem.svashReveal += dt;
if (unit.mem.svashReveal < AURA_IV - 1e-9) return;
unit.mem.svashReveal = 0;
for (const e of enemiesInGrid(battle, unit, grid, 0, { ignoreStealth: true })) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit });
},
}),
}),
skill: {
kind: 'charges', // DEFAULT (data): cast right before an attack on an enemy in his initial (melee) range
onStart({ battle, unit }) {
slash(battle, unit, unit.s.atk);
// cost cut: nearest to the eye, right side guard/caster/sniper preferred
const { left, pick } = waitingArea(battle, unit);
cutCost(pick, num(bb.cost));
for (const a of left) a.mem.svashCasts = Math.min(maxStacks, num(a.mem.svashCasts) + 1);
},
},
talents: [
{ install(battle, unit) { // 开放性开局
const sp = num(t0.sp), mul = num(t0.respawn_time, 1);
const left = (a) => isOp(a) && a.ownerId === unit.ownerId && (a === unit || a.base.cost < num(unit.mem.eyeCost));
battle.on('battleStart', () => {
if (!on(unit) || !(sp > 0)) return;
for (const a of battle.allies(unit.ownerId)) if (left(a) && a.skill) a.skill.gainSp(sp, 'talent');
}, { owner: unit });
battle.on('deploy', (c) => {
const a = c.unit;
if (c.initial || !left(a) || !(a === unit || on(unit))) return;
if (sp > 0 && a.skill) a.skill.gainSp(sp, 'talent');
}, { owner: unit });
battle.on('death', (c) => {
const a = c.unit;
if (!left(a) || a.removed || !(a === unit || on(unit)) || !Number.isFinite(a.respawnAt) || !(mul > 0 && mul < 1)) return;
a.respawnAt = a.deathAt + (a.respawnAt - a.deathAt) * mul;
}, { owner: unit });
} },
{ install(battle, unit) { // 雪境先驱
const defv = num(t1.def), regen = num(t1.hp_recovery_per_sec_by_max_hp_ratio), after = num(t1.interval, 15);
whileOn(battle, unit, AURA_IV, () => {
const m = battle.time - unit.deployedAt >= after - 1e-9 ? 2 : 1;
for (const a of battle.allies()) {
if (isOp(a) && isKjerag(a)) battle.addBuff(a, { key: 'svash2:snow', duration: AURA_DUR, mods: mods({ defFlat: defv * m, hpRegenRatio: regen * m }) });
}
});
battle.on('beforeStatus', (c) => {
if (c.status === 'freeze' && c.target.side === 'ally' && c.target.findBuff('svash2:snow')) c.cancel = true;
}, { owner: unit });
} },
],
install(battle, unit) {
const tok = battle.tokenDef(eyeId, unit); // the owner's skill variant (DESIGN §16)
unit.mem.eyeCost = num(tok?.stats?.cost, 14);
if (selectedId(chess, def) === 'skchr_svash2_3') {
// S3: every attack hits the enemies of the skill range on the target's line (his facing frame): the line of
// the target's position; another enemy is on it when its body is (a huge one: any tile — body.js)
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !unit.skill?.active || !c.targets.length) return;
const main = c.targets[0];
const lat = (r, cc) => toLocal(r - unit.tileR, cc - unit.tileC, unit.dir)[0];
const l0 = lat(Math.round(main.y), Math.round(main.x));
const onLine = (e) => bodyKeys(e).some((k) => lat(Math.floor(k / COLS), k % COLS) === l0);
const line = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).filter((e) => e !== main && onLine(e));
if (line.length) c.targets = [main, ...line];
}, { owner: unit, priority: 10 });
}
// waiting-area effects resolve when the op (re)deploys
battle.on('deploy', (c) => {
const a = c.unit;
if (!isOp(a) || a.ownerId !== unit.ownerId) return;
if (a.mem.svashCostBase != null) { a.base.cost = a.mem.svashCostBase; a.mem.svashCostBase = null; }
if (num(a.mem.svashShield) > 0 && !c.initial) { // S1: 部署后获得…屏障
battle.addBuff(a, { key: 'svash2:barrier', shield: a.mem.svashShield, visible: true });
a.mem.svashShield = 0;
}
const casts = num(a.mem.svashCasts);
if (casts > 0 && !c.initial) {
a.mem.svashCasts = 0;
for (let i = 0; i < casts; i++) slash(battle, a, a.s.atk);
}
}, { owner: unit });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 引星棘刺 — S2 解构涌潮 (instant / 2 charges elite): throws an alchemy unit at the target: for 12/15 s ground enemies
// around it get healing ×0.5, take 120/140 % ATK arts per second, allies heal 12/15 % ATK per second; the unit drifts
// along the throw direction and its radius grows. T1 心相: ATK +10 %, +3 s when another op is in range.
// T2 视界: allies ASPD +5, enemies −5 (doubled on straight roads ≥ 6 tiles). Module (elite): +0.1 SP/s with a unit out.
// S1 度算浪波 (instant): an alchemy unit thrown at the ally in her range with the lowest HP ratio: for
// projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and heal
// hp_recovery_per_sec_ratio × ATK per second. S3 “我的海疆”: passive — her range is the skill range; active
// (instant) — alchemy units on the max_target_token operators with the lowest block count: for projectile_delay_time s
// the enemies around each of them (RING1, following it) get ATK/DEF/RES −, one strongest instance (不叠加), and take
// atk_scale × ATK arts per second, everything ramping +per_interval each `interval` s up to max_stack_cnt steps.
chess_char_5_15_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1);
const sid = selectedId(chess, def);
const baseDur = num(bb.projectile_delay_time, num(bb.remaining_time, 12));
const r0 = num(bb.projectile_range, 1.1), grow = num(bb.value), speed = num(bb.projectile_move_speed);
const healMul = Math.max(0, num(bb.heal_scale, 1));
const extend = (battle, unit) => (battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0);
// S3 ramp: value after `steps` intervals, clamped towards the max ("效果逐渐提升…15秒后达到最大")
const ramp = (base, per, max, steps) => {
const v = num(base) + num(per) * steps;
return num(max) < num(base) ? Math.max(num(max), v) : Math.min(num(max, v), v);
};
/** One tick (AURA_IV) of an S1 guard zone / S3 sea zone. */
const tickSpecial = (battle, unit, z, seaHits) => {
if (z.type === 'guard') {
const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1;
const allies = battle.alliesFor(unit).filter(inZone);
for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
if (z.acc >= 1 - 1e-9) {
z.acc -= 1;
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio);
if (heal > 0) for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
}
return;
}
// sea: follows its operator while it stands on the field; the first burn lands with the unit, then one per
// `interval` (so the 15th step — the max — is reached 15 s after the throw, for the debuff and the damage alike)
if (z.anchor && z.anchor.alive && z.anchor.deployed) { z.x = z.anchor.x; z.y = z.anchor.y; }
const iv = Math.max(0.1, num(bb.interval, 1));
const steps = Math.min(Math.max(0, num(bb.max_stack_cnt, 15)), Math.floor((z.t - AURA_IV + 1e-9) / iv));
const foes = battle.foesInRadius(z.x, z.y, RING1);
for (const e of foes) seaHits.set(e, Math.max(seaHits.get(e) ?? -1, steps));
if (z.acc >= iv - 1e-9) {
z.acc -= iv;
const scale = ramp(bb.atk_scale, bb.atk_scale_per_interval, bb.max_atk_scale, steps);
z.burn = { foes, scale }; // dealt after the debuffs of this tick are on
}
};
return {
skills: lazySkills({
skchr_thorn2_1: () => ({
kind: instantKind(chess, def),
onStart({ battle, unit }) {
const t = battle.alliesInGrid(unit).filter((a) => a.hp > 0).sort((a, b) => a.hpRatio - b.hpRatio || b.blocking.length - a.blocking.length || dist(a, unit) - dist(b, unit) || a.id - b.id)[0];
if (!t) return;
const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit) };
(unit.mem.zones ??= []).push(z);
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: RING1, duration: z.dur });
},
}),
skchr_thorn2_3: () => ({
kind: instantKind(chess, def),
onStart({ battle, unit }) {
const ops = battle.allies(unit.ownerId).filter((a) => isOp(a) && a.hp > 0)
.sort((a, b) => a.s.blockCnt - b.s.blockCnt || dist(a, unit) - dist(b, unit) || a.id - b.id)
.slice(0, Math.max(1, num(bb.max_target_token, 3)));
const dur = num(bb.projectile_delay_time, 19) + extend(battle, unit);
for (const a of ops) {
(unit.mem.zones ??= []).push({ type: 'sea', anchor: a, x: a.x, y: a.y, t: 0, acc: Math.max(0.1, num(bb.interval, 1)) - AURA_IV, dur });
battle.fx('zone', { x: a.x, y: a.y, id: unit.id, r: RING1, duration: dur });
}
},
}),
}),
skill: {
kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant',
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
sortEnemyTargets(battle, unit, list, null);
let t = list.find((e) => !e.isFlying) ?? list[0];
if (!t) {
// PRTS 备注: no enemy in range → thrown at the farthest tile straight ahead inside her range
let best = null;
for (const k of unit.rangeKeys || []) {
const r = Math.floor(k / COLS), c = k % COLS;
if (!battle.grid.inRect(r, c)) continue;
const [lat, d] = toLocal(r - unit.tileR, c - unit.tileC, unit.dir);
if (lat !== 0) continue;
if (d > 0 && (!best || d > best.d)) best = { d, x: c, y: r };
}
if (!best) return;
t = best;
}
const extra = battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0;
// it drifts away from her deployment tile ("移动方向始终为远离棘刺部署位置中心的方向")
const dx = t.x - unit.x, dy = t.y - unit.y, len = Math.hypot(dx, dy) || 1;
const z = { x: t.x, y: t.y, vx: (dx / len) * speed, vy: (dy / len) * speed, t: 0, acc: 0, dur: baseDur + extra };
(unit.mem.zones ??= []).push(z);
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: r0, duration: z.dur });
},
},
talents: [
{ install(battle, unit) { permBuff(battle, unit, 'thorn2:mind', { atkPct: num(t0.atk) }); } },
{ install(battle, unit) { // 视界
const cnt = num(t1.cnt, 6);
const ally = num(t1.attack_speed_ally), allyX = num(t1.attack_speed_ally_extra), foe = num(t1.attack_speed_enemy), foeX = num(t1.attack_speed_enemy_extra);
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.alliesFor(unit)) {
const v = ally + (straightRun(battle, a.tileR, a.tileC) >= cnt ? allyX : 0);
if (v) battle.addBuff(a, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } });
}
for (const e of battle.enemies) {
if (!e.alive || e.hidden) continue;
const v = foe + (straightRun(battle, Math.round(e.y), Math.round(e.x)) >= cnt ? foeX : 0);
if (v) battle.addBuff(e, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } });
}
});
} },
],
install(battle, unit) {
const sp = num(tm.sp_recovery_per_sec);
if (sid === 'skchr_thorn2_3') { // S3 被动效果:攻击范围扩大 (her own range, so also the DEFAULT trigger range)
const g = skillGrid(chess, def);
if (g) { unit.rangeGrid = g; battle.refreshRange(unit); }
}
// alchemy units keep working after she falls (they are already thrown)
battle.every(AURA_IV, () => {
const zones = unit.mem.zones;
if (!zones || !zones.length) return;
const R = battle.rect;
const seaHits = new Map();
for (const z of zones) {
z.t += AURA_IV;
z.acc += AURA_IV;
if (z.type) { tickSpecial(battle, unit, z, seaHits); continue; }
z.x = Math.max(R.c0, Math.min(R.c1, z.x + z.vx * AURA_IV));
z.y = Math.max(R.r0, Math.min(R.r1, z.y + z.vy * AURA_IV));
const r = r0 + grow * z.t;
const foes = battle.foesInRadius(z.x, z.y, r).filter((e) => !e.isFlying);
for (const e of foes) battle.addBuff(e, { key: 'thorn2:rot', duration: AURA_DUR, mods: { healingTakenMul: healMul } });
if (z.acc >= 1 - 1e-9) {
z.acc -= 1;
for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] });
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr);
if (heal > 0) for (const a of battle.alliesInRadius(z.x, z.y, r, null)) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t) });
}
}
for (const [e, steps] of seaHits) { // S3 debuff: 不叠加 — the strongest zone wins
if (!e.alive) continue;
battle.addBuff(e, { key: 'thorn2:sea', duration: AURA_DUR, visible: true, mods: mods({
atkPct: ramp(bb.atk, bb.atk_per_interval, bb.max_atk, steps),
defPct: ramp(bb.def, bb.def_per_interval, bb.max_def, steps),
resMul: 1 + ramp(bb.magic_resistance, bb.magic_resistance_per_interval, bb.max_magic_resistance, steps),
}) });
}
for (const z of zones) {
if (!z.burn) continue;
for (const e of z.burn.foes) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * z.burn.scale, type: 'arts', isSkill: true, tags: ['skill', 'mySea'] });
z.burn = null;
}
unit.mem.zones = zones.filter((z) => z.t < z.dur - 1e-9);
if (chess?.isGolden && sp > 0 && on(unit) && unit.mem.zones.length) battle.addBuff(unit, { key: 'thorn2:module', duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
}, { owner: unit });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 白面鸮 — ringhealer. S2 脑啡肽 (33/36 s): skill range, BAT −1.8/−1.9 s. T1 技力光环: all allies +0.3 SP/s (highest
// wins). Module (elite): 攻击范围扩大.
chess_char_5_16_a: (bb, chess, def) => {
const t0 = talent(chess, 0);
return {
skill: {
kind: 'duration', heal: true,
mods: mods({ batPct: batPct(bb.base_attack_time, chess) }),
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
},
talents: [{ install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec), () => true); } }],
install(battle, unit) { moduleRangeUp(battle, unit, chess); },
};
},
// ---------------------------------------------------------------------------------------------------------------
// 山 — S2 横扫架势 (stance toggle): DEF −30 %, range = own tile, ATK +35 %, block +1, hits every blocked enemy, 4 % max HP/s
// regen. T1 巨力重拳: 20 % chance ×1.6 ATK and target ATK −15 % for 3 s. T2 强壮肉体: DEF +10 %, 15 % phys dodge.
// Module (elite): ASPD +10 above 50 % HP.
// S1 左勾扫拳 (instant, attack SP): next attack atk_scale × ATK on max_target enemies. S3 震地碎岩击 (duration): skill
// range, BAT +0.7 s, ATK +, double hits on up to attack@max_target enemies, each pushed (中等力度); T1 chance →
// talent@prob. Module FGT-X (elite): 15 % physical dodge.
chess_char_5_17_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
const sid = selectedId(chess, def);
const critScale = num(t0.atk_scale, 1);
const critProb = (unit) => (sid === 'skchr_f12yin_3' && unit.skill?.active ? num(bb['talent@prob'], num(t0.prob)) : num(t0.prob));
return {
skills: lazySkills({
skchr_f12yin_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), maxTargets: Math.max(1, num(bb.max_target, 2)) } }),
skchr_f12yin_3: () => ({
kind: 'duration',
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 3)) }),
attack: {
hits: 2,
onEachHit({ battle, unit, target, kind }) {
if (kind !== 'main' || !target || !target.alive || target.side !== 'enemy') return;
// "中等力度地推动" (PRTS 备注: on the 2nd hit of each attack — onEachHit runs after both): a radial push by
// the official 力度 − 重量 distance (Battle.push)
battle.push(target, num(bb['attack@force'], 1), { from: unit });
},
},
}),
}),
skill: {
kind: 'toggle',
mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }),
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
attack: { hitAllBlocked: true },
},
trait: {
dmgMul: (b, u) => (u.mem.crit ? critScale : 1),
afterHit: (b, u, t) => {
if (!u.mem.crit || !t || !t.alive || !num(t0.atk)) return;
b.addBuff(t, { key: 'f12yin:punch', duration: num(t0.duration, 3), mods: { atkPct: num(t0.atk) }, visible: true, status: 'weaken' });
},
},
talents: [
{ install(battle, unit) { // 巨力重拳: one roll per attack
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit) return;
const p = critProb(unit);
unit.mem.crit = p > 0 && battle.rng.chance(p);
if (unit.mem.crit) battle.fx('crit', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
} },
{ install(battle, unit) { permBuff(battle, unit, 'f12yin:body', { defPct: num(t1.def), dodgePhys: num(t1.prob) }); } },
],
install(battle, unit) {
// FGT-X: 拥有15%的物理闪避 (an own dodge source: rolls independently of 强壮肉体)
if (num(tb.prob) > 0) permBuff(battle, unit, 'f12yin:moduleX', { dodgePhys: num(tb.prob) });
const aspd = num(tb.attack_speed);
if (!aspd) return;
whileOn(battle, unit, 0.2, () => { if (unit.hpRatio > HALF_HP) battle.addBuff(unit, { key: 'f12yin:module', duration: 0.3, mods: { aspd } }); });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 百炼嘉维尔 — S2 链锯强袭 (33/36 s): skill range, ATK/DEF +; hits on unblocked enemies drag them in front of her.
// T1 战地巨斧: ATK/DEF +10 %, +4 % per extra blocked enemy. T2 医学背景: healing received +20 % (+40 % below 50 %).
// Module (elite): ×1.1 vs blocked enemies.
chess_char_5_18_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
const force = num(bb['attack@force'], 1);
const blockedMul = num(tb.atk_scale);
return {
skill: {
kind: 'duration',
mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt) }),
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
attack: {
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy' || target.blockedBy) return;
battle.pullToFront(target, unit, force); // the official 力度 − 重量 pull (Battle.pullToFront)
},
},
},
trait: blockedMul > 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedMul : 1) } : undefined,
talents: [
{ install(battle, unit) { // 战地巨斧
const apply = () => {
const extra = Math.max(0, unit.blocking.length - 1);
if (unit.mem.axeN === extra && unit.findBuff('gvial2:axe')) return;
unit.mem.axeN = extra;
battle.addBuff(unit, { key: 'gvial2:axe', mods: mods({ atkPct: num(t0.atk) + num(t0.atk_add) * extra, defPct: num(t0.def) + num(t0.def_add) * extra }) });
};
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.axeN = -1; apply(); } }, { owner: unit });
whileOn(battle, unit, 0.2, apply);
} },
{ install(battle, unit) { // 医学背景
battle.on('heal', (c) => {
if (c.target !== unit) return;
c.amount *= unit.hpRatio < num(t1.hp_ratio, 0.5) ? num(t1.heal_scale_2, 1) : num(t1.heal_scale_1, 1);
}, { owner: unit });
} },
],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 玛恩纳 — librator (ramp to +200 % ATK while idle). S3 未照耀的荣光 (26 s, CUSTOM_RANGE): skill range, trait ×2, 5
// targets at 125/150 % ATK phys, air units too (PRTS 备注 "※可对空"; player report B4 after 0.1.0: on a flying wave the
// cast hit nothing for 26 s); enemies in range take +10/11 % of his ATK true damage from every Kazimierz attack. Per
// PRTS 备注 the trait bonus drops by 10 % (per_kill_reduce, absolute: +400 % → +390 %) for each enemy knocked out by
// his own attack (or the damage it carries — not 无动于衷's reflection, not a mark another operator's attack set off),
// settled after that attack, never below +0 %. T1 游侠: ×1.1 ATK on attacks (×1.15 and −15 % damage taken with ≥3
// enemies around). T2 无动于衷: taunt +1, Kazimierz ops reflect 15 % of his ATK as true damage when attacked.
// S1 未声张的怒火 (duration, SEARCH): attacks attack@atk_scale × ATK, DEF +. S2 未宽解的悲哀 (duration): skill range,
// BAT +0.3 s, attacks attack@atk_scale × ATK twice; a kill of his own attacks during the skill keeps the trait ramp
// when it ends. S1 / S2 have no 对空 note: ground only, like his trait.
chess_char_5_19_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1);
const sid = selectedId(chess, def);
const up = num(bb.trait_up, 1), perKill = num(bb.per_kill_reduce);
// S3: the trait bonus = ramp × trait_up + per_kill_reduce × kills, ≥ 0 — the trait's own ramp buff stays, the
// difference goes into `mlynar:traitUp` (negative once the kills take the bonus below the ramp)
const applyUp = (battle, unit) => {
const ramp = num(unit.trait.ramp);
const extra = unit.mem.mlyUp ? Math.max(0, ramp * up + perKill * num(unit.mem.mlyKills)) - ramp : 0;
if (extra) battle.addBuff(unit, { key: 'mlynar:traitUp', mods: { atkPct: extra } });
else battle.removeBuff(unit, 'mlynar:traitUp');
};
/**
* "仅自身普通攻击(与该次攻击附带的伤害)击倒非角色类单位" (PRTS S2 / S3 备注): `onKill(victim)` for each enemy he
* knocks out, while his skill runs, that his current attack hit (or the mark it set off, tagged 'mlynarOwn') — so a
* kill by the damage that attack carries (天马之枪, the 卡西米尔 bond's true damage, the mark) counts too, in any
* hook order; `onAttack()` after each of his attacks ("加成降低于当次攻击后统一结算"), which also closes the set.
* 无动于衷's reflection and a mark another operator's attack set off happen outside his attack: never counted.
*/
const ownKills = (battle, unit, onKill, onAttack = null) => {
const hit = new Set();
battle.on('damaged', (c) => {
if (c.source === unit && (c.dmg?.isAttack || (c.dmg?.tags || []).includes('mlynarOwn'))) hit.add(c.target);
}, { owner: unit, priority: 1000 });
battle.on('kill', (c) => { if (c.killer === unit && hit.has(c.victim) && c.victim.side === 'enemy' && unit.skill?.active) onKill(c.victim); }, { owner: unit });
battle.on('attack', (c) => { if (c.attacker !== unit) return; hit.clear(); if (onAttack) onAttack(); }, { owner: unit });
};
return {
skills: lazySkills({
skchr_mlynar_1: () => ({ kind: 'duration', mods: mods({ defPct: num(bb.def) }), attack: { atkScale: num(bb['attack@atk_scale'], 1) } }),
skchr_mlynar_2: () => ({
kind: 'duration', mods: mods({ batPct: batPct(bb.base_attack_time, chess) }),
targeting: skillRange(chess, def),
attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: 2 },
}),
}),
skill: {
kind: 'duration',
targeting: { ...(skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : {}), canHitFly: true },
attack: { atkScale: num(bb['attack@atk_scale'], 1), maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) },
onStart({ battle, unit }) {
unit.mem.mlyUp = true; unit.mem.mlyKills = 0; unit.mem.mlyPending = 0;
applyUp(battle, unit);
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 2, skill: 'mlynar' });
},
onEnd({ battle, unit }) { unit.mem.mlyUp = false; unit.mem.mlyPending = 0; battle.removeBuff(unit, 'mlynar:traitUp'); },
},
trait: { dmgMul: (b, u) => (num(u.mem.mlyNear) >= num(t0.cnt, 3) ? num(t0.atk_scale_up, 1) : num(t0.atk_scale_base, 1)) },
talents: [
{ install(battle, unit) { // 游侠
const dr = num(t0.damage_resistance);
whileOn(battle, unit, 0.2, () => {
unit.mem.mlyNear = battle.foesInRadius(unit.x, unit.y, AROUND_RADIUS).length;
if (dr > 0 && unit.mem.mlyNear >= num(t0.cnt, 3)) battle.addBuff(unit, { key: 'mlynar:ranger', duration: 0.3, mods: { dmgTakenMul: 1 - dr } });
});
} },
{ install(battle, unit) { // 无动于衷
permBuff(battle, unit, 'mlynar:unmoved', { taunt: num(t1.taunt_level) });
const scale = num(t1.atk_scale);
if (!(scale > 0)) return;
battle.on('damaged', (c) => {
const a = c.target, src = c.source;
if (a.side !== 'ally' || !isOp(a) || !isKazimierz(a) || !src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return;
if (!on(unit) || leaderOf(battle, unit) !== unit) return;
battle.dealDamage(unit, src, { amount: unit.s.atk * scale, type: 'true', canDodge: false, tags: ['talent', 'reflect'] });
}, { owner: unit });
} },
],
install(battle, unit) {
if (sid === 'skchr_mlynar_2') {
// 技能期间若击倒敌人,技能结束时特性效果不重置: the librator trait resets the ramp on skillEnd (priority 0) —
// remember it before and put it back after
battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.mlyKeep = false; }, { owner: unit });
ownKills(battle, unit, () => { unit.mem.mlyKeep = true; });
battle.on('skillEnd', (c) => {
if (c.unit === unit) unit.mem.mlyRamp = unit.mem.mlyKeep && c.reason !== 'death' ? num(unit.trait.ramp) : null;
}, { owner: unit, priority: 100 });
battle.on('skillEnd', (c) => {
if (c.unit !== unit || unit.mem.mlyRamp == null) return;
unit.trait.ramp = unit.mem.mlyRamp;
unit.mem.mlyRamp = null;
if (unit.trait.ramp > 0 && unit.alive) battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } });
}, { owner: unit, priority: -100 });
}
if (sid && sid !== 'skchr_mlynar_3') return;
ownKills(battle, unit, () => { unit.mem.mlyPending = num(unit.mem.mlyPending) + 1; }, () => {
if (!unit.mem.mlyPending || !unit.mem.mlyUp) return;
unit.mem.mlyKills = num(unit.mem.mlyKills) + unit.mem.mlyPending;
unit.mem.mlyPending = 0;
applyUp(battle, unit);
});
const extra = num(bb.atk_scale);
if (!(extra > 0)) return;
battle.on('damaged', (c) => {
const src = c.source, e = c.target;
if (!src || src.side !== 'ally' || !isOp(src) || !isKazimierz(src) || !c.dmg?.isAttack || c.type === 'element') return;
if (e.side !== 'enemy' || !e.alive || !unit.skill?.active || !on(unit) || !inRange(unit, e)) return;
const tags = src === unit ? ['skill', 'mlynarMark', 'mlynarOwn'] : ['skill', 'mlynarMark'];
battle.dealDamage(unit, e, { amount: unit.s.atk * extra, type: 'true', canDodge: false, isSkill: true, tags });
}, { owner: unit });
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 安洁莉娜 — slower (no normal attack while the skill is off). S3 秘杖·反重力模式 (14/18 s, SEARCH): every enemy on the
// field is weightless, skill range, ATK +, 4/5 targets. T1 加速力场: all allies ASPD +7. T2 兼职工作: while the skill is
// off, all allies regenerate 20 HP/s. Module (elite): longer sluggish (trait bb, profession tunables).
// S1 秘杖·速充模式 (duration, attack SP): ATK +; she attacks normally with S1 ("技能未开启时无法普通攻击" is only in the
// S2/S3 texts). S2 秘杖·微粒模式 (duration, SEARCH): attack interval ×base_attack_time ("极大幅度缩短": a positive value
// described as a shortening is the new interval ratio, tier1 batMod convention), each attack damage_scale × ATK arts.
// Module DEC-X (elite): SP +0.2/s with an enemy in range.
chess_char_5_20_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1);
const sid = selectedId(chess, def);
const ratioBat = (v) => (num(v) > 0 && num(v) < 1 ? num(v) - 1 : batPct(v, chess));
const weightless = (battle, unit) => {
const left = Math.max(0.1, num(unit.skill?.timeLeft, 1));
for (const e of battle.enemies) {
if (!e.alive || e.findBuff('aglina:weightless')) continue;
// 失重: weight −1 level (engine mod `massFlat`; massLevel never drops below 0)
battle.addBuff(e, { key: 'aglina:weightless', status: 'weightless', visible: true, duration: left, data: { src: unit }, mods: { massFlat: -WEIGHTLESS_MASS } });
}
};
return {
skills: lazySkills({
skchr_aglina_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk) }) }),
skchr_aglina_2: () => ({ kind: 'duration', mods: mods({ batPct: ratioBat(bb.base_attack_time) }), attack: { atkScale: num(bb.damage_scale, 1) } }),
}),
skill: {
kind: 'duration',
mods: mods({ atkPct: num(bb.atk) }),
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
attack: { maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) },
onStart({ battle, unit }) { unit.mem.aglAcc = 0; weightless(battle, unit); battle.fx('weightless', { x: unit.x, y: unit.y, id: unit.id }); },
onTick({ battle, unit, dt }) { unit.mem.aglAcc += dt; if (unit.mem.aglAcc >= AURA_IV) { unit.mem.aglAcc = 0; weightless(battle, unit); } },
onEnd({ battle, unit }) {
for (const e of battle.enemies) { const b = e.findBuff('aglina:weightless'); if (b && b.data.src === unit) battle.removeBuff(e, b); }
},
},
trait: sid === 'skchr_aglina_1' ? undefined : { noAttackUnlessSkill: true },
install(battle, unit) { enemySpUp(battle, unit, 'aglina:module', num(tm.sp_recovery_per_sec)); },
talents: [
{ install(battle, unit) { // 加速力场
const aspd = num(t0.attack_speed);
if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); });
} },
{ install(battle, unit) { // 兼职工作
const hp = num(t1.hp_recovery_per_sec);
// an HP-regeneration attribute, so 禁疗 does not stop it — PRTS 备注 "生命回复的提供方式为增加目标的'生命回复速度'属性,
// 不受治疗加成和禁疗影响" (异常效果 禁疗: "增减生命回复速度…的效果不会被识别为治疗类能力"); a 孤立 unit (炎佑) is not
// selected (Battle.alliesFor)
if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); });
} },
],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 寒檀 — S2 “女巫之泪” (15 s): skill range, BAT −2.4 s; attacks become icicles on random tiles of the range (cycling left
// row → right row → own row): 65/75 % ATK arts + 1 s cold to every enemy within 1.5 of the impact. T1 生于冰寒: after 20 s on the field ATK +15 % and 抵抗 (negative
// statuses on her last half as long).
chess_char_5_21_a: (bb, chess, def) => {
const t0 = talent(chess, 0);
const scale = num(bb['attack@atk_scale'], 1), cold = num(bb['attack@cold']);
const resist = Math.min(1, Math.max(0, -num(t0.one_minus_status_resistance))); // share of the duration removed
return {
// S1 迅捷打击·γ型 (duration): ATK +, ASPD +.
skills: lazySkills({
'skcom_quickattack[3]': () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }),
}),
skill: {
kind: 'duration',
mods: mods({ batPct: batPct(bb.base_attack_time, chess) }),
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
attack: { noAttack: true },
onStart({ unit }) { unit.mem.iceCd = 0; unit.mem.iceRow = 0; },
onTick({ battle, unit, dt }) {
unit.mem.iceCd -= dt;
if (unit.mem.iceCd > 0 || !unit.canAct || unit.s.flags.disarm) return;
if (!battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) return;
unit.mem.iceCd = unit.s.interval;
// PRTS 备注: icicles fall on a random tile of ① the row on her left, ② the row on her right, ③ her own row, …
let k = null;
for (let i = 0; i < ICICLE_ROWS.length && k == null; i++) {
const idx = (num(unit.mem.iceRow) + i) % ICICLE_ROWS.length;
const lat = ICICLE_ROWS[idx];
const keys = (unit.rangeKeys || []).filter((x) => toLocal(Math.floor(x / COLS) - unit.tileR, x % COLS - unit.tileC, unit.dir)[0] === lat);
if (!keys.length) continue;
k = battle.rng.pick(keys);
unit.mem.iceRow = (idx + 1) % ICICLE_ROWS.length;
}
if (k == null) return;
const r = Math.floor(k / COLS), c = k % COLS;
unit.lastAttackAt = battle.time;
unit.stats.attacks++; // "攻击变为…召唤冰凌": every icicle is one of her attacks
battle.fx('iceSpike', { x: c, y: r, id: unit.id, r: ICICLE_RADIUS });
battle.after(ICICLE_DELAY, () => {
const hit = [];
for (const e of battle.foesInRadius(c, r, ICICLE_RADIUS)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'iceSpike'] });
if (e.alive && cold > 0) battle.applyStatus(e, 'cold', { duration: cold, source: unit });
if (e.alive) hit.push(e);
}
// an icicle is an attack: "攻击时" effects (items, bonds) see it with the enemies it hit
if (hit.length && battle.hasHook('attack')) battle.emit('attack', { attacker: unit, targets: hit, isSkill: true });
}, { owner: unit });
},
},
talents: [{ install(battle, unit) { // 生于冰寒
const after = num(t0.interval, 20);
// + 抵抗 (engine `resist` status: new control statuses last half as long, never compounding with other sources)
whileOn(battle, unit, 0.5, () => {
if (battle.time - unit.deployedAt < after - 1e-9 || unit.findBuff('sntlla:born')) return;
battle.addBuff(unit, { key: 'sntlla:born', visible: true, mods: mods({ atkPct: num(t0.atk) }) });
if (resist > 0) battle.applyStatus(unit, 'resist', { value: resist, source: unit });
});
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 妮芙 — S2 怵然震爆 (1/2 charges): next attack 270/300 % ATK arts + 4/5 s fear on the target and an equal arts splash
// (projectile_range), every hit adding 18/22 % of its damage as apoptosis; targets in an apoptosis burst raise T1 to 70/85 %.
// T1 失魂: hitting an enemy in an apoptosis burst → 40 % ATK elemental damage per second until the burst ends.
// T2 窥心钥: an apoptosis burst in range → ATK +2 % (×10). Module (elite): ×damage_scale vs enemies in a burst.
// S1 笞心击 (duration): ATK +; attacks add attack@ep_damage_ratio of their damage as 凋亡损伤 and, on a target in an
// apoptosis burst, attack@extra_ep_damage_scale × ATK elemental damage. S3 心防溃决 (duration): skill range, ATK +,
// ASPD +, 2 targets; attacks on a target in an apoptosis burst deal elemental damage (split_atk_scale × ATK, no arts).
// Module PRI-Y (elite): SP +0.2/s while an enemy in range is in an element burst.
chess_char_5_22_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1);
const sid = selectedId(chess, def);
const ep = num(bb.ep_damage_ratio, num(bb['attack@ep_damage_ratio'])), skillSoul = num(bb.element_atk_scale), soulBase = num(t0.element_atk_scale);
const soul = (battle, unit, e, scale) => {
const burst = e.findBuff('apoptosisBurst');
if (!burst || !(scale > 0)) return;
const key = `nymph:soul:${unit.id}`;
const cur = e.findBuff(key);
if (cur) { cur.data.scale = Math.max(num(cur.data.scale), scale); return; }
battle.addBuff(e, {
key, duration: Math.max(0.1, burst.timeLeft), interval: 1, data: { scale },
onTick: ({ unit: x, buff }) => {
if (!x.findBuff('apoptosisBurst')) { battle.removeBuff(x, buff); return; }
elementHit(battle, unit, x, unit.s.atk * num(buff.data.scale), 'nymphSoul', 'apoptosis');
},
});
battle.fx('soul', { x: e.x, y: e.y, id: e.id });
};
return {
skills: lazySkills({
skchr_nymph_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk) }) }),
skchr_nymph_3: () => ({
kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 2)) }),
attack: { atkScale: num(bb['attack@split_atk_scale'], 1) },
}),
}),
skill: {
kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant',
attack: {
atkScale: num(bb.atk_scale, 1), splashRadius: num(bb.projectile_range, 1.5),
onHit({ battle, unit, target, x, y }) {
if (target && target.alive) battle.applyStatus(target, 'fear', { duration: num(bb.fear), source: unit });
battle.fx('aoe', { x, y, id: unit.id, r: num(bb.projectile_range, 1.5), skill: 'nymph' });
},
},
},
talents: [
{ install(battle, unit) { // 失魂 (+ the skill's apoptosis rider and 70 % upgrade)
battle.on('damaged', (c) => {
if (c.source !== unit || c.type === 'element' || c.target.side !== 'enemy' || !c.target.alive || !c.dmg) return;
// (S1 has no attack override: its attacks are the skill's while it runs)
const skillAtk = c.dmg.isSkill || (sid === 'skchr_nymph_1' && !!unit.skill?.active);
// (not on a killing blow: the target is at 0 HP here — no burst on the corpse)
if (skillAtk && c.dmg.isAttack && ep > 0 && c.amount > 0 && hasHp(c.target)) {
battle.dealDamage(unit, c.target, { type: 'element', element: 'apoptosis', amount: c.amount * ep, tags: ['skill', 'nymph'] });
}
if (c.dmg.isAttack && c.target.alive) soul(battle, unit, c.target, c.dmg.isSkill ? Math.max(skillSoul, soulBase) : soulBase);
}, { owner: unit });
} },
{ install(battle, unit) { // 窥心钥
battle.on('elementBurst', (c) => {
if (c.element !== 'apoptosis' || c.target.side !== 'enemy' || !on(unit) || !inRange(unit, c.target)) return;
battle.addBuff(unit, { key: 'nymph:key', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 10)), mods: { atkPct: num(t1.atk) } });
}, { owner: unit });
} },
],
install(battle, unit) {
burstDamageUp(battle, unit, num(tb.damage_scale));
burstSpUp(battle, unit, 'nymph:module', num(tm.sp_recovery_per_sec));
if (sid === 'skchr_nymph_1') { // S1: extra elemental damage on burst targets (the 凋亡 rider is T1's `ep`)
const extra = num(bb['attack@extra_ep_damage_scale']);
battle.on('damaged', (c) => {
if (c.source !== unit || !unit.skill?.active || !c.dmg?.isAttack || c.type === 'element' || c.type === 'elemental') return;
const e = c.target;
if (e.side !== 'enemy' || !e.alive || !e.findBuff('apoptosisBurst') || !(extra > 0)) return;
elementHit(battle, unit, e, unit.s.atk * extra, 'nymphLash', 'apoptosis');
}, { owner: unit });
}
if (sid === 'skchr_nymph_3') { // S3: attacks on a burst target become elemental damage
battle.on('hit', (c) => {
if (c.source !== unit || !unit.skill?.active || !c.dmg.isAttack || !c.target || !c.target.findBuff('apoptosisBurst')) return;
if (c.dmg.type !== 'arts' && c.dmg.type !== 'phys') return;
c.dmg.type = 'elemental';
c.dmg.element = 'apoptosis';
c.dmg.canDodge = false;
(c.dmg.tags ||= []).push('nymphBreak');
}, { owner: unit, priority: -10 });
}
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 录武官 — S2 一点关窍 (25 s): ATK +; healed allies get a 10 s buff healing 80/120 HP on every hit taken.
// T1 学成于聚: an op in range starting a skill → +1 SP and ASPD +16 for 8 s. Module (elite): heals below 50 % ×1.15.
// S1 触类旁通 (2/3 charges): the next heal restores heal_scale × ATK and heals max_target allies.
chess_char_5_23_a: (bb, chess, def) => {
const t0 = talent(chess, 0), tb = traitBb(chess);
const dur = num(bb['attack@buff_duration'], 10), value = num(bb['attack@fixed_heal_value']);
return {
skills: lazySkills({
skchr_reckpr_1: () => ({ kind: instantKind(chess, def), heal: true, attack: { healScale: num(bb.heal_scale, 1), maxTargets: Math.max(1, num(bb.max_target, 2)) } }),
}),
skill: {
kind: 'duration', heal: true,
mods: mods({ atkPct: num(bb.atk) }),
onHit({ battle, unit, target }) {
// "治疗干员后为其施加一个增益": operators only (summons are 友方单位, not 干员)
if (!target || !target.alive || !isOp(target) || !(value > 0)) return;
battle.addBuff(target, { key: 'reckpr:guard', duration: dur, visible: true, data: { src: unit, value } });
},
},
talents: [{ install(battle, unit) { // 学成于聚
battle.on('skillStart', (c) => {
const a = c.unit;
if (a === unit || !isOp(a) || !on(unit) || !inRange(unit, a)) return;
if (num(t0.prob, 1) < 1 && !battle.rng.chance(num(t0.prob, 1))) return;
unit.skill?.gainSp(num(t0.sp), 'talent');
if (num(t0.attack_speed)) battle.addBuff(unit, { key: 'reckpr:learn', duration: num(t0.duration, 8), mods: { aspd: num(t0.attack_speed) } });
}, { owner: unit });
} }],
install(battle, unit) {
battle.on('damaged', (c) => {
const a = c.target;
if (a.side !== 'ally' || !(c.amount > 0) || a.hp <= 0 || c.type === 'element' || isHpLoss(c.dmg)) return; // (not a 流失)
const b = a.findBuff('reckpr:guard');
if (b && b.data.src === unit) battle.heal(unit, a, num(b.data.value));
}, { owner: unit });
lowHpHealUp(battle, unit, tb);
},
};
},
};
export default KITS;