feedback5 O1: 自选 operator kits 伊芙利特, 谬因 (with her 中继器) and 林 — every skill, talent, trait and module (stage 1 / 3) at every form

- op-ifrit.js: 轰击术师 line; 精神融解 RES ×0.6 aura, BLA-Δ Lv3 +50 % ATK 元素伤害 on burn-bursting targets (attacks and 灼地 ticks);
  莱茵回路 +2 SP / 6 s, BLA-X Lv3 30 % +5; S1 ATK / ASPD; S2 charges, DEF cut + 灼伤 (current ATK, lasts after she leaves);
  S3 维持技能状态 (no attack, ground-only ticks from the Begin clip's event then every 1 s, RES cut, 2 %/s 流失, ended by stun);
  BLA-X distance scaling on all her damage while she stands, BLA-Δ 8 % 灼燃损伤 rider.
- op-aphris.js: her range is the extension path (5 tiles, +2 and turned by her 中继器; S2 unbounded and turned by allied
  operators); the 中继器's lifetime / marks / redeploy (leaves and returns with her, cleared by S3), ATK +25 / 35 % while it
  stands, 取样优化 法术穿透 on allied operators of her path; S1 SP_FULL toggle; S2 beam every 0.5 s (no attack); S3 ammo 20,
  3 s 强制缴械, extra hit + longer 停顿 past a 中继器; BLA-Y cost −8, Lv3 35 s / 20 s / +35 %.
- op-lin.js: 琉璃璧 shield (absorbs ≤ 200, breaks above: stun + burst on her current range, regrows 8 / 6 s), 韬光 per hit
  taken; S1 状态切换 toggle (停顿), S2 琉璃璧 copies for allies in range, S3 x-2 + threshold ×2 / ×2.5 + kill refresh;
  PLX-X keeps DEF +100 % / RES +10 in skills, PLX-Y +3 % per enemy in range (≤ 5).
- Sources: data/backups.json, PRTS (备注), the client's battle data (charpacks, battle/prefabs skills / equips, buff templates)
  and the battle skeletons' clip events; [ASSUMED] items in the kit headers.
- kits/index.js OPERATOR_KIT_FILES += op-ifrit.js, op-aphris.js, op-lin.js; tests test/content/op_ifrit / op_aphris / op_lin
  (10 each).
- Golden: diy only — diy-003 … diy-006 now pack the new kits (they held the prototypes), diy-007 … diy-012 are new (the rest of
  林, then the prototypes repacked into diy-008 … diy-012); diy-001 / 002 (推进之王) and every other family unchanged.
This commit is contained in:
sganggs committed 2026-10-06 00:20:37 +08:00
1 parent 742f478526
commit 541048fed6
8 files changed
+1945 -100

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
// server/sim/content/kits/ops/op-aphris.js — 谬因 (char_4229_aphris) 自选 operator kit: 6★ 轰击术师 (术师), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module and her summon “中继器” at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4229_aphris, 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].
// Summon: tokens.token_10070_aphris_pc “中继器” (talent 链路协议; a hand piece the player places with a facing — data
// `placeable`, a talent summon: it deploys with the board, docs/SIM.md §1.1 token pieces).
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 谬因 (链路协议 备注:
// her range "以自己所在格为起点向部署方向延伸,直至达到延伸距离上限" — 5 tiles from E1 —, "若延伸到了自身的中继器所在的地块,会令延伸
// 距离上限增加(并非增加谬因的攻击距离属性)并改变接下来的延伸方向", "尝试延伸到已延伸到的地块或地图边界外时延伸会提前终止", "索敌与攻击
// 始终采用格子判定", "攻击力增加效果不可叠加"; 取样优化 备注 "无视法抗效果为增加受益者的法术穿透属性(直接加算)"; 连续映射 备注
// "中继器持续时间结束时不再进行自动撤退"; 临界瞬爆 备注 "技能持续期间,自身的攻击范围可无限延伸直至无法继续延伸", "持有孤立的我方干员
// 也可令谬因攻击范围延伸效果转向"; 混沌的本质 备注 "技能开启后3秒内持有强制缴械", "“停顿时间延长”指的是增加本次攻击对该敌人施加的
// 停顿的时间", "技能开始/结束时强制撤回所有中继器", 修正 "清空再部署"); PRTS 中继器 (备注: 无敌, 禁疗, 孤立, 阻挡数 0, 再部署策略
// 队列); PRTS 卫戍协议/帮助 §作战阶段 ("若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"); the client's
// battle data (charpack char_4229_aphris — the range selectors, the talent abilities; battle/prefabs [uc]skills skchr_aphris_1/2/3;
// buff_template_data aphris_token_add_atk_tohost, aphris_pc_t[with-draw], aphris_sk1(_token), aphris_sk2_switch_mode4,
// aphris_sk3 / [token-withdraw] / [on_finish], die_to_kill_token, charge_token[born]).
// - Range (链路协议; her selectors `_extendable`, `_extendValidator` = the 中继器, `_extendLengthEveryValidateChar` 2): a path,
// not her data grid — from her tile along her facing, one tile at a time, up to her 5-1 length; reaching her own 中继器
// adds 2 to the length still allowed and turns the path to that piece's facing; it stops at a tile it already holds or at
// the map's edge. The path is her range (`unit.rangeGrid`, rebuilt when a piece or an operator comes or goes, at her
// skill's start / end, and checked every tick): her attacks, the DEFAULT trigger, 取样优化 and the detail card all read it.
// [ASSUMED] a huge enemy counts on every tile it covers (the engine's selection; PRTS: 格子判定 by its position), her hits
// land at once (the client spaces them `_hitInterval` 0.05 s along the beam) and a 攻击距离 bonus (none exists for her in
// this mode) would extend it as the engine extends any range.
// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her path at once,
// air units too. Module BLA-Y “第三相态”: 部署费用 −8 (the attribute; stats 34 → 26), HP / ATK in the stats, no trait change;
// stage 3 changes T1 (35 s piece, 再部署时间变短 — the piece's byModule respawnTime 30 → 20 —, ATK +35 %).
// - T1 链路协议: one 中继器 lasting the token talent's duration (25 s; BLA-Y stage 3: its hidden module talent, 35 s) from each
// deployment (aphris_pc_t[with-draw]: it withdraws when that time is up unless it holds aphris_sk1_token — given to every
// piece of hers when 连续映射 starts, and to a piece taking the field while 连续映射 or 混沌的本质 runs). Back on its tile its
// respawnTime (30 / 20 s) after it left, paying its 3 DP — the 卫戍 auto redeploy of a placed summon, as 斯卡蒂的海嗣 —, and
// only while 谬因 stands: her leaving the field withdraws it (die_to_kill_token), her (re)deployment makes it ready at once
// (charge_token[born]). ATK +atk while one of hers stands (aphris_token_add_atk_tohost: one buff aphris_t1[atk], "不可叠加").
// The piece "不会受到攻击" (data: untargetable) and holds 无敌, 禁疗 and 孤立 (PRTS 中继器; [ASSUMED] given by this kit — the
// data's `abnormal` lacks them).
// - T2 取样优化 "攻击范围内的友方干员攻击时无视敌方10点法术抗性" (aphris[t2]: attribute 27, 法术穿透, on allied operators — profession
// mask 639 — of her attack range): resIgnoreFlat +magic_resist_penetrate_fixed on every allied operator standing on her path,
// her own tile included [ASSUMED: the selector does not drop its owner], refreshed every AURA_IV s; several sources keep the
// strongest.
// - S1 连续映射 (AUTO, 持续时间无限): ATK +atk (aphris_sk1) and the 中继器 marks above. An AUTO skill acting on herself fires at
// full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule, kits/README.md checklist 5); `toggle`.
// - S2 临界瞬爆 (MANUAL, data DEFAULT, 8 s): mode 4 (aphris_sk2_switch_mode4) — her normal attack is replaced by the beam
// ability (`_preDelay` 0.167 = the Skill_2_Begin clip, then `_triggerDelta` 0.5; MAGICAL atk_scale): from BEAM_FIRST on,
// every `interval` (0.5) s, every selectable enemy on her path takes atk_scale × ATK arts (16 hits in the 8 s). The path
// meanwhile has no length limit (`_infinite`) and turns at her 中继器 and at every allied
// operator it reaches (validators: the piece, allies of profession mask 639 — every operator profession; a 孤立 one too),
// to that unit's facing. The data resolves DEFAULT: the unbounded range is no data range (no rangeId), so build-data's
// ACTIVE_RANGE rule cannot see it (reported, not hand-coded).
// - S3 混沌的本质 (MANUAL, data DEFAULT, 20 发弹药 — `ammo`, attack@trigger_time): for charge_time (3) s from the cast 强制缴械
// (aphris_sk3[sheltering], abnormal flag disarmed) and aphris_sk3[token-withdraw]: every 中继器 of hers withdrawn and its
// redeploy time cleared (ResetTokenRespawnTime — the placed piece is back on its tile at once; the two extra pieces the
// skill allows have no piece to place in the auto battle [ASSUMED: N/A]); attack interval +base_attack_time (−1.1 s on
// 2.9 s), ATK +atk, each attack attack@atk_scale × ATK on her path, and on every enemy it hits the buff aphris_sk3: 停顿
// origin_sluggish + extra_sluggish × n s and n hits of extra_atk_scale × ATK arts, n = the 中继器 the path passed before the
// enemy, at most max_times (1). It ends with its bullets; aphris_sk3[on_finish] withdraws every 中继器 and clears their
// redeploy time again (so the piece is back at once, now with its normal lifetime).
import { num, talentBb, skillRec, up, batMod, toggleBuff } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { dirVec, toLocal } from '../../../dir.js';
const S1 = 'skchr_aphris_1';
const S2 = 'skchr_aphris_2';
const S3 = 'skchr_aphris_3';
/** Her summon “中继器” (talent 链路协议). */
const RELAY = 'token_10070_aphris_pc';
/** Extra path length per 中继器 (the selectors' `_extendLengthEveryValidateChar`; "使该次攻击距离+2"). */
const RELAY_REACH = 2;
/** Talent-2 aura refresh period and buff length (the non-stacking aura convention of the kits). */
const AURA_IV = 0.25;
const AURA_DUR = 0.4;
/** S2 临界瞬爆: the first beam hit (skchr_aphris_2 `_preDelay`, the Skill_2_Begin clip's length), then every `interval` s. */
const BEAM_FIRST = 0.167;
/** How often a withdrawn 中继器 tries to come back once ready (her on the field, its tile free, DP paid). */
const RELAY_RETRY = 0.1;
const LINK_KEY = 'aphris:link';
const SAMPLING_KEY = 'aphris:sampling';
const CHARGE_KEY = 'aphris:charge';
const RELAY_ABNORMAL_KEY = 'aphris:relayAbnormal';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 中继器 of `unit`. */
const isRelayOf = (t, unit) => !!t && t.kind === 'token' && t.defId === RELAY && t.ownerUnit === unit;
const relaysOf = (battle, unit) => battle.allyUnits.filter((t) => isRelayOf(t, unit));
/** 连续映射 / 混沌的本质 running: a 中继器 taking the field now gets the no-withdraw mark (aphris_pc_t[with-draw]). */
const marksRelays = (unit) => !!(unit.skill && unit.skill.active && (unit.skill.id === S1 || unit.skill.id === S3));
/** Her own path length: the farthest forward tile of her data grid (5 on her E2 5-1). */
function baseReach(unit) {
let n = 0;
for (const p of unit.def?.rangeGrid ?? []) if (Array.isArray(p) && p[0] === 0 && p[1] > n) n = p[1];
return n;
}
/** A 中继器's lifetime: the last `duration` of its talents (the BLA-Y stage-3 hidden talent after the base one). */
function relayLife(t) {
let life = 0;
for (const x of t.def?.talents ?? []) if (x && x.bb && x.bb.duration != null) life = num(x.bb.duration, life);
return life;
}
/**
* Her 延伸 path (header): absolute tiles in order from her own, with `index` (tile key → position) and `relays` (the
* positions of the 中继器 tiles it passed). `beam` = S2: no length limit, every allied operator turns it too.
*/
function aphrisPath(battle, unit, beam = false) {
const tiles = [[unit.tileR, unit.tileC]];
const index = new Map([[unit.tileR * COLS + unit.tileC, 0]]);
const relays = [];
let [dr, dc] = dirVec(unit.dir);
let reach = beam ? Infinity : baseReach(unit);
let r = unit.tileR, c = unit.tileC, steps = 0;
while (steps < reach) {
const nr = r + dr, nc = c + dc;
const k = nr * COLS + nc;
if (!battle.grid.inBounds(nr, nc) || index.has(k)) break;
index.set(k, tiles.length);
tiles.push([nr, nc]);
r = nr; c = nc; steps++;
const o = battle.unitAt(nr, nc);
if (o && isRelayOf(o, unit)) {
reach += RELAY_REACH;
[dr, dc] = dirVec(o.dir);
relays.push(tiles.length - 1);
} else if (beam && o && o !== unit && o.kind === 'op' && o.side === 'ally') [dr, dc] = dirVec(o.dir);
}
return { tiles, index, relays };
}
/** Rebuild her range from the path when it changed (the relative grid of the path in her facing-RIGHT frame). */
function refreshPath(battle, unit) {
if (!up(unit)) return;
const beam = !!(unit.skill && unit.skill.active && unit.skill.id === S2);
const p = aphrisPath(battle, unit, beam);
const sig = p.tiles.map(([r, c]) => r * COLS + c).join(',');
unit.mem.aphrisPath = p;
if (unit.mem.aphrisSig === sig) return;
unit.mem.aphrisSig = sig;
unit.rangeGrid = p.tiles.map(([r, c]) => toLocal(r - unit.tileR, c - unit.tileC, unit.dir));
battle.refreshRange(unit);
}
/** How many 中继器 her path passed before `e`'s tile (S3 aphris_sk3 "extendTimes"). */
function relaysBefore(unit, e) {
const p = unit.mem.aphrisPath;
if (!p || !p.relays.length) return 0;
const i = p.index.get(Math.round(e.y) * COLS + Math.round(e.x));
return i == null ? 0 : p.relays.filter((j) => j < i).length;
}
/** Withdraw every 中继器 of hers that stands; `clear` = their redeploy time cleared (ResetTokenRespawnTime). */
function withdrawRelays(battle, unit, clear) {
for (const t of relaysOf(battle, unit)) {
if (t.alive) {
t.mem.relayCleared = clear;
battle.retreat(t, { reason: 'expired', permanent: true });
} else if (clear && !t.removed) t.mem.relayReadyAt = battle.time;
}
}
/** A 中继器 of hers took the field: its abnormal flags and redeploy rule once, its lifetime and mark every time. */
function relayDeployed(battle, unit, t) {
if (!t.mem.aphrisRelay) {
t.mem.aphrisRelay = true;
battle.addBuff(t, { key: RELAY_ABNORMAL_KEY, flags: { invulnerable: true, isolated: true, noHeal: true }, persist: true, allowDead: true });
// back on its tile its respawnTime after it left (at once when its redeploy time was cleared), while she stands,
// paying its DP — a token is otherwise removed for good: the piece is kept (`removed = false`) so its hooks survive
// and `battle.redeploy` accepts it
battle.on('death', (c) => {
if (c.unit !== t || battle.finished) return;
t.removed = false;
t.mem.relayReadyAt = battle.time + (t.mem.relayCleared ? 0 : Math.max(0, num(t.base.respawnTime)));
t.mem.relayCleared = false;
if (t.mem.relayRetry) return;
t.mem.relayRetry = battle.every(RELAY_RETRY, (b, sched) => {
const stop = () => { sched.cancel(); t.mem.relayRetry = null; };
if (t.alive || t.removed || b.finished) { stop(); return; }
if (!up(unit) || b.time + 1e-9 < t.mem.relayReadyAt) return;
if (b.redeploy(t, { free: false })) stop();
}, { owner: t });
}, { owner: t, priority: -10 });
}
t.mem.relayKept = marksRelays(unit); // aphris_pc_t[with-draw] ON_BUFF_START: aphris_sk1_token
const life = relayLife(t);
if (!(life > 0)) return;
const seq = t.deploySeq;
battle.after(life, () => {
if (!t.alive || t.deploySeq !== seq || t.mem.relayKept) return; // 连续映射's mark: "不再进行自动撤退"
battle.retreat(t, { reason: 'expired', permanent: true });
}, { owner: t });
}
export default {
char_4229_aphris: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
return {
skills: {
[S1]: {
kind: 'toggle', trigger: 'SP_FULL', mods: { atkPct: num(b1.atk) },
onStart({ battle, unit }) { for (const t of relaysOf(battle, unit)) if (t.alive) t.mem.relayKept = true; }, // aphris_sk1 → aphris_sk1_token
},
[S2]: {
kind: 'duration',
attack: { noAttack: true },
onStart({ battle, unit }) {
unit.mem.beamAt = 0;
unit.mem.beamNext = BEAM_FIRST;
battle.fx('beam', { x: unit.x, y: unit.y, id: unit.id, skill: 'aphris:beam' });
},
onTick({ battle, unit, skill, dt }) {
const iv = num(b2.interval, 0.5);
if (!(iv > 0)) return;
const t = (unit.mem.beamAt = (unit.mem.beamAt ?? 0) + dt);
while (t >= unit.mem.beamNext - 1e-6 && skill.active && unit.alive) {
unit.mem.beamNext += iv;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'aphris:beam'] });
}
}
},
},
[S3]: {
kind: 'ammo', ammo: num(b3['attack@trigger_time'], 20),
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
attack: {
atkScale: num(b3['attack@atk_scale'], 1),
onEachHit({ battle, unit, target }) { // aphris_sk3 on every enemy the attack hits
if (!target || target.side !== 'enemy' || !target.alive) return;
const max = num(b3['attack@max_times'], 1);
const n = max >= 0 ? Math.min(max, relaysBefore(unit, target)) : relaysBefore(unit, target);
const slow = num(b3['attack@origin_sluggish']) + num(b3['attack@extra_sluggish']) * n;
if (slow > 0) battle.applyStatus(target, 'sluggish', { duration: slow, source: unit });
const ex = num(b3['attack@extra_atk_scale']);
for (let i = 0; i < n && target.alive && ex > 0; i++) battle.dealDamage(unit, target, { amount: unit.s.atk * ex, type: 'arts', isSkill: true, tags: ['skill', 'aphris:relay'] });
},
},
onStart({ battle, unit }) {
const ct = num(b3.charge_time);
if (ct > 0) battle.addBuff(unit, { key: CHARGE_KEY, duration: ct, flags: { disarm: true }, tags: ['skill'] }); // 强制缴械
withdrawRelays(battle, unit, true); // aphris_sk3[token-withdraw]
},
onEnd({ battle, unit }) {
battle.removeBuff(unit, CHARGE_KEY);
withdrawRelays(battle, unit, true); // aphris_sk3[on_finish]
},
},
},
talents: [
{ install(battle, unit) { // 链路协议: ATK +atk while one of her 中继器 stands (one buff however many)
const a = num(t0.atk);
if (a) toggleBuff(battle, unit, LINK_KEY, () => relaysOf(battle, unit).some((t) => t.alive && t.deployed), { atkPct: a });
// die_to_kill_token: her pieces leave with her; charge_token[born]: her (re)deployment makes a waiting one ready
battle.on('death', (c) => { if (c.unit === unit) withdrawRelays(battle, unit, false); }, { owner: unit });
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
for (const t of relaysOf(battle, unit)) if (!t.alive && !t.removed) t.mem.relayReadyAt = battle.time;
}, { owner: unit });
} },
{ install(battle, unit) { // 取样优化: 法术穿透 +v on the allied operators of her path
const v = num(t1.magic_resist_penetrate_fixed);
if (!(v > 0)) return;
battle.every(AURA_IV, () => {
if (!up(unit) || !unit.rangeKeySet) return;
for (const a of battle.alliesFor(unit)) {
if (a.kind !== 'op' || !unit.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue;
const cur = a.findBuff(SAMPLING_KEY);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key: SAMPLING_KEY, duration: AURA_DUR, mods: { resIgnoreFlat: v }, source: unit, data: { v }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
} },
],
install(battle, unit) {
// the 延伸 path: rebuilt on any ally's arrival / departure (her 中继器, an operator S2 bends on), her skill's
// start / end, and checked every tick
battle.on('deploy', (c) => {
const t = c.unit;
if (t === unit) unit.mem.aphrisSig = null;
else if (isRelayOf(t, unit)) relayDeployed(battle, unit, t);
if (t && t.side === 'ally') refreshPath(battle, unit);
}, { owner: unit });
battle.on('death', (c) => { if (c.unit && c.unit.side === 'ally' && c.unit !== unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('skillStart', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('skillEnd', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('tick', () => refreshPath(battle, unit), { owner: unit });
// S3's 强制缴械 from the cast on: the attack loop goes on to the attack the cast was made for (ai.js updateAlly
// checks `disarm` before the cast only) — no target, no attack, no bullet spent
battle.on('beforeAttack', (c) => { if (c.attacker === unit && unit.findBuff(CHARGE_KEY)) c.targets = []; }, { owner: unit });
},
};
},
};