Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-kalts.js
T
sganggs 97454d8cda feedback5 WE1: 「同时攻击阻挡的所有敌人」 takes up to the block count of targets from the range, blocked first (the queued centurion question; Mon3tr S3)
PRTS 分支特性信息 强攻手 / 重剑手 / 推击手 "普通攻击最大目标数等于阻挡数(不会低于1)"; PRTS 作战机制 §AOE "锁定人数的无弹道AOE攻击
(例如近卫分支“强攻手”)…在抬手时选取范围内的全体目标(不超过其攻击目标上限)"; PRTS 忍冬 S3 备注 "可对空". The client's selectors of
the branch traits and of every kit skill marked hitAllBlocked carry _limitedMaxTargetNumToBlockedCnt (_allowZeroBlockCntLimit
off) — charpacks extracted 2026-10-06.

- ai.js: hitAllBlocked no longer returns just the blocked enemies; targetCount = max(1, block count) (+ maxTargets mods)
  of targets from the range and the blocked enemies, blocked first. Mon3tr S3 drops maxTargets 1; 凯尔希's Mon3tr under
  S2 picks its targets the same way. [ASSUMED] maxTargets mods add; 忍冬 S3's range-only note not modelled.

Golden: 52 scenarios, each with a hitAllBlocked attacker (probe-checked) — roster-001, -003, -004, -007, -009, -011,
-013, -014, -015, -016, -020, -022, -023, -024, -030, -032, -038, -040, -041, -042, -043, -049; bond-sargonShip-high,
bond-soloShip-low, bond-soloShip-high, bond-yanShip-low; boss-boss_4-solo, boss-boss_6-solo, boss-boss_7-solo,
hidden-boss_9-solo, hidden-boss_10-solo; matches solo-NORMAL-1, solo-ABYSS-1, coop3-ABYSS-5, coop4-FUNNY-6,
coop4-ABYSS-7, coop2-NORMAL-70-diy; diy-013, -014, -033, -034, -035, -037, -038, -039, -051, -053, -076, -078, -109,
-127, -130. standins unchanged.

Tests: test/sim/feedback5-block-targets.test.js (new, 2). Updated on purpose: feedback1-transform.test.js (its real round
relied on 百炼嘉维尔 — a 强攻手 — falling; the ember-walks-on check is now a deterministic sub-test). test/content and
test/sim pass. Docs: SIM.md centurion row, professions.js header.
2026-10-06 16:28:31 +08:00

