feedback5: merge O13 — 自选 kits for 假日威龙陈, 莱伊 (with her 沙地兽) and 阿斯卡纶; golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 01:48:44 +08:00
commit 9602fd5425
8 files changed
+2282 -102

No files matched your search

+3
View File
@@ -121,6 +121,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-vvana.js',
'op-headb2.js',
'op-hodrer.js',
'op-chen2.js',
'op-ray.js',
'op-ascln.js',
]);
/**
+260
View File
@@ -0,0 +1,260 @@
// 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. Potential 0 [ASSUMED: no
// account]. 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" (bb attack_speed / attack_speed_add / cnt): ASPD +8, +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 +8, +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 });
}
}
},
};
},
};
+282
View File
@@ -0,0 +1,282 @@
// server/sim/content/kits/ops/op-chen2.js — 假日威龙陈 (char_1013_chen2) 自选 operator kit: 6★ 散射手 (狙击), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (RPR-X 沙滩战斗套装, RPR-Y 假期的
// 最后一天) at every form. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_1013_chen2): 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 (zh_CN, as built into backups.json); PRTS 假日威龙陈
// (节约风气 修正 and 备注, 假日余韵 备注, S2 / S3 备注, the RPR-X stage-1 note); PRTS 作战机制 §地形TAG; PRTS 分支特性信息 散射手
// ("可对空", the trait's 1-3 front grid); gamedata_const ba.charged (蓄力); the client's battle data read from the local
// install — charpack char_1013_chen2 (the S2 / S3 modes: an attack plus a RangeAttack firing projectile_chr_chen2_s2 / _s3),
// battle/prefabs [uc]projectiles projectile_chr_chen2_s2 / _s3, buff_template_data chen2_s_debuff, chen2_s_2_enhance_judge,
// chen2_tr, chen2_t_002[spareShot], chen2_e_003_t[map_tag].
// - Trait (散射手) "攻击范围内的所有敌人,对自己前方一横排的敌人攻击力提升至150%": the reaperrange profile — every enemy of
// her range, air units too, ×atk_scale (trait bb; chen2_tr AtkScaleUp) on the trait's front grid (her tile and the three
// ahead). RPR-X overrides it with atk_scale 1.6; RPR-Y adds "部署费用减少" (cost −8, in the stats: her redeploy after a
// knock-out costs 8 DP less). Ranged physical arrows; blocks 1; ground enemies target her (no flag).
// - T1 节约风气 "在场时自身弹药类技能攻击时20%概率不消耗对应弹药,其他【狙击】干员的弹药类技能改为10%概率不消耗(同类效果取最
// 高)": while she is on the field, an attack made by a running 弹药类技能 (PRTS 备注: a skill whose text says "攻击装有X发
// 弹药 / 子弹" — never the 猎手 bullets) spends no ammo with that chance: hers spareshot_chen.prob, another 【狙击】
// operator's 20 % — PRTS 修正 (原因 "描述与游戏实际表现不符合", and the RPR-X stage-1 note "实际应为…其他【狙击】干员…20%"):
// the game gives the other snipers what she has, spareshot_chen.prob, not the text's / `prob`'s 10 % [the mechanism is
// ASSUMED; the 20 % is PRTS's]. RPR-X stage 3 (talent change; chen2_t_002[spareShot]: 【狙击】 profession ⇒ [sniper],
// else deploy class RANGED ⇒ [other]): herself e_spareshot_chen.prob 25 %, other 【狙击】 operators 20 %, other 远程
// operators 12 %. 同类效果取最高: the highest chance of every 假日威龙陈 on the field. One roll per attack ("一次性消耗多发弹药
// 时仅进行一次判定"); a spared attack spends nothing of what it would ("强制改为实耗0发"), and every `ammoUsed` of it still
// fires ("不影响以'消耗弹药'为效果触发条件的侦测": the bullet is put back in that event, before the skill checks for its last
// bullet) — 拉特兰 / garrison counters see it.
// - T2 假日余韵 "攻击速度+8,当场地中存在水地形时改为攻击速度+12" ([common].attack_speed 8, [map].attack_speed +4). 水地形 is
// a map TAG (PRTS 作战机制 §地形TAG): no level of this mode carries one (the act1 / act2 autochess level files' mapData.tags
// are null), so +8. RPR-Y stage 3 "攻击力+15%,攻击速度+12,当场地中存在水地形时改为攻击力+28%,攻击速度+20,技能开启时,场地
// 视为水地形": +15 % / +12, and while her skill runs the field holds the water TAG (chen2_e_003_t[map_tag]: SetRuntimeMapTag
// water at ON_SKILL_START, cleared at ON_SKILL_FINISH / ON_OWNER_FINISH — one flag of the level, no count: the first
// holder whose skill ends clears it) — +28 % / +20 for every 假日威龙陈 of that field. The module row's [map] keys are the
// text's totals (20 / 0.28), the base row's [map] 4 the step over [common] 8 (8 + 4 = 12): read so.
// - S1 高压冲击 (AUTO, attack SP, data DEFAULT — an attack skill): ATK +atk, 4 bullets (attack@trigger_time), every enemy of
// her range takes the trait's front-row multiplier ("对攻击范围内的所有敌人应用特性加成", as 送葬人 S1).
// - S2 “堇青之夜” (MANUAL, data DEFAULT): ATK +atk, 8 bullets (attack@trigger_time); 蓄力 (maxChargeTime 2;
// chen2_s_2_enhance_judge: ≥ 2 charges at the cast ⇒ the enhanced mode, then ClearCharacterSp; ba.charged): cast with both
// charges stored ⇒ attack@another_trigger_time (20) bullets; every cast empties the SP. Every attack leaves slime (below).
// - S3 “假日风暴” (MANUAL, data ACTIVE_RANGE on its 2-6): range 2-6 while it runs, ATK +atk, every attack strikes twice
// (MultiMeleeAttack _additionalTimes 1) and takes the trait multiplier on every enemy, leaves slime; 32 bullets, 2 per
// attack — PRTS 备注 "即使剩余弹药数小于2发仍可'消耗2发弹药'进行攻击;技能仅在弹药数归零后自动结束" (one `ammoUsed` per bullet).
// - Slime (S2 / S3 "每次攻击在范围内生成持续5秒的粘液,地面敌人经过时移动速度-N%、防御力-M(不叠加)"): each attack's RangeAttack
// fires a projectile that lands at once on her own ground mount point in her direction and lives
// attack@projectile_life_time s; every 0.2 s its range selector (ground enemies, `_ignoreTargetFree`: PRTS 备注 "无视无法选
// 择类效果(如隐匿与无敌)") gives chen2_slime for 0.25 s — move speed FINAL_SCALER ×(1 + attack@move_speed), DEF
// FINAL_ADDITION attack@def. Here: the tiles of her attack range at the attack hold slime for the life time (a later attack
// refreshes them) [ASSUMED: the projectile's selector range is her attack range — the prefab names no grid; its effect is
// "chen2_skill_02_range_01"], pulsed every 0.2 s; 不叠加 — one effect per 假日威龙陈 (independentCharacterSource: two of
// them each slow); the DEF cut is compensated for the target's DEF multipliers (a final addition). It outlives her: the
// projectiles are not hers to clear [ASSUMED]. Air units never (地面敌人). (Each struck ground enemy's own 0.1 s
// chen2_s_debuff from the attack is inside that range anyway.)
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
import { bodyKeys } from '../../../body.js';
import { aggregateMods } from '../../../buffs.js';
const S1 = 'skchr_chen2_1';
const S2 = 'skchr_chen2_2';
const S3 = 'skchr_chen2_3';
/** PRTS 节约风气 备注: a 弹药类技能 is one whose text says "攻击装有X发弹药" or "攻击装有X发子弹". */
const AMMO_SKILL = /攻击装有\d+发(?:弹药|子弹)/;
/** projectile_chr_chen2_s2 / _s3 `_interval` (the slime's selection period) and chen2_slime's lifeTime. */
const SLIME_IV = 0.2;
const SLIME_DUR = 0.25;
/** Buff key of a 假日威龙陈's slime effect on an enemy (independentCharacterSource: one per source). */
export const slimeKey = (src) => `chen2:slime#${src.id}`;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
// ---- T1 节约风气: one battle-wide pair of handlers over every 假日威龙陈 of the battle ------------------------------------
/** battle → { srcs: Map(unit → { self, sniper, other }) }. */
const SPARE = new WeakMap();
/** The spare chance of `u`'s ammo-skill attack: the highest of the 假日威龙陈 on the field (同类效果取最高). */
export function spareChance(battle, u) {
const st = SPARE.get(battle);
if (!st || !u) return 0;
const op = u.kind === 'op';
let p = 0;
for (const [s, v] of st.srcs) {
if (!up(s)) continue;
const q = u === s ? v.self : op && u.def?.profession === 'SNIPER' ? v.sniper : op && u.def?.position === 'RANGED' ? v.other : 0;
if (q > p) p = q;
}
return Math.min(1, p);
}
function installSpare(battle, unit, probs) {
let st = SPARE.get(battle);
if (!st) {
st = { srcs: new Map() };
SPARE.set(battle, st);
// the roll: when an attack is made by a running 弹药类技能, before its bullets are spent (skills.js onAttackPerformed
// runs after the `attack` hook); one roll per attack (no draw when nobody's chance applies)
battle.on('attack', (ctx) => {
const u = ctx.attacker, sk = u?.skill;
if (!u || u.side !== 'ally' || !sk || !sk.active || sk.kind !== 'ammo' || !AMMO_SKILL.test(String(sk.def?.description ?? ''))) return;
const p = spareChance(battle, u);
u.mem.chen2Spare = { t: battle.time, n: u.stats.attacks, proc: p > 0 && battle.rng.chance(p) };
if (u.mem.chen2Spare.proc) battle.fx('reload', { x: u.x, y: u.y, id: u.id });
}, { priority: -50 });
// the spared attack's every bullet goes back in its own ammoUsed event (the event itself still fires)
battle.on('ammoUsed', (ctx) => {
const u = ctx.unit, m = u?.mem?.chen2Spare, sk = ctx.skill;
if (!m || !m.proc || m.t !== battle.time || m.n !== u.stats.attacks || !sk || !sk.active || sk.kind !== 'ammo') return;
sk.ammoLeft += 1;
}, { priority: 100 });
}
st.srcs.set(unit, probs);
}
// ---- 水地形 TAG (RPR-Y stage 3): the level's one runtime flag ----------------------------------------------------------
/** battle → { on } (SetRuntimeMapTag water: no count — the last set / clear wins). */
const WATER = new WeakMap();
/** Whether the field of `battle` counts as water terrain now (no level of the mode has the TAG of its own). */
export const waterOn = (battle) => !!WATER.get(battle)?.on;
function setWater(battle, on) {
let s = WATER.get(battle);
if (!s) WATER.set(battle, (s = { on: false }));
s.on = on;
}
/**
* 假日余韵's two lines: { common: { atkPct, aspd }, water: { atkPct, aspd } }. The base row's [map] value is the step over
* [common] (8 + 4 = the text's 12); a module row's [map] values are the text's totals (RPR-Y: 改为 +28 % / +20).
*/
export function holidayLines(t) {
const bb = t?.bb ?? {};
const ca = num(bb['chen2_t_2[common].atk']), cs = num(bb['chen2_t_2[common].attack_speed']);
const ma = num(bb['chen2_t_2[map].atk']), ms = num(bb['chen2_t_2[map].attack_speed']);
const total = !!t?.fromModule;
return { common: { atkPct: ca, aspd: cs }, water: total ? { atkPct: ma || ca, aspd: ms || cs } : { atkPct: ca + ma, aspd: cs + ms } };
}
// ---- slime (S2 / S3): one battle-wide pulse over every 假日威龙陈's slimed tiles ------------------------------------------
/** battle → Map(source unit → Map(tileKey → { until, ms, def })). */
const SLIME = new WeakMap();
/** A source's slimed tiles (tile key → { until, ms, def }), for tests. */
export const slimeOf = (battle, src) => SLIME.get(battle)?.get(src) ?? null;
/** The enemy's DEF multiplier from every buff but `key`'s ((1 + Σ defPct) × Π defMul): a FINAL_ADDITION is divided by it. */
function defFactor(e, key) {
const { add, mul } = aggregateMods(e.buffs.filter((b) => b.key !== key));
return Math.max(0, 1 + (add.defPct ?? 0)) * (mul.defMul ?? 1);
}
function slimeState(battle) {
let m = SLIME.get(battle);
if (m) return m;
m = new Map();
SLIME.set(battle, m);
// no owner: the slime outlives the unit that left it [ASSUMED]
battle.every(SLIME_IV, () => {
const now = battle.time;
for (const [src, tiles] of m) {
for (const [k, p] of tiles) if (p.until <= now + 1e-9) tiles.delete(k);
if (!tiles.size) continue;
const key = slimeKey(src);
for (const e of battle.enemies) {
// 地面敌人 only; 无视无法选择 — no targetability check (stealth, invulnerable)
if (!e.alive || e.hidden || !e.deployed || e.isFlying) continue;
let p = null;
for (const k of bodyKeys(e)) { p = tiles.get(k) ?? null; if (p) break; }
if (!p) continue;
const f = defFactor(e, key);
battle.addBuff(e, {
key, duration: SLIME_DUR, source: src, tags: ['skill', 'slime'],
mods: { moveMul: Math.max(0, 1 + p.ms), defFlat: f > 1e-6 ? p.def / f : p.def },
});
}
}
});
return m;
}
/** S2 / S3 onAttack: the tiles of her attack range hold her slime for life s (refreshed by every attack). */
function leaveSlime(battle, unit, b) {
const life = num(b['attack@projectile_life_time'], 5), ms = num(b['attack@move_speed']), def = num(b['attack@def']);
if (!(life > 0) || (!ms && !def) || !unit.rangeKeys) return;
const m = slimeState(battle);
let tiles = m.get(unit);
if (!tiles) m.set(unit, (tiles = new Map()));
const fresh = !tiles.size;
const until = battle.time + life;
for (const k of unit.rangeKeys) tiles.set(k, { until, ms, def });
if (fresh) battle.fx('zone', { x: unit.x, y: unit.y, radius: 2, dur: life, id: unit.id, skill: 'chen2:slime' });
}
export default {
char_1013_chen2: (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);
const t1 = (chess?.talents ?? []).filter((t) => t && t.index !== -1)[1] ?? null;
const lines = holidayLines(t1);
const waterSkill = /技能开启时,场地视为水地形/.test(String(t1?.desc ?? ''));
const frontAll = (battle, u) => num(u.profile?.frontScale, 1.5);
const spare = {
self: num(t0['e_spareshot_chen.prob'], num(t0['spareshot_chen.prob'])),
sniper: num(t0['chen2_t_002[spareshot][sniper].prob'], num(t0['spareshot_chen.prob'])),
other: num(t0['chen2_t_002[spareshot][other].prob']),
};
const s2Charged = count(b2['attack@another_trigger_time'], 0);
return {
skills: {
[S1]: {
kind: 'ammo',
ammo: count(b1['attack@trigger_time'], 4),
mods: { atkPct: num(b1.atk) },
attack: { dmgMul: frontAll },
},
[S2]: {
kind: 'ammo',
ammo: count(b2['attack@trigger_time'], 8),
mods: { atkPct: num(b2.atk) },
attack: {}, // (its attacks are the skill's: isSkill)
onStart({ unit, skill }) {
// 蓄力: cast with every charge stored (activate took one) ⇒ the charged bullets; any cast spends all the SP
const charged = skill.maxCharges > 1 && skill.charges + 1 >= skill.maxCharges;
unit.mem.chen2Charged = charged;
if (charged && s2Charged > skill.ammoLeft) skill.addAmmo(s2Charged - skill.ammoLeft);
skill.charges = 0;
skill.sp = 0;
},
onAttack({ battle, unit }) { leaveSlime(battle, unit, b2); },
onEnd({ unit }) { unit.mem.chen2Charged = false; },
},
[S3]: {
kind: 'ammo',
ammo: count(b3['attack@trigger_time'], 32),
mods: { atkPct: num(b3.atk) },
targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid.map((p) => [p[0], p[1]]) } : undefined,
attack: { hits: 2, dmgMul: frontAll },
onAttack({ battle, unit, skill }) {
leaveSlime(battle, unit, b3);
// 每次攻击消耗2发: the second bullet (the engine spends the other one); with one left she still attacks
if (skill.ammoLeft > 1) {
skill.ammoLeft--;
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
}
},
},
},
talents: [
{ install(battle, unit) { // 节约风气
if (spare.self > 0 || spare.sniper > 0 || spare.other > 0) installSpare(battle, unit, spare);
} },
{ install(battle, unit) { // 假日余韵: +ASPD (+ATK), the water line while the field counts as water
const KEY = 'talent:chen2:holiday';
const apply = () => {
const w = waterOn(battle);
if (unit.mem.chen2Water === w && unit.findBuff(KEY)) return;
unit.mem.chen2Water = w;
const l = w ? lines.water : lines.common;
const mods = {};
if (l.atkPct) mods.atkPct = l.atkPct;
if (l.aspd) mods.aspd = l.aspd;
if (Object.keys(mods).length) battle.addBuff(unit, { key: KEY, mods, persist: true, allowDead: true, tags: ['talent'] });
else battle.removeBuff(unit, KEY);
};
apply();
battle.on('tick', apply, { owner: unit });
if (!waterSkill) return;
// RPR-Y stage 3 "技能开启时,场地视为水地形" (chen2_e_003_t[map_tag]): set at her skill's start unless she holds it,
// cleared when her skill ends or she leaves — if she set it
const clear = () => { if (unit.mem.chen2WaterSet) { unit.mem.chen2WaterSet = false; setWater(battle, false); apply(); } };
battle.on('skillStart', (ctx) => {
if (ctx.unit !== unit || unit.mem.chen2WaterSet) return;
unit.mem.chen2WaterSet = true;
setWater(battle, true);
apply();
}, { owner: unit });
battle.on('skillEnd', (ctx) => { if (ctx.unit === unit) clear(); }, { owner: unit });
battle.on('death', (ctx) => { if (ctx.unit === unit) clear(); }, { owner: unit });
} },
],
};
},
};
+320
View File
@@ -0,0 +1,320 @@
// server/sim/content/kits/ops/op-ray.js — 莱伊 (char_4117_ray) 自选 operator kit: 6★ 猎手 (狙击), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (HUN-X 《跳舞的月光》, HUN-Y 证明什么?) at
// every form, and the kit of her summon 沙地兽 (token_10034_ray_sndbst). Kit contract and the 自选 rules: ../README.md
// ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4117_ray): 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 / the token's character_table row (zh_CN, as
// built into backups.json) and PRTS 莱伊 (巡哨伙伴 / 入神 / S1 / S2 / S3 备注), PRTS 沙地兽 (备注 "持有禁疗、无敌", "退场时返还1
// 个可部署的沙地兽", "莱伊退场时强制撤退场上的沙地兽"; its skill "仅在持有者携带技能2时才会携带"), PRTS 分支特性信息 猎手, BWIKI
// 莱伊 ("装填间隔即为攻击间隔,初始子弹数即为最大子弹数"), PRTS 卫戍协议/帮助 (a placed summon that leaves is deployed again on
// its tile "在满足条件后"); the client's battle data read from the local install — buff_template_data ray_tr / ray_tr_add /
// ray_tr_sub (the magazine), ray_s_1[konckback] (Knockback _useSourceDirection, _decreaseForceLevelWhenNotInDirection 1),
// ray_s_1[kill_add_bullet], ray_t_1[damage_scale] (PHYSICAL DamageScale on targets holding ray_sndbst_aura), ray_s_2[deck],
// ray_s_3[reload] / [check_full] / [sp], ray_sndbst_tr / ray_sndbst_collect, and charpack char_4117_ray.
// - Trait (猎手) "攻击时需要消耗子弹且攻击力提升至120%,不攻击时会缓慢地装填子弹(最多8发)" (trait bb value / atk_scale; the
// hunter profile's ×atk_scale on every bullet attack, skill attacks included). PRTS 分支特性信息 猎手: "入场时持有自身子弹上限
// 数的子弹" — full at every deployment; "存在子弹且攻击范围内存在目标时进行攻击,不存在子弹/子弹未满且攻击范围内无目标时进行
// 装弹,通常情况下每次装填1颗" and "装弹行为属于普通攻击行为,持续时间使用装填间隔,装填间隔默认为自身基础攻击间隔,不受攻速/
// 攻击间隔变化影响": a reload is an action of her attack loop lasting her BASE attack time (1.6 s; ASPD never shortens it),
// loading one bullet when it ends — taken when the magazine is empty (targets or not) or not full with nobody to shoot.
// This kit's own reload (kit trait install / canAttack / afterAttack); the engine's hunter profile (1 bullet per second
// after 1 s without attacking) stays for 雪猎. Can hit air units (PRTS "可对空"), blocks 1, ground enemies target her.
// HUN-X adds "子弹数量为空时下次装填额外加装1发子弹" (trait bb extra_add): a reload begun on an empty magazine loads 1 + that.
// HUN-Y raises the trait to atk_scale 1.33 (and ASPD +5 / +7 in the stats).
// - T1 巡哨伙伴 "可以在攻击范围内部署沙地兽于25秒内侦察一片区域延伸攻击范围,自身优先攻击该区域内的目标且对其造成的物理伤害提
// 高15%" (bb damage_scale; HUN-X stage 3: 20 % and "沙地兽的再部署时间-10秒" — the token's module stats, respawn 20 s).
// PRTS 备注: "攻击范围内" of her attacks, talents and skills means "自己或自己的召唤物的攻击范围内" — while a 沙地兽 scouts,
// the enemies of its x-4 (3×3) are in her range (Battle.setExtraRange) and in her skills' trigger range
// (skill.addTriggerRange); she picks a target of that area before any other (after the ones she blocks); her physical
// damage on an enemy there ×(1 + damage_scale) ("伤害倍率提升": DamageInfo mul).
// The 沙地兽 is a hand piece the player places in her range (data ownerRange): it deploys with the board, scouts for the
// token talent's `duration` (25 s) and then leaves [ASSUMED: the scouting ends with its stay — PRTS "退场时返还1个可部署
// 的沙地兽"]; it is untargetable (its trait text), 无敌 and 禁疗 (PRTS 备注), makes no attack (实体类型 装置) [ASSUMED], and
// is withdrawn when 莱伊 leaves the field (PRTS 备注). A 沙地兽 that left comes back on its tile once its redeploy time
// (×(1 + S2 respawn_time) while 广域警觉 runs) has passed, paying its cost (3 DP), while 莱伊 is on the field.
// - T2 入神 "攻击相同目标时每次攻击提高自身攻击力8%,最多3层" (bb atk / max_stack_cnt; HUN-Y stage 3: 9 %, 4 layers). PRTS 备注:
// "每次攻击前:若目标与上次攻击目标不同,失去之前获得的增益;随后获得一层增益,持续时间无限" — before each attack (the special
// bullet of S1 too; never a reload), lost on a new target, gone when she leaves the field.
// - S1 脱身矢 (MANUAL, 2 charges, data DEFAULT): "立即用额外特殊子弹攻击目标,造成相当于攻击力N%的物理伤害并将其中等力度地推开,
// 若将其击倒则使下次装填额外加装1发子弹". PRTS 备注: "'额外特殊子弹'实际为无需消耗子弹的一次攻击,可受特性加成" — an attack
// at once (Battle.forceAttack; the cast's normal attack follows as usual) at atk_scale × ATK × the trait scale that takes no
// bullet; a 中力 (bb force) push along her facing, radial at 力度 −1 when the target is off it (PRTS "推动方向默认为自身朝
// 向,因角度过大变为径向推力时,力度仅-1": the engine's directional rule with a −1 instead of −2); a kill adds bb cnt bullets
// to the next reload ("额外装填效果可叠加并于下1次装填时一同生效"). With an empty magazine (no attack to wait for) the cast
// comes as soon as an enemy is in range [ASSUMED, as 雪猎's special bullets]. The 地穴 clause has nothing to act on here.
// - S2 广域警觉 (AUTO, attack SP; 持续时间无限 — a toggle): range 4-10, ATK +atk, the 沙地兽's redeploy time ×(1 +
// respawn_time). Self / summon effects only: it fires at full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule).
// Passive "沙地兽撤退时回收命中该区域的子弹": while she carries S2, the bullets of her attacks that hit an enemy inside a
// scouting 沙地兽's area are counted (its own counter: ≤ its trait value 8 — ray_sndbst_tr) and come back to her magazine
// when it leaves, while she is on the field (ray_sndbst_collect → ray_tr_add: HUN-X's extra_add first on an empty
// magazine, capped at her maximum); S1's special bullet is no bullet. PRTS "装弹行为不触发攻击回复": no reload gives
// attack SP.
// - S3 “得见光芒” (MANUAL, 16 s, data ACTIVE_RANGE on its 3-8): "立即停止攻击直至子弹装满,装填间隔大幅缩短(-1.2)": at the cast
// she stops attacking and reloads (a reload under way is cut to the new interval) until her magazine is full, every reload
// while it runs lasting base attack time + reload_interval (0.4 s); range 3-8; every attack attack@atk_scale × ATK
// (× the trait scale) and 束缚 (bind) attack@unmove_duration s; a kill of hers during it ⇒ +sp SP when it ends, through
// 阻回 (PRTS 备注 "获得技力效果无视阻回").
import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js';
import { acquireTargets, effectiveProfile } from '../../../ai.js';
import { sortEnemyTargets } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js';
const S1 = 'skchr_ray_1';
const S2 = 'skchr_ray_2';
const S3 = 'skchr_ray_3';
export const SANDBEAST = 'token_10034_ray_sndbst';
/** 巡哨伙伴 (E2): the 沙地兽's scouting when its def carries no duration (PRTS: "持续25秒"). */
const LIFE_FALLBACK = 25;
/** Seconds between two tries of a 沙地兽 that left to come back (tile busy, DP short, 莱伊 away). */
const RETRY = 0.25;
/** Tag of S1's special bullet (no bullet of the magazine). */
export const SPECIAL_TAG = 'ray:special';
const TRANCE_KEY = 'talent:ray:trance';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** The magazine of the trait ("最多8发"). */
const ammoMax = (unit) => Math.max(1, Math.floor(num(unit.profile?.ammoMax, 8)));
/** First talent of a normalised token def holding `key` (token talents keep no index). */
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
/** The 沙地兽's kit: no attack, 无敌, 禁疗 (its untargetability comes from its trait text, simdata normalizeToken). */
function sandbeastKit() {
return {
skill: null,
trait: { noAttack: true },
talents: [],
install(battle, t) {
battle.addBuff(t, { key: 'ray:sandbeast', flags: { invulnerable: true, noHeal: true }, persist: true, allowDead: true });
},
};
}
export default {
char_4117_ray: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const extraAdd = Math.max(0, Math.floor(num(traitBb(chess).extra_add)));
const picked = chess?.skill?.skillId ?? null;
const tokenId = (chess?.talents ?? []).find((t) => t && t.tokenKey)?.tokenKey ?? SANDBEAST;
/** One reload: her base attack time, S3's reload_interval while it runs (PRTS: ASPD never changes it). */
const reloadInterval = (unit) => {
const s3on = !!(unit.skill && unit.skill.active && unit.skill.id === S3);
return Math.max(0.05, num(unit.base?.bat, 1.6) + (s3on ? num(b3.reload_interval) : 0));
};
const startReload = (unit) => {
const m = unit.mem;
m.rayReloading = true;
if ((unit.trait.ammo ?? 0) <= 0 && extraAdd > 0) m.rayLoadExtra = (m.rayLoadExtra ?? 0) + extraAdd; // HUN-X
unit.atkCd = reloadInterval(unit);
};
const finishReload = (unit) => {
const m = unit.mem;
m.rayReloading = false;
const add = 1 + Math.max(0, Math.floor(num(m.rayLoadExtra)));
m.rayLoadExtra = 0;
unit.trait.ammo = Math.min(ammoMax(unit), (unit.trait.ammo ?? 0) + add);
};
const resetTrait = (unit) => {
unit.trait.ammo = ammoMax(unit);
Object.assign(unit.mem, { rayReloading: false, rayLoadExtra: 0, rayFill: false });
};
return {
trait: {
// 猎手 (PRTS 分支特性信息): her own reload, an action of the attack loop (canAttack runs once the cooldown is over)
install(battle, unit) {
resetTrait(unit);
battle.on('deploy', (ctx) => { if (ctx.unit === unit) resetTrait(unit); }, { owner: unit });
},
canAttack(battle, unit) {
const m = unit.mem;
if (m.rayReloading) finishReload(unit);
const max = ammoMax(unit), ammo = unit.trait.ammo ?? 0;
if (m.rayFill) { // S3: no attack until the magazine is full
if (ammo < max) { startReload(unit); return false; }
m.rayFill = false;
}
if (ammo <= 0 || (ammo < max && !acquireTargets(battle, unit, effectiveProfile(unit)).length)) { startReload(unit); return false; }
return true;
},
afterAttack(battle, unit) { unit.trait.ammo = Math.max(0, (unit.trait.ammo ?? 0) - 1); },
},
skills: {
[S1]: {
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// the special bullet: an attack that spends no bullet (its own afterAttack) and takes the trait scale
attack: {
atkScale: num(b1.atk_scale, 1),
tags: ['skill', SPECIAL_TAG],
afterAttack() {},
onHit({ battle, unit, target }) {
if (!target) return;
if (!target.alive) { unit.mem.rayLoadExtra = num(unit.mem.rayLoadExtra) + Math.max(0, Math.floor(num(b1.cnt, 1))); return; }
const force = num(b1.force, 1);
const [fr, fc] = unit.fwd;
const vx = target.x - unit.x, vy = target.y - unit.y, d = Math.hypot(vx, vy);
if (d >= PUSH_DIRECTIONAL_MIN_DIST && vx * fc + vy * fr >= d * Math.SQRT1_2) battle.push(target, force, { from: unit, dir: { x: fc, y: fr }, fixed: true });
else battle.push(target, force - 1, { from: unit });
},
},
onStart({ battle, skill, unit }) {
if (battle.forceAttack(unit)) return;
// nobody to shoot after all: no cast
skill.pending = false;
skill.addCharge(1);
},
},
[S2]: {
kind: 'toggle',
trigger: 'SP_FULL',
mods: { atkPct: num(b2.atk) },
targeting: s2?.rangeGrid ? { rangeGrid: s2.rangeGrid.map((p) => [p[0], p[1]]) } : undefined,
},
[S3]: {
kind: 'duration',
targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid.map((p) => [p[0], p[1]]) } : undefined,
attack: {
atkScale: num(b3['attack@atk_scale'], 1),
onHit({ battle, unit, target }) {
if (target && target.alive) battle.applyStatus(target, 'bind', { duration: num(b3['attack@unmove_duration'], 2), source: unit });
},
},
onStart({ unit }) {
const m = unit.mem;
m.rayFill = true;
m.rayKilled = false;
// 立即停止攻击: the reload starts now (one under way is cut to the new interval)
unit.atkCd = m.rayReloading ? Math.min(unit.atkCd, reloadInterval(unit)) : 0;
},
onEnd({ unit, reason }) {
const m = unit.mem;
m.rayFill = false;
if (m.rayKilled && reason !== 'death' && up(unit) && unit.skill) unit.skill.gainSp(num(b3.sp), 'init'); // 无视阻回
m.rayKilled = false;
},
},
},
talents: [
{ install(battle, unit) { // 巡哨伙伴 + the 沙地兽 (and S2's bullet recovery)
const m = unit.mem;
const mine = (t) => !!t && t.kind === 'token' && t.defId === tokenId && t.ownerUnit === unit;
// her placed 沙地兽 pieces run the 沙地兽's kit (set up here, before the battle starts — as 鸿雪's 打字机)
for (const t of battle.allyUnits) {
if (!mine(t) || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t);
battle._setupUnit(t, sandbeastKit());
}
const ds = num(t0.damage_scale);
m.rayArea = null;
m.rayBullets = 0;
if (unit.skill) unit.skill.addTriggerRange(() => (m.rayAreaKeys ? [m.rayAreaKeys] : []));
const endArea = () => { m.rayArea = null; m.rayAreaKeys = null; m.rayBeast = null; battle.setExtraRange(unit, null); };
battle.on('deploy', (ctx) => {
const t = ctx.unit;
if (!mine(t)) return;
const life = num(tokTalent(t.def, 'duration')?.bb?.duration, LIFE_FALLBACK);
m.rayBeast = t;
m.rayAreaKeys = [...t.rangeKeys];
m.rayArea = new Set(m.rayAreaKeys);
m.rayBullets = 0;
m.rayCounted = 0;
battle.setExtraRange(unit, m.rayAreaKeys);
const seq = t.deploySeq;
battle.after(life, () => { if (t.alive && t.deploySeq === seq) battle.retreat(t, { reason: 'expired', permanent: true }); }, { owner: unit });
}, { owner: unit });
battle.on('death', (ctx) => {
const t = ctx.unit;
if (t === unit) { // "莱伊退场时强制撤退场上的沙地兽"
for (const x of battle.allyUnits) if (mine(x) && x.alive) battle.retreat(x, { reason: 'retreat', permanent: true });
return;
}
if (!mine(t) || battle.finished) return;
t.removed = false; // the piece stays (Battle.redeploy needs it; its hooks are kept)
if (m.rayBeast === t) endArea();
// S2 passive: the bullets that hit in its area come back (ray_sndbst_collect → ray_tr_add: its count — ≤ its own
// trait value — onto her magazine, HUN-X's extra_add first when she is empty, capped at her maximum)
if (picked === S2 && m.rayBullets > 0 && up(unit)) {
const back = Math.min(m.rayBullets, Math.max(1, Math.floor(num(t.def?.traitBb?.value, ammoMax(unit)))));
const empty = (unit.trait.ammo ?? 0) <= 0 ? extraAdd : 0;
unit.trait.ammo = Math.min(ammoMax(unit), (unit.trait.ammo ?? 0) + empty + back);
}
m.rayBullets = 0;
const left = battle.time;
battle.every(RETRY, (b, sched) => {
if (t.alive || t.removed || b.finished) { sched.cancel(); return; }
const mul = unit.skill && unit.skill.active && unit.skill.id === S2 ? Math.max(0, 1 + num(b2.respawn_time)) : 1;
if (b.time + 1e-9 < left + Math.max(0, num(t.base.respawnTime)) * mul || !up(unit)) return;
if (b.redeploy(t, { free: false })) sched.cancel();
}, { owner: unit });
}, { owner: unit, priority: -10 });
// 自身优先攻击该区域内的目标 (after the enemies she blocks)
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !m.rayArea || !ctx.targets.length) return;
const area = m.rayArea, prof = ctx.profile ?? unit.profile;
const all = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
for (const e of battle.blockedTargets(unit, prof)) if (!all.includes(e)) all.push(e);
if (!all.some((e) => bodyInKeys(e, area))) return;
sortEnemyTargets(battle, unit, all, prof.priority ?? null);
const rank = (e) => (e.blockedBy === unit ? 0 : bodyInKeys(e, area) ? 1 : 2);
const order = all.map((e, i) => ({ e, i, r: rank(e) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.e);
ctx.targets = order.slice(0, ctx.targets.length);
}, { owner: unit, priority: 10 });
// ×(1 + damage_scale) on her physical damage to an enemy of the area
if (ds > 0) {
battle.on('hit', (ctx) => {
if (ctx.source !== unit || !m.rayArea || !ctx.target || ctx.target.side !== 'enemy' || ctx.dmg.type !== 'phys') return;
if (bodyInKeys(ctx.target, m.rayArea)) ctx.dmg.mul *= 1 + ds;
}, { owner: unit });
}
// S2 passive: count the bullets of her attacks that hit an enemy of the area (one per attack)
if (picked === S2) {
battle.on('damaged', (ctx) => {
const d = ctx.dmg, e = ctx.target;
if (ctx.source !== unit || !m.rayArea || !d || !d.isAttack || !e || e.side !== 'enemy' || (d.tags && d.tags.includes(SPECIAL_TAG))) return;
if ((d.attackId && d.attackId === m.rayCounted) || !bodyInKeys(e, m.rayArea)) return;
m.rayCounted = d.attackId;
m.rayBullets++;
}, { owner: unit });
}
} },
{ install(battle, unit) { // 入神: +atk per attack on the same target (≤ max_stack_cnt), lost on a new target
const atk = num(t1.atk), max = Math.max(1, Math.floor(num(t1.max_stack_cnt, 3)));
if (!(atk > 0)) return;
const m = unit.mem;
m.rayTrance = null;
m.rayStacks = 0;
battle.on('deploy', (ctx) => { if (ctx.unit === unit) { m.rayTrance = null; m.rayStacks = 0; } }, { owner: unit });
battle.on('beforeAttack', (ctx) => {
const t = ctx.attacker === unit ? ctx.targets[0] : null;
if (!t) return;
if (m.rayTrance !== t) { m.rayTrance = t; m.rayStacks = 0; }
m.rayStacks = Math.min(max, m.rayStacks + 1);
battle.addBuff(unit, { key: TRANCE_KEY, mods: { atkPct: atk * m.rayStacks }, tags: ['talent'] });
}, { owner: unit, priority: -10 });
} },
],
install(battle, unit) {
// S3: 技能期间若击倒敌人 — a kill of hers while it runs
if (picked === S3) {
battle.on('kill', (ctx) => {
if (ctx.killer === unit && unit.skill && unit.skill.active && unit.skill.id === S3) unit.mem.rayKilled = true;
}, { owner: unit });
}
// S1 with an empty magazine (the attack loop only reloads then): cast once an enemy is in range [ASSUMED]
if (picked !== S1) return;
battle.on('tick', () => {
const sk = unit.skill;
if (!up(unit) || !unit.canAct || !sk || !sk.ready || sk.pending || sk.opCooling || unit.s.flags.silence || unit.s.flags.disarm) return;
if ((unit.trait.ammo ?? 1) > 0) return;
if (acquireTargets(battle, unit, effectiveProfile(unit)).length) sk.activate('DEFAULT');
}, { owner: unit });
},
};
},
};