Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-nian.js
T
sganggs 5877800a67 feedback5 CB3: the "受到攻击时" counters answer every damage instance from an enemy, not its attacks only — 菲莱 S2 now blasts on 深溟巢涌者's pulse, so do 星熊 S2, 泡泡, 刺玫 S2, 余 S1, 玛恩纳 无动于衷, 年 S2 and 斩业星熊 (community report of 2026-10-06, item 30)
The report's 退行的潮涌者 is the 巢涌者 (深溟 / 富营养的巢涌者 in this mode): no normal attack, its talent hurts every operator in
range once a second. Official (ArknightsGameData buff_template_data): its pulse is an AdvancedApplyDamage with the enemy as
source (enemy_dsubrl_aoe[aura]); the counters fire on ON_TAKE_DAMAGE — philae_s_2 with no filter, inverse_damage (星熊 S2),
inverse_damage[magic] + nian_s_2 (年 S2), bubble_s_2 / bubble_t_1 (泡泡), vendla_s_2 (刺玫), yu_s_1[inverse_damage] (余 S1),
hsgma2_s_1 / hsgma2_e_003_tr_take (斩业星熊), mlynar_t_2[inverse] (玛恩纳) on a source of the other side; 棘刺 S2 (普通伤害
filter) and 信仰搅拌机 S3 already took any damage. Cause: the kits required dmg.isAttack. Now kits/shared/tier1.js
byEnemyAttack = any enemy damage instance but a 流失, an element 损伤, 无来源 damage or another counter / reflection (tags
'counter' / 'reflect'); 菲莱 S2 any instance but a 流失 or a counter. test/content/op_nian.test.js pinned "a non-attack hit
returns nothing" for 年 S2 — updated on purpose (answered now; a 流失 still is not).

Golden: 19 scenarios where an enemy's non-attack damage (skills, pulses, areas) reached one of these operators — their counter
now answers it (more damage, fx thorns / aoe): roster-002, -005, -008, -010, -012, -016, -033, -044; bond-lateranoShip-low;
fields boss-boss_2-solo, boss-boss_4-pair, boss-boss_4-solo, hidden-boss_8-pair, hidden-boss_8-solo; matches solo-FUNNY-1,
solo-FUNNY-2, coop4-ABYSS-7, coop2-ABYSS-10-boosted; diy-051. Tests: test/sim/feedback5-counters-any-damage.test.js (6; 5
fail on the parent, the first checks the 巢涌者 never attacks). Docs: docs/SIM.md (re-entrancy rule).
2026-10-07 02:30:07 +08:00

