Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-ascln.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +08:00

261 lines
16 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-ascln.js — 阿斯卡纶 (char_4132_ascln) 自选 operator kit: 6★ 伏击客 (特种), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (AMB-X “无形,无情”, AMB-Y
// “阿斯卡纶的眼睛”) at every form. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4132_ascln): 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. Full potential (the owner's
// decision of 2026-10-07). Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS
// 阿斯卡纶 (死亡拘审 备注 and its {{**}} 最终乘算 (100−18×层数)%, S2 修正 / 备注, S3 备注, the AMB-X note), PRTS 分支特性信息
// 伏击客 ("嘲讽等级为-1"), PRTS 命中率, Arknights Terra Wiki "Ascalon" (死亡拘审: "The MSPD reduction and Arts damage stack
// additively"); the client's battle data read from the local install — buff_template_data ascln_t_1 / ascln_t_1[debuff]
// (ON_OUTPUT_DAMAGE of NORMAL attacks; STACK, the duration refreshed; per trigger buff count × atk_ratio × ATK, MAGICAL,
// attack type BUFF), ascln_t_2 (CheckHeightTypeInRange x-5 ≥ cnt), ascln_s_2[enemy] (ON_OWNER_KILLED of a WALK enemy:
// the debuff in a circle of range_radius on the WALK_ONLY enemies but the dead one), ascln_s_3[self] (heal on
// ON_EVADE_DAMAGE and on a HIT_FAILED damage), ascln_e_003_t_1[heal] (a heal when the enemy holding it finishes).
// - Trait (伏击客) "对攻击范围内所有敌人造成伤害;拥有50%的物理和法术闪避且不容易成为敌人的攻击目标": the stalker profile — every
// enemy of her range at once, melee, ground only (no "可对空"), physical — and 50 % physical / arts dodge (trait bb prob;
// AMB-Y: 65 %). "不容易成为敌人的攻击目标" is her taunt level −1, which her data stats already carry (PRTS 伏击客 "嘲讽等级为
// -1"): the kit trait installs the dodge without the profile's own extra −1 (professions.js installStalker adds one on
// top of the data's — a double count reported for 水月, whose kit keeps the profile; not changed here).
// AMB-X adds "攻击范围内所有敌人移动速度-20%" (hidden talent move_speed): every enemy of her current range, air units too,
// moves ×(1 + move_speed); PRTS "多个伏击客X模组之间的减速效果可叠加" — one slow per 阿斯卡纶 (her own key).
// - T1 死亡拘审 "攻击对敌人施加效果:移动速度降低18%,每秒受到10%阿斯卡纶当前攻击力的法术伤害,持续25秒,效果最多叠加三层"
// (bb move_speed / atk_ratio / max_stack_cnt / debuff_duration / interval): every damage instance of her attacks (S1's two
// strikes are two attacks) adds a layer (≤ max_stack_cnt) and resets the duration (PRTS 备注); the target moves
// ×(1 − 0.18 × layers) (PRTS 最终乘算 (100−18×层数)%) and takes layers × atk_ratio × her CURRENT ATK as arts damage every
// `interval` s (Terra Wiki: both parts stack additively; PRTS 伤害分类 法术持续伤害 — not dodgeable, tag 'dot'); "自身退场后,
// 所有已施加的本天赋效果将立刻结束". One effect per 阿斯卡纶 [ASSUMED]. AMB-X stage 3: 11 %, 30 s (the talent change).
// AMB-Y stage 3 adds "拥有该效果的敌人被击倒时阿斯卡纶回复10%生命" (hp_ratio): a heal of hp_ratio × her max HP whoever knocks
// such an enemy out, while she is on the field.
// - T2 噬光残影 "攻击速度+8,自身周围四格有高台时,攻击速度额外+6" (full potential: +10; bb attack_speed / attack_speed_add / cnt): ASPD +10, +6 more
// while at least `cnt` of the four tiles next to her are 高台 (heightType HIGHLAND in the level files — the 高台 deploy
// tiles and the HIGHLAND forbidden tiles), judged at each deployment.
// - S1 追袭 (AUTO, 2 / 3 charges, data DEFAULT — a "next attack" skill): the next attack at atk_scale × ATK and "连续攻击两
// 次": every enemy of her range struck twice (bb cnt is the charge count, not the strikes).
// - S2 恩赐 (MANUAL, 35 s, data DEFAULT): ATK +atk; PRTS 修正 (原因 "描述与游戏实际表现不符合"): "使攻击范围内所有敌人移动速度
// -N%" — every enemy of her range, air units too, moves ×(1 − |move_speed|) (refreshed every AURA_IV s, 同名 keeps the
// strongest — two 阿斯卡纶 do not stack [ASSUMED]) — and "在地面敌人被击倒时对周围地面敌人施加一层第一天赋效果": a ground
// enemy of her range knocked out (by anyone [ASSUMED]) gives one 死亡拘审 layer to every selectable ground enemy within
// range_radius (PRTS 备注 "尸爆半径1.3", a 中点判定 around it).
// - S3 降临 (MANUAL, 45 s, data ACTIVE_RANGE on its y-10): range y-10, ATK +atk, base attack time + base_attack_time s
// (−1.2 / −1.5 on 3.5 s), taunt level +taunt_level (PRTS 备注 "从-1提升至1"); ground enemies of her range have their
// 物理 / 法术命中率 −N % (PRTS 命中率: an attack of that damage type rolls once when it starts and misses as a whole —
// every target and hit of it; here one roll per enemy attack at its first damage instance, a battle-wide `hit` handler
// as 娜仁图亚's / Raidian's, the strongest of the 阿斯卡纶 whose range holds the attacker; several kinds of 命中率 cuts roll
// apart [ASSUMED]); "敌人未命中自身或自身闪避时回复5%最大生命值": attack@hp_ratio × her max HP for each enemy attack her cut
// makes miss her and each dodge of hers, while it runs (a heal: no 生命回复速度 note).
import { num, talentBb, moduleBb, traitBb, skillRec, up, batMod } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { hasHp } from '../../../damage.js';
const S1 = 'skchr_ascln_1';
const S2 = 'skchr_ascln_2';
const S3 = 'skchr_ascln_3';
/** Refresh period / length of the range auras (S2's slow, AMB-X's slow): the kits' non-stacking aura convention. */
const AURA_IV = 0.25;
const AURA_DUR = 0.4;
/** S3's 命中率 handler runs before the enemy content's own hit riders (priority 200), after Raidian's / 娜仁图亚's (300). */
const MISS_PRIORITY = 290;
/** Tag of the 死亡拘审 ticks (法术持续伤害). */
export const DREAD_TAG = 'ascln:dread';
const ANY = Object.freeze({ canHitFly: true });
/** The four tiles next to her (offsets: direction does not matter). */
const ADJ4 = Object.freeze([[1, 0], [-1, 0], [0, 1], [0, -1]]);
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** Buff key of her 死亡拘审 on an enemy (one per 阿斯卡纶). */
export const dreadKey = (src) => `ascln:dread#${src.id}`;
/**
* One layer of 死亡拘审 from `src` on enemy `e` (talent blackboard `tb`): ≤ max_stack_cnt layers, the duration reset,
* move ×(1 − ms × layers), layers × atk_ratio × src's current ATK arts every interval s while src is on the field.
*/
export function addDread(battle, src, e, tb) {
if (!e || !e.alive || e.side !== 'enemy' || !hasHp(e)) return null;
const key = dreadKey(src);
const max = Math.max(1, Math.floor(num(tb.max_stack_cnt, 3)));
const ms = Math.abs(num(tb.move_speed)), dur = num(tb.debuff_duration, 25), iv = num(tb.interval, 1), ratio = num(tb.atk_ratio);
if (!(dur > 0)) return null;
const old = e.findBuff(key);
const n = Math.min(max, (old?.data?.n ?? 0) + 1);
const mods = { moveMul: Math.max(0, 1 - ms * n) };
if (old) {
old.data.n = n;
old.mods = mods;
old.timeLeft = dur;
old.duration = dur;
e.markDirty();
return old;
}
return battle.addBuff(e, {
key, duration: dur, mods, source: src, interval: iv > 0 ? iv : 1, data: { n }, tags: ['talent', 'dot'],
onTick: ({ battle: b, unit: t, buff }) => {
if (!up(src)) { b.removeBuff(t, buff); return; }
if (ratio > 0) b.dealDamage(src, t, { amount: src.s.atk * ratio * buff.data.n, type: 'arts', canDodge: false, tags: ['talent', 'dot', DREAD_TAG] });
},
});
}
// ---- S3 命中率: one battle-wide handler over every 阿斯卡纶 of the battle ---------------------------------------------------
/** battle → { srcs: [{ unit, phys, arts, heal }], last: WeakMap(enemy → { id, miss }) }. */
const MISS = new WeakMap();
const s3On = (u) => !!(u.skill && u.skill.active && u.skill.id === S3);
function missCut(st, e, type) {
let cut = 0;
for (const s of st.srcs) {
const v = type === 'arts' ? s.arts : s.phys;
if (v > cut && up(s.unit) && s3On(s.unit) && s.unit.rangeKeySet && bodyInKeys(e, s.unit.rangeKeySet)) cut = v;
}
return Math.min(1, cut);
}
function missHit(battle, st, ctx) {
const e = ctx.source, d = ctx.dmg;
if (!e || e.side !== 'enemy' || e.isFlying || !d || !d.isAttack || d.cancel || (d.type !== 'phys' && d.type !== 'arts')) return;
// one roll per attack: every damage instance of it shares the first instance's result
const prev = d.attackId ? st.last.get(e) : null;
let miss, first = false;
if (prev && prev.id === d.attackId) miss = prev.miss;
else {
first = true;
const cut = missCut(st, e, d.type);
miss = cut > 0 && battle.rng() < cut;
if (d.attackId) st.last.set(e, { id: d.attackId, miss });
}
if (!miss) return;
d.cancel = true;
ctx.stopPropagation = true;
const t = ctx.target;
if (t) battle.fx('dodge', { x: t.x, y: t.y, id: t.id });
// 敌人未命中自身: the 阿斯卡纶 it aimed at heals (once per missed attack)
if (first && t) for (const s of st.srcs) if (s.unit === t && up(t) && s3On(t) && s.heal > 0) battle.heal(t, t, t.s.maxHp * s.heal, { self: true });
}
function installMiss(battle, unit, phys, arts, heal) {
if (!(phys > 0) && !(arts > 0)) return;
let st = MISS.get(battle);
if (!st) {
st = { srcs: [], last: new WeakMap() };
MISS.set(battle, st);
// no owner: it serves every 阿斯卡纶 of the battle, whichever is on the field
battle.on('hit', (ctx) => missHit(battle, st, ctx), { priority: MISS_PRIORITY });
}
st.srcs.push({ unit, phys, arts, heal });
}
export default {
char_4132_ascln: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s3 = skillRec(chess, S3);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hidden = moduleBb(chess);
const dodge = num(traitBb(chess).prob, 0.5);
const picked = chess?.skill?.skillId ?? null;
return {
trait: {
// 伏击客: the dodge; the taunt level −1 is in her stats (no second −1)
install(battle, unit) {
const p = num(unit.profile?.dodge, dodge);
if (p > 0) battle.addBuff(unit, { key: 'trait:stalker', mods: { dodgePhys: p, dodgeArts: p }, persist: true, allowDead: true });
},
},
skills: {
[S1]: {
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: { atkScale: num(b1.atk_scale, 1), hits: 2 },
},
[S2]: {
kind: 'duration',
mods: { atkPct: num(b2.atk) },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess), taunt: num(b3.taunt_level) },
targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid.map((p) => [p[0], p[1]]) } : undefined,
},
},
talents: [
{ install(battle, unit) { // 死亡拘审 (+ AMB-Y stage 3: a heal when an enemy carrying it is knocked out)
battle.on('damaged', (ctx) => {
const d = ctx.dmg, e = ctx.target;
if (ctx.source !== unit || !up(unit) || !d || !d.isAttack || !e || e.side !== 'enemy' || d.type === 'element') return;
addDread(battle, unit, e, t0);
}, { owner: unit });
const key = dreadKey(unit);
battle.on('death', (ctx) => {
const u = ctx.unit;
if (u === unit) { // 自身退场后,所有已施加的本天赋效果将立刻结束
for (const e of battle.enemies) if (e.findBuff(key)) battle.removeBuff(e, key);
return;
}
const hr = num(t0.hp_ratio);
if (hr > 0 && u.side === 'enemy' && ctx.reason === 'killed' && up(unit) && u.findBuff(key)) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true });
}, { owner: unit });
} },
{ install(battle, unit) { // 噬光残影: ASPD +10, +6 with 高台 among the four tiles next to her
const base = num(t1.attack_speed), add = num(t1.attack_speed_add), cnt = Math.max(1, Math.floor(num(t1.cnt, 1)));
const KEY = 'talent:ascln:shadow';
const apply = (r, c) => {
let n = 0;
for (const [dr, dc] of ADJ4) {
const rr = r + dr, cc = c + dc;
if (battle.grid.inBounds(rr, cc) && battle.grid.tile(rr, cc).height === 'HIGH') n++;
}
const v = base + (n >= cnt ? add : 0);
if (v) battle.addBuff(unit, { key: KEY, mods: { aspd: v }, persist: true, allowDead: true, tags: ['talent'], data: { high: n } });
};
apply(unit.homeR ?? unit.tileR, unit.homeC ?? unit.tileC);
battle.on('deploy', (ctx) => { if (ctx.unit === unit) apply(unit.tileR, unit.tileC); }, { owner: unit });
} },
],
install(battle, unit) {
// AMB-X: every enemy of her range moves ×(1 + move_speed) (one slow per 阿斯卡纶: they stack — PRTS)
const mx = num(hidden.move_speed);
if (mx < 0) {
const key = `ascln:ambx#${unit.id}`;
battle.every(AURA_IV, () => {
if (!up(unit)) return;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, ANY)) battle.addBuff(e, { key, duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + mx) }, source: unit, tags: ['module'] });
}, { owner: unit, immediate: true });
}
if (picked === S2) {
const ms = Math.abs(num(b2.move_speed)), radius = num(b2.range_radius, 1.3);
const slow = () => {
if (!up(unit) || !unit.skill?.active) return;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, ANY)) {
battle.applyStrongest(e, 'ascln:s2slow', { duration: AURA_DUR, value: ms, mods: (v) => ({ moveMul: Math.max(0, 1 - v) }), source: unit });
}
};
if (ms > 0) {
battle.on('skillStart', (ctx) => { if (ctx.unit === unit) slow(); }, { owner: unit });
battle.every(AURA_IV, slow, { owner: unit });
}
// a ground enemy of her range knocked out ⇒ one 死亡拘审 layer on the selectable ground enemies within the radius
battle.on('death', (ctx) => {
const v = ctx.unit;
if (ctx.reason !== 'killed' || !v || v.side !== 'enemy' || v.isFlying || !up(unit) || !unit.skill?.active || !unit.rangeKeySet || !bodyInKeys(v, unit.rangeKeySet)) return;
battle.fx('aoe', { x: v.x, y: v.y, radius, id: unit.id, skill: 'ascln:endowment' });
for (const e of battle.foesInRadius(v.x, v.y, radius, true)) {
if (e !== v && !e.isFlying && canTargetEnemy(unit, e, ANY)) addDread(battle, unit, e, t0);
}
}, { owner: unit });
}
if (picked === S3) {
const heal = num(b3['attack@hp_ratio']);
installMiss(battle, unit, Math.abs(num(b3['attack@damage_hitrate_physical'])), Math.abs(num(b3['attack@damage_hitrate_magical'])), heal);
// 自身闪避时回复…最大生命值
if (heal > 0) {
battle.on('dodge', (ctx) => {
if (ctx.target !== unit || !up(unit) || !s3On(unit) || !ctx.source || ctx.source.side !== 'enemy') return;
battle.heal(unit, unit, unit.s.maxHp * heal, { self: true });
}, { owner: unit });
}
}
},
};
},
};