272 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/kits/ops/op-kalts.js — 凯尔希 (char_003_kalts) 自选 operator kit: 6★ 医师 (医疗), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her modules (PHY-X Mon2tr, PHY-Y 医者, ISW-A 凯尔希特限
// 证章) at every form, and the kit of her summon Mon3tr (token_10002_kalts_mon3tr) — its block, its attacks and her skills
// acting through it. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_003_kalts): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7,
// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no
// account]. Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json);
// PRTS 凯尔希 (Mon3tr 备注 "仅在未装配模组时优先治疗效果为优先治疗自身、干员Mon3tr和凯尔希的召唤物", "可以强行选择…凯尔希的召唤物
// 作为治疗目标,相同生命比例下优先治疗自身", "治疗效果无视禁疗,但对象选取受禁疗制约"; 不毁重构 备注 "可对空"; S3 备注 "技能效果流失
// 的生命值可以击倒Mon3tr,并触发第二天赋", "攻击力加成每秒更新一次"); PRTS Mon3tr(凯尔希的召唤物) (备注 "持有禁疗(可被凯尔希…无视)",
// "凯尔希退场时强制撤退场上的Mon3tr"; 攻击范围 1-1, 阻挡数 3); gamedata_const ba.binding 绑定 "绑定对象不在场时技能强制结束,清空
// 所有技力且无法回复技力"; the client's battle data (charpack char_003_kalts: its attack `_ignoreHealFree`, WithdrawTokens,
// Talents/1 the in-range aura kalts_t_1[token_def_down_finish] + charge_token[born], Talents/2; token prefab
// token_10002_kalts_mon3tr: mon3tr_c_healfree (abnormal flag 7 = 禁疗), kalts_t_1[token_def_down] (DEF FINAL_SCALER 0),
// kalts_token[finish_kill_mark], Talents/2 the death rattle (FixedValueDamage PURE `value`, SPLASH, stun `stun`, range x-4),
// modes S2 (`_limitedMaxTargetNumToBlockedCnt`) / S3 (damage type PURE); skill prefabs skchr_kalts_1 (kalts_s_1[evade] =
// damage_block[phy]), skchr_kalts_2 / _3 (`_onlyAvailableWhenTokenValid`, RecoverSpCond every 0.1 s); equips kalts_equip_1_*
// (heal_scale_up[hpratio][LE]; stage 2+ the toggle-once HP ≤ 50 % rattle), kalts_equip_2_* (heal_scale_up[lowland]),
// kalts_equip_3_* (`_checkRoguelikeMode`); buff_template_data kalts_s_2_3[sp_cond], kalts_t_withdraw_token,
// kalts_s_3[ratio_atk], kalts_token_death_rattle_projectile, kalts_e_002_t[token]).
// - Trait (医师) "恢复友方单位生命": the profession default (one heal per attack on an injured ally of her 3-10 range), with T1's
// order: when she, her Mon3tr or — with no module — an operator Mon3tr (char_4179_monstr) is injured, the one of them with
// the lowest HP ratio (ties: herself); else the lowest HP ratio. Her heals reach her Mon3tr through its 禁疗, and her heal
// selection takes it (profile `healThrough`, damage.js heal / Battle.injuredAlliesInKeys); other 禁疗 units stay out.
// - Module PHY-X Mon2tr: ×heal_scale (1.15) on a heal whose target is at or below hp_ratio (50 %) before it
// (heal_scale_up[hpratio][LE]); stage 3 changes T2 (below). PHY-Y 医者: ×heal_scale on a heal whose target stands on a 地面
// tile (heal_scale_up[lowland]); stage 3 changes T1 (below). ISW-A 凯尔希特限证章: only its attributes act — its trait
// ("在集成战略中,同时治疗两个目标") and talent ("在集成战略中,Mon3tr不占用部署位,生命上限和攻击力+50%…") are 集成战略-only
// (`_checkRoguelikeMode`): N/A here, the trait blackboard it carries is not used. Stats: the module attributes.
// - T1 Mon3tr: her Mon3tr is a hand piece the player places; it deploys with the board, blocks 3 and strikes one ground
// enemy of its 1-1 in melee for physical damage (its data), holds 禁疗 (only her heals pass — the data's `abnormal` lacks it
// [ASSUMED: given here]); its DEF is ×0 while its tile is outside her attack range (kalts_t_1[token_def_down]; PHY-Y stage
// 3: inside it ASPD +attack_speed, DEF +def — kalts_t_1[token_def_down_finish]). It is withdrawn when she leaves (no
// rattle); it comes back on its tile its respawnTime (25 s) after it left, paying its cost (10 DP), only while she stands —
// her (re)deployment readies a waiting one at once (charge_token[born]: RechargeToken with timing NORMAL, while its own timer
// is the summon's charge_token[finish], ON_FINISH — the one reading of every summoner kit, 傀影's twin included)
// [ASSUMED: the 卫戍 auto redeploy of a placed summon].
// - T2 不毁重构: Mon3tr knocked out (not withdrawn; S3's 流失 counts) ⇒ every selectable enemy of its 3×3 (the talent's x-4,
// air units too) takes `value` (1200) true damage (溅射, not dodgeable) and is stunned `stun` (3) s. PHY-X stage 3 (the
// token's module talent: 3.5 s, 1500, hp_ratio): also once per deployment when a hit leaves it at or below hp_ratio of
// its max HP (the equip's toggle-once `_maxHpRatio` 0.5) and it survives [ASSUMED].
// - S1 指令:结构加固 (MANUAL, data DEFAULT — a heal skill: cast as she is about to heal): her DEF +def and Mon3tr's DEF
// +attack@def while it runs; 物理格挡 prob (damage_block[phy]: a physical damage instance she takes is blocked whole —
// not a dodge, nothing ignores it; a 流失 is no damage).
// - S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +attack_speed; Mon3tr ATK +attack@atk and "可以攻击阻挡的所有
// 敌人": each of its attacks takes up to its block count (3) of targets, the blocked ones first (the 强攻手 rule — PRTS 分支特性信息
// "普通攻击最大目标数等于阻挡数(不会低于1)"; its mode's _limitedMaxTargetNumToBlockedCnt).
// - S3 指令:熔毁 (MANUAL, data DEFAULT, 绑定 Mon3tr, 18 s): Mon3tr DEF +attack@def, its attacks deal true damage, its ATK
// +attack@atk × the remaining share of the skill, updated every second from the cast (+190 % … +10.6 %); unless it killed
// an enemy meanwhile, at the skill's normal end it loses attack@hp_ratio × its max HP (a 流失 that may knock it out).
// - 绑定 (S2 / S3): cast only while her Mon3tr is on the field; every 0.1 s without one, a running S2 / S3 ends, her SP is
// emptied and recovers no more (阻回 flag `noSp`) until it is back (kalts_s_2_3[sp_cond]).
import { num, talentBb, traitBb, moduleOn, skillRec, up } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { acquireTargets } from '../../../ai.js';
const S1 = 'skchr_kalts_1';
const S2 = 'skchr_kalts_2';
const S3 = 'skchr_kalts_3';
/** Her summon Mon3tr (talent Mon3tr). */
export const MON3TR = 'token_10002_kalts_mon3tr';
/** The operator Mon3tr (an owned 自选 pick): in T1's heal order when she wears no module (PRTS 备注). */
const OP_MON3TR = 'char_4179_monstr';
const PHY_X = 'uniequip_002_kalts';
const PHY_Y = 'uniequip_003_kalts';
/** 不毁重构's area when the token talent lacks it (x-4: its tile and the 8 around it). */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
/** 绑定's check period (RecoverSpCond triggerInterval 0.1). */
const BIND_IV = 0.1;
/** How often a waiting Mon3tr tries to come back once ready. */
const RETRY = 0.1;
const BIND_KEY = 'kalts:binding';
const TAG_RATTLE = 'kalts:rattle';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const isMon3trOf = (t, unit) => !!t && t.kind === 'token' && t.defId === MON3TR && t.ownerUnit === unit;
const mon3trsOf = (battle, unit) => battle.allyUnits.filter((t) => isMon3trOf(t, unit));
const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id);
/** Keep buff `key` with `mods` on `u` while `want`; replace it when the mods changed. */
function holdBuff(battle, u, key, want, mods) {
const has = u.findBuff(key);
if (!want) { if (has) battle.removeBuff(u, key); return; }
if (has && Object.keys(mods).every((k) => has.mods?.[k] === mods[k])) return;
battle.addBuff(u, { key, mods, tags: ['skill'] });
}
/** 不毁重构's blast from Mon3tr's tile: `value` true damage (溅射) + `stun` s on every selectable enemy of its 3×3. */
function rattle(battle, m, { value, stun, grid }) {
battle.fx('aoe', { x: m.x, y: m.y, radius: 1.5, id: m.id, skill: TAG_RATTLE });
const keys = absoluteRangeKeys(grid, m.tileR, m.tileC, m.dir, 0);
for (const e of battle.enemiesInKeys(keys, m, { canHitFly: true })) {
if (!e.alive) continue;
battle.dealDamage(m, e, { amount: value, type: 'true', isSplash: true, canDodge: false, tags: [TAG_RATTLE] });
if (e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: m });
}
}
/**
* Mon3tr's kit: 禁疗, DEF ×0 outside her range (PHY-Y stage 3 bonus inside), her S1 / S2 / S3 acting through it, 不毁重构
* (and PHY-X stage 3's), its return. `owner` = 凯尔希.
*/
function mon3trKit(owner, { t0, b1, b2, b3 }) {
return {
skill: null,
talents: [],
install(battle, m) {
const rt = (m.def?.talents ?? []).find((x) => x && x.bb && x.bb.value != null);
const rb = rt?.bb ?? {};
const blast = { value: num(rb.value, 1200), stun: num(rb.stun, 3), grid: rt?.rangeGrid?.length ? rt.rangeGrid : X4 };
const halfAt = num(rb.hp_ratio); // PHY-X stage 3: 生命值首次低于50%
battle.addBuff(m, { key: 'kalts:mon3tr:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
battle.on('deploy', (c) => {
if (c.unit !== m) return;
m.mem.halfDone = false;
m.mem.s3NoKill = false;
}, { owner: m });
// her range, her skills: buffs held every tick
battle.on('tick', () => {
if (!up(m)) return;
const inRange = up(owner) && !!owner.rangeKeySet && owner.rangeKeySet.has(m.tileR * COLS + m.tileC);
holdBuff(battle, m, 'kalts:mon3tr:defZero', !inRange, { defMul: 0 });
holdBuff(battle, m, 'kalts:mon3tr:near', inRange && (num(t0.attack_speed) > 0 || num(t0.def) > 0), { aspd: num(t0.attack_speed), defPct: num(t0.def) });
holdBuff(battle, m, 'kalts:s1:mon3tr', skillOn(owner, S1), { defPct: num(b1['attack@def']) });
holdBuff(battle, m, 'kalts:s2:mon3tr', skillOn(owner, S2), { atkPct: num(b2['attack@atk']) });
let s3Atk = 0;
if (skillOn(owner, S3)) {
const sk = owner.skill;
const dur = sk.duration > 0 ? sk.duration : 1;
const steps = Math.floor(battle.time - sk.lastStart + 1e-9); // "攻击力加成每秒更新一次", from the cast
s3Atk = num(b3['attack@atk']) * Math.max(0, 1 - steps / dur);
}
holdBuff(battle, m, 'kalts:s3:mon3tr', skillOn(owner, S3), { atkPct: s3Atk, defPct: num(b3['attack@def']) });
}, { owner: m });
// S2: "Mon3tr可以攻击阻挡的所有敌人" — the 强攻手 rule (its mode's _limitedMaxTargetNumToBlockedCnt): up to its block count
// of targets, the blocked ones first (ai.js acquireTargets with `hitAllBlocked`)
battle.on('beforeAttack', (c) => {
if (c.attacker !== m || !skillOn(owner, S2) || !c.profile) return;
const t = acquireTargets(battle, m, { ...c.profile, hitAllBlocked: true });
if (t.length) c.targets = t;
}, { owner: m });
// S3: true damage; a kill keeps its HP
battle.on('hit', (c) => {
if (c.source === m && c.dmg && c.dmg.isAttack && skillOn(owner, S3)) c.dmg.type = 'true';
}, { owner: m, priority: 50 });
battle.on('kill', (c) => { if (c.killer === m && c.victim && c.victim.side === 'enemy') m.mem.s3NoKill = false; }, { owner: m });
// PHY-X stage 3: once per deployment, a hit that leaves it at or below half
if (halfAt > 0) {
battle.on('damaged', (c) => {
if (c.target !== m || m.mem.halfDone || !m.alive || !(m.hp > 0) || m.hpRatio > halfAt + 1e-9) return;
m.mem.halfDone = true;
rattle(battle, m, blast);
}, { owner: m });
}
battle.on('death', (c) => {
if (c.unit !== m || battle.finished) return;
if (c.reason === 'killed') rattle(battle, m, blast); // 被击倒后(不包括撤退)
// the piece stays (退场时返还1个可部署的Mon3tr): back on its tile once ready, while she stands
m.removed = false;
m.mem.readyAt = battle.time + Math.max(0, num(m.base.respawnTime));
if (m.mem.retry) return;
m.mem.retry = battle.every(RETRY, (b, sched) => {
const stop = () => { sched.cancel(); m.mem.retry = null; };
if (m.alive || m.removed || b.finished) { stop(); return; }
if (!up(owner) || b.time + 1e-9 < m.mem.readyAt) return;
if (b.redeploy(m, { free: false })) stop();
}, { owner: m });
}, { owner: m, priority: -10 });
},
};
}
export default {
char_003_kalts: (bb, chess) => {
const t0 = talentBb(chess, 0); // Mon3tr: def, attack_speed (PHY-Y stage 3: 0.2 / 20 inside her range)
const tb = traitBb(chess); // PHY-X: heal_scale, hp_ratio; PHY-Y: heal_scale (ISW-A's: 集成战略-only, unused)
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const mod = moduleOn(chess) ? chess.module.id : null;
const picked = chess?.skill?.skillId ?? null;
return {
// her heals pass her Mon3tr's 禁疗 and her heal selection takes it (PRTS: "持有禁疗(可被凯尔希…无视)")
trait: { healThrough: (healer, a) => isMon3trOf(a, healer) },
skills: {
[S1]: { kind: 'duration', mods: { defPct: num(b1.def) } },
[S2]: {
kind: 'duration', mods: { aspd: num(b2.attack_speed) },
onStart({ battle, unit, skill }) { if (!mon3trsOf(battle, unit).some(up)) skill.end('binding'); }, // 绑定
},
[S3]: {
kind: 'duration',
onStart({ battle, unit, skill }) {
const m = mon3trsOf(battle, unit).find(up);
if (!m) { skill.end('binding'); return; }
m.mem.s3NoKill = true; // kalts_s_3[no_kill_mark]
},
onEnd({ battle, unit, reason }) {
if (reason !== 'duration') return; // ended by 绑定 or her leaving: no loss
for (const m of mon3trsOf(battle, unit)) {
if (!up(m) || !m.mem.s3NoKill) continue;
m.mem.s3NoKill = false;
battle.loseHp(m, m.s.maxHp * num(b3['attack@hp_ratio'], 0.5), { source: m, tags: ['kalts:meltdown'] });
}
},
},
},
talents: [
{ install(battle, unit) { // Mon3tr: her pieces run Mon3tr's kit; withdrawn with her, readied by her deployment
for (const t of battle.allyUnits) {
if (!isMon3trOf(t, unit) || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t);
battle._setupUnit(t, mon3trKit(unit, { t0, b1, b2, b3 }));
}
battle.on('death', (c) => {
if (c.unit !== unit) return;
for (const t of mon3trsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'retreat', permanent: true });
}, { owner: unit });
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
for (const t of mon3trsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time;
}, { owner: unit });
// the heal order: herself, her Mon3tr (and, with no module, an operator Mon3tr) first when injured
const noModule = !moduleOn(chess);
const first = (a) => a === unit || isMon3trOf(a, unit) || (noModule && a.kind === 'op' && a.def?.charId === OP_MON3TR);
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !c.profile || c.profile.dmgType !== 'heal') return;
const pri = battle.injuredAlliesInKeys(unit.rangeKeys, unit).filter(first);
if (!pri.length) return;
pri.sort((a, b) => a.hpRatio - b.hpRatio || (a === unit ? -1 : b === unit ? 1 : 0) || a.deploySeq - b.deploySeq);
c.targets = [pri[0]];
}, { owner: unit, priority: 10 });
} },
// 不毁重构 lives in Mon3tr's kit (its knock-out, PHY-X stage 3's half-HP blast)
],
install(battle, unit) {
// PHY-X / PHY-Y: ×heal_scale on the heals she outputs to a target at or below hp_ratio / on a 地面 tile
const hs = num(tb.heal_scale, 1);
const boosted = mod === PHY_X ? (t) => t.hpRatio <= num(tb.hp_ratio, 0.5) + 1e-9 : mod === PHY_Y ? (t) => !!t.ground : null;
if (boosted && hs !== 1) {
battle.on('heal', (c) => {
if (c.source !== unit || c.opts?.regen || !c.target || !boosted(c.target)) return;
c.amount *= hs;
}, { owner: unit });
}
// S1 物理格挡: a physical damage instance blocked whole with prob
if (picked === S1) {
const p = num(b1.prob);
battle.on('hit', (c) => {
if (c.target !== unit || !skillOn(unit, S1) || !c.dmg || c.dmg.cancel || c.dmg.type !== 'phys' || !(p > 0)) return;
if (!battle.rng.chance(p)) return;
c.dmg.cancel = true;
battle.fx('block', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit, priority: -500 });
}
// 绑定 (S2 / S3): no Mon3tr on the field ⇒ the skill ends, the SP is emptied and recovers no more
if (picked === S2 || picked === S3) {
battle.every(BIND_IV, () => {
if (!unit.alive || !unit.deployed || !unit.skill) return;
if (mon3trsOf(battle, unit).some(up)) { if (unit.findBuff(BIND_KEY)) battle.removeBuff(unit, BIND_KEY); return; }
const sk = unit.skill;
if (sk.active) sk.end('binding');
sk.sp = 0;
sk.charges = 0;
if (!unit.findBuff(BIND_KEY)) battle.addBuff(unit, { key: BIND_KEY, flags: { noSp: true }, tags: ['skill'] });
}, { owner: unit, immediate: true });
}
},
};
},
};