172 lines
12 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-nian.js — 年 (char_2014_nian) 自选 operator kit: 6★ 铁卫 (重装), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_2014_nian, the DIY slot statuses): 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). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), gamedata_const
// ba.shield 护盾 ("每层护盾可以抵挡一次伤害") and ba.buffres 抵抗, PRTS 年 (积甲成山 备注 "根据重装干员数量获得的治疗效果加成与
// 生命上限加成叠加时以加法叠加"; 干明可鉴 备注 "攻击力与防御力增益持续时间无限"; 铜印 备注 "沉默效果与反伤效果会直接选中伤害来源,
// 但仅对阵营为敌方的来源产生效果"; 铁御 技能范围 x-2).
// - Trait (铁卫) "能够阻挡三个敌人": the profession default (block 3 from the data stats), ground-only physical melee on her
// 1-1. Module PRO-X 混沌阵列 adds "阻挡敌人时防御力+20%" (trait bb def): DEF + while she blocks at least one enemy.
// Module PRO-Y “请勿叩门” "能够阻挡四个敌人" (attr block_cnt +1, already in the stats). From stage 2 its trait part carries
// the blackboard of 积甲成山's module text (battle_equip_table: TRAIT part max_hp / heal_scale / heal_scale_addition /
// heal_scale_max_value / max_stack_cnt, the talent part only the team bonus) "场上每有一名重装干员,自身受到治疗效果和生命上限
// 额外提升4%,最多叠加3层": n = the 重装 operators on the field, her included [ASSUMED: "每有一名重装干员" counts every 重装 on
// the field] and a teammate's in a shared field (alliesFor), at most max_stack_cnt — max HP +max_hp × n (直接乘算 with her
// other "+%" bonuses) and healing received ×(heal_scale + heal_scale_addition × n), at most heal_scale_max_value (the
// PRTS note's 加法叠加: the stacks add up, 104 / 108 / 112 %). Re-counted every tick.
// - T1 积甲成山 "编入队伍时,所有【重装】职业干员的生命上限+16%": every 重装 operator of her player's team (her included) max HP
// +max_hp for the whole battle, deployed or not (the kits' 编入队伍时 convention: 斯卡蒂 深海掠食者, 早露 学生楷模). PRO-Y
// stage 3: +21 % (the module talent change).
// - T2 干明可鉴 "部署后立即获得3层护盾": `times` 护盾 layers at every deployment (a hit-negating barrier: buff shieldHits — each
// layer negates one damage instance). PRO-X stage 2+ (talent change atk / def / sp / max_stack_cnt) "每层护盾破裂时,攻击力
// +7%,防御力+7%且获得3点技力": each broken layer one more stack of ATK / DEF + (at most max_stack_cnt; "持续时间无限": until she
// leaves the field [ASSUMED: a deployment's layers give a deployment's stacks]) and +sp SP (none while a skill runs: AK's
// no-SP-during-a-skill rule, tier1 giveSp).
// - S1 锡灼 (MANUAL, time SP; data DEFAULT — the owner's 重装 cast-in-range exception, rawRule TAKE_DAMAGE): `duration` s of
// DEF +def and ATK +atk; normal attacks deal arts damage.
// - S2 铜印 (MANUAL; data DEFAULT): `duration` s — stops attacking; DEF +def, block +block_cnt; every enemy attack she takes
// (each damage instance of one, as 星熊 荆棘 / 泡泡) deals atk_scale × ATK arts to the attacker and silences it `silence`
// s ("失去特殊能力"): a direct pick of the damage source (ignoreSelect), enemies only (PRTS 备注), even when a 护盾 layer
// negated the hit.
// - S3 铁御 (MANUAL; data DEFAULT — its x-2 is a 技能范围 for the allies, no attack-range change): `duration` s of ATK
// +[self].atk (block +[self].block_cnt = 0); every other allied operator on her x-2 ("周围其他友方干员": summons are no 干员
// [ASSUMED]) gets DEF +[ally].def, block +[ally].block_cnt and 抵抗 (the engine `resist` status, value
// −one_minus_status_resistance), refreshed every AURA s while the skill runs; two 年 of a shared field keep the stronger
// (installAura's rule). The DEF / block buff ends with the skill, the 抵抗 within AURA_DUR.
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up, giveSp, byEnemyAttack } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_nian_1';
const S2 = 'skchr_nian_2';
const S3 = 'skchr_nian_3';
/** 铁御's aura: refresh period and buff lifetime (s) — the tier-4 kits' AURA / AURA_DUR. */
const AURA = 0.2;
const AURA_DUR = 0.25;
/** 铁御's 技能范围 x-2 when the data carries no grid. */
const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1],
[0, 2], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]);
const SHIELD = 'talent:nian:shield';
const BREAK = 'talent:nian:shieldBreak';
const GUARD = 'skill:nian:ironGuard';
const FORT = 'trait:nian:fortify';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 【重装】 operator (summons are never 重装 operators). */
const isTank = (a) => !!a && a.kind === 'op' && a.def?.profession === 'TANK';
/** The mods of `o` without its zero entries. */
const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0));
export default {
char_2014_nian: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const guardGrid = skillRec(chess, S3)?.rangeGrid ?? X2;
const allyDef = num(b3['nian_s_3[ally].def']), allyBlock = num(b3['nian_s_3[ally].block_cnt']);
const resist = Math.min(0.95, Math.max(0, -num(b3.one_minus_status_resistance)));
/** 铁御: the other allied operators on her x-2 get the DEF / block buff and 抵抗 for AURA_DUR. */
const ironGuard = (battle, unit) => {
const keys = new Set(absoluteRangeKeys(guardGrid, unit.tileR, unit.tileC, unit.dir, 0));
for (const a of battle.alliesFor(unit)) {
if (a === unit || a.kind !== 'op' || a.hidden || !keys.has(a.tileR * COLS + a.tileC)) continue;
const cur = a.findBuff(GUARD);
if (!(cur && cur.source !== unit && (cur.data?.v ?? 0) > allyDef && cur.timeLeft > 0.05)) {
battle.addBuff(a, { key: GUARD, duration: AURA_DUR, mods: nz({ defPct: allyDef, blockCnt: allyBlock }), source: unit, data: { v: allyDef }, tags: ['skill', 'aura'] });
}
if (resist > 0) battle.applyStatus(a, 'resist', { duration: AURA_DUR, value: resist, source: unit });
}
};
return {
skills: {
[S1]: { kind: 'duration', mods: nz({ defPct: num(b1.def), atkPct: num(b1.atk) }), attack: { dmgType: 'arts' } },
[S2]: { kind: 'duration', mods: nz({ defPct: num(b2.def), blockCnt: num(b2.block_cnt) }), attack: { noAttack: true } },
[S3]: {
kind: 'duration',
mods: nz({ atkPct: num(b3['nian_s_3[self].atk']), blockCnt: num(b3['nian_s_3[self].block_cnt']) }),
onStart({ battle, unit }) {
unit.mem.nianGuardAcc = 0;
ironGuard(battle, unit);
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'nian:ironGuard' });
},
onTick({ battle, unit, dt }) {
unit.mem.nianGuardAcc = (unit.mem.nianGuardAcc ?? 0) + dt;
if (unit.mem.nianGuardAcc + 1e-9 < AURA) return;
unit.mem.nianGuardAcc -= AURA;
ironGuard(battle, unit);
},
onEnd({ battle, unit }) {
for (const a of battle.allyUnits) {
const b = a.findBuff(GUARD);
if (b && b.source === unit) battle.removeBuff(a, b);
}
},
},
},
talents: [
{ install(battle, unit) { // 积甲成山: every 重装 operator of her team max HP +max_hp (PRO-Y stage 3: the module's value)
const hp = num(t0.max_hp);
if (!(hp > 0)) return;
for (const a of battle.allyUnits) if (isTank(a) && a.ownerId === unit.ownerId) statBuff(battle, a, 'talent:nian:armor', { hpPct: hp });
} },
{ install(battle, unit) { // 干明可鉴: `times` 护盾 layers at every deployment; PRO-X stage 2+: ATK / DEF stack + SP per broken layer
const times = Math.max(0, Math.floor(num(t1.times)));
if (!(times > 0)) return;
const atk = num(t1.atk), def = num(t1.def), sp = num(t1.sp), cap = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1)));
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
unit.mem.nianLayers = times;
battle.addBuff(unit, { key: SHIELD, shieldHits: times, visible: true, tags: ['talent'] });
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, n: times });
}, { owner: unit });
if (!(atk || def || sp)) return;
battle.on('damaged', (c) => {
if (c.target !== unit || !unit.alive) return;
const left = unit.findBuff(SHIELD)?.shieldHits ?? 0;
const broken = (unit.mem.nianLayers ?? 0) - left;
if (!(broken > 0)) return;
unit.mem.nianLayers = left;
for (let i = 0; i < broken; i++) {
if (atk || def) battle.addBuff(unit, { key: BREAK, refresh: 'stack', stacks: 1, maxStacks: cap, mods: nz({ atkPct: atk, defPct: def }), visible: true, tags: ['talent'] });
giveSp(unit, sp);
}
battle.fx('shieldBreak', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
} },
],
install(battle, unit) {
// PRO-X 混沌阵列: 阻挡敌人时防御力+20%
const blockDef = num(tb.def);
if (blockDef) toggleBuff(battle, unit, 'trait:nian:block', () => unit.blocking.length > 0, { defPct: blockDef });
// PRO-Y stage 2+: per 重装 operator on the field max HP +max_hp and healing received +heal_scale_addition (≤ the cap)
const perHp = num(tb.max_hp), healAdd = num(tb.heal_scale_addition), stackCap = Math.floor(num(tb.max_stack_cnt));
if ((perHp > 0 || healAdd > 0) && stackCap > 0) {
const healBase = num(tb.heal_scale, 1), healMax = num(tb.heal_scale_max_value, Infinity);
battle.on('tick', () => {
const cur = unit.findBuff(FORT);
const n = up(unit) ? Math.min(stackCap, battle.alliesFor(unit).filter(isTank).length) : 0;
if ((cur?.data?.n ?? 0) === n) return;
if (!(n > 0)) { battle.removeBuff(unit, FORT); return; }
battle.addBuff(unit, { key: FORT, mods: nz({ hpPct: perHp * n, healingTakenMul: Math.min(healMax, healBase + healAdd * n) }), data: { n }, tags: ['trait'] });
}, { owner: unit });
}
// S2 铜印: every enemy damage instance she takes (the official inverse_damage[magic] + nian_s_2 on ON_TAKE_DAMAGE, not
// its attacks only: tier1 byEnemyAttack) ⇒ atk_scale × ATK arts back to its source + silence `silence` s
const scale = num(b2.atk_scale), sil = num(b2.silence);
battle.on('damaged', (c) => {
const src = c.source;
if (c.target !== unit || !unit.alive || unit.skill?.id !== S2 || !unit.skill.active) return;
if (!byEnemyAttack(c) || !src.alive) return;
if (scale > 0) battle.dealDamage(unit, src, { amount: unit.s.atk * scale, type: 'arts', canDodge: false, isSkill: true, ignoreSelect: true, tags: ['skill', 'counter'] });
if (sil > 0 && src.alive) battle.applyStatus(src, 'silence', { duration: sil, source: unit });
battle.fx('counter', { x: src.x, y: src.y, id: unit.id });
}, { owner: unit });
},
};
},
};