feedback5 O2: 自选 operator kits for 艾雅法拉, 刻俄柏 and 霍尔海雅 — every skill, talent, trait and module (stages 1 and 3) at every form, with tests

- kits/ops/op-amgoat.js (艾雅法拉): S1 二重咏唱 (ATK from the second cast of a deployment), S2 点燃 (main target's RES cut
  first, ½ explosion r1.5 on all, ½ again on the main target), S3 火山 (x-3, −1.1 s, lava on random 4 / 5 enemies), 炎息
  aura (CCR-X Lv3 携带时 22 %), 乱火 deploy SP (CCR-Y Lv3 ASPD, maxima with an elite / leader in range), CCR-X RES ignore,
  CCR-Y SP per hit on an elite / leader.
- kits/ops/op-cerber.js (刻俄柏): S1 unblocked first + 束缚, S2 interval ×0.6 / ×0.4 highest DEF, S3 3-3 physical lowest DEF
  + silence, 剥壳 DEF × ratio arts (CCR-X Lv3 ramp 50 → 75 %), 独行长路 x-5 incl. PRTS's enemy-like-ally rule (CCR-Y Lv3:
  decided per deployment).
- kits/ops/op-heyak.js (霍尔海雅): S1 two targets / single-target 浮空, S2 nine random shots with 浮空 rolls, S3 three
  whirlwinds (PRTS speed 1, 1x1 box, 5 tiles, 1.77 s ramp, away rules), 传承终焉 (lifts resolve first), 曾有羽翼 失重 / CCR-Y
  Lv3 法术脆弱; ISW-A: attributes and 传承终焉 numbers only (its trait and hidden talents are validInGameTag roguelike).
- kits/index.js: the three files appended to OPERATOR_KIT_FILES (one line per file).
- Golden: only the diy family moved — diy-003 … diy-006 re-packed and diy-007 … diy-013 new, because the diy family fields
  every kitted owned 6★ in every form × module × skill (the three new operators, then the prototypes); diy-001 / 002 and
  every other family (roster, bonds, fields, matches, standins) byte-identical. test/golden/README.md counts updated
  (13 diy battles, 61 of 157 fast).
- Reported, not changed: 艾雅法拉 S3's data trigger is SKILL_RANGE (攻击范围增大 escapes build-data's ATTACK_RANGE_CHANGE).
This commit is contained in:
sganggs committed 2026-10-06 00:00:14 +08:00
1 parent 742f478526
commit f4b826fe28
9 files changed
+2020 -103

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
// server/sim/content/kits/ops/op-amgoat.js — 艾雅法拉 (char_180_amgoat) 自选 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_180_amgoat, 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; every module part is
// valid outside 集成战略 — no `validInGameTag`) and PRTS 艾雅法拉 (S1 备注 "每次部署艾雅法拉时,重新计算该技能的使用次数";
// S2 备注 "爆炸范围为半径1.5", "技能实际效果为先削减主攻击目标的法术抗性,再对爆炸范围内所有敌人造成½倍率的伤害(包括主攻击目标),
// 最后对主攻击目标额外造成一次½倍率的伤害", 法术抗性 "{{**|80%|-20%}}" = a multiplier; S3 "攻击间隔大幅度缩短(-1.1)";
// 乱火's hidden 备注 "在最小值与最大值之间随机生成一个浮点数后向下取整").
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “错过的声音”
// adds "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “宠物大赛第一名”
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader — the S2
// explosion's splash and its second ½ are not further 命中 [ASSUMED]; a running timed skill takes no SP, as always).
// - T1 炎息 "在场时,所有友方【术师】职业干员的攻击力+14%": an aura on every 【术师】 operator of the field (her included) while
// she is deployed (installAura, the kits' convention for field-wide talents; several sources keep the strongest). CCR-X
// stage 2+ turns it into "携带时…+22%" (the module talent's bb): the same aura whether she is on the field or not — she
// is carried while she belongs to the battle, knocked out or waiting to redeploy included.
// - T2 乱火 "部署后立即随机获得7~15点技力" (bb sp_min 7, sp_max 16): floor(uniform [sp_min, sp_max)) SP at every deployment
// (PRTS hidden 备注). CCR-Y stage 3 adds "并随机提升6~15的攻击速度" (attack_speed_min / _max, the same draw) for that
// deployment [ASSUMED: no duration given — until she leaves the field], and with `factor` "部署后范围内存在精英或领袖敌人时,
// 获得的技力和提升的攻击速度取最大值": an elite or leader enemy in her attack range at that deployment gives the text's
// maxima, 15 SP / 15 ASPD — the blackboard maxima are the exclusive bounds of the draw (stage 2's attack_speed_max 1
// with no ASPD in its text says so) [ASSUMED: 取最大值 = the largest value the draw yields; checked at the deployment
// instant — at the battle start no enemy is on the field yet].
// - S1 二重咏唱 (MANUAL, 25 s, data DEFAULT): ASPD +[a].attack_speed; from the second cast of a deployment on, ASPD
// +[b].attack_speed and ATK +[b].atk (PRTS 备注: the count restarts at every deployment).
// - S2 点燃 (AUTO, 可充能 2, data DEFAULT): the next attack, on impact — the main target's RES ×(1 + magic_resistance) for
// `duration` s first, then an explosion of radius 1.5 around it (a 中点判定 splash) dealing atk_scale × ATK arts to
// every enemy in it, the main target included, each of the others then getting the same RES cut, and last
// atk_scale_2 × ATK arts to the main target again (the PRTS 备注's order; it names the main target's cut only as
// coming first, so the others' cut follows their hit [ASSUMED]; the cut is "同名效果取最高" through applyStrongest).
// The explosion reaches air units and skips enemies an area effect cannot select (foesInRadius); like the engine's
// profession splash it can be dodged.
// - S3 火山 (MANUAL, 15 s): ATK +atk, attack interval base_attack_time (−1.1 s flat on her 1.6 s), the attack range x-3
// while it runs, each attack throws lava at attack@max_target enemies of that range drawn at random (all of them when
// fewer) — one projectile each, no splash [ASSUMED: the text names none]. Trigger: the data's SKILL_RANGE on
// x-3 — its text says "攻击范围增大", which tools/build-data.mjs ATTACK_RANGE_CHANGE does not list, so the owner's
// ACTIVE_RANGE rule did not reach it (reported; the kit keeps the data rule).
// - No summon (the forms list no token).
import { num, talentBb, traitBb, skillRec, statBuff, installAura, batMod, giveSp, onHitBy } from '../shared/tier1.js';
import { hasHp } from '../../../damage.js';
import { canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
const S1 = 'skchr_amgoat_1';
const S2 = 'skchr_amgoat_2';
const S3 = 'skchr_amgoat_3';
/** S2 点燃: the explosion radius (PRTS 备注 "爆炸范围为半径1.5"; no blackboard key). */
const IGNITE_RADIUS = 1.5;
/** S3 火山's 技能范围 x-3 when the data carries no grid (range_table x-3: the tiles within 3 steps). */
const X3 = Object.freeze([[3, 0], [2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -3], [0, -2], [0, -1],
[0, 0], [0, 1], [0, 2], [0, 3], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1], [-3, 0]]);
/** Damage of the S2 explosion and its second ½ (not a further 命中 of the attack: CCR-Y counts the first). */
const IGNITE_TAG = 'amgoat:ignite';
const AURA_KEY = 'talent:amgoat:casters';
const AURA_EVERY = 0.5;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 【术师】 operator (summons are never operators). */
const isCaster = (a) => !!a && a.kind === 'op' && a.def?.profession === 'CASTER';
/** An elite or leader enemy (精英或领袖敌人). */
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
/** A module trait that works 在集成战略中 only (ISW modules): nothing of it applies in this mode. */
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
/** Named talent `i` of the record (its desc tells the module stage's wording). */
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
/** "携带时": the aura of installAura, held while she belongs to the battle — on the field or not. */
function carriedAura(battle, unit, { key, select, mods, value }) {
battle.every(AURA_EVERY, () => {
for (const a of battle.alliesFor(unit)) {
if (!select(a)) continue;
const cur = a.findBuff(key);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key, duration: AURA_EVERY + 0.1, mods, source: unit, data: { v: value }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
}
export default {
char_180_amgoat: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const carried = /携带时/.test(String(talentRec(chess, 0)?.desc ?? ''));
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const mr = num(b2.magic_resistance), resDur = num(b2.duration);
const resCut = (battle, unit, e) => {
if (e && e.alive && mr) battle.applyStrongest(e, 'amgoat:ignite', { duration: resDur, value: mr, mods: (v) => ({ resMul: Math.max(0, 1 + v) }), source: unit });
};
const maxLava = Math.max(1, Math.floor(num(b3['attack@max_target'], 1)));
return {
skills: {
[S1]: {
kind: 'duration',
onStart({ battle, unit }) {
// the casts of this deployment (PRTS 备注: "每次部署…重新计算该技能的使用次数")
const n = unit.mem.amgoatDuetSeq === unit.deploySeq ? num(unit.mem.amgoatDuetN) + 1 : 1;
unit.mem.amgoatDuetSeq = unit.deploySeq;
unit.mem.amgoatDuetN = n;
const mods = n >= 2
? { aspd: num(b1['amgoat_s_1[b].attack_speed']), atkPct: num(b1['amgoat_s_1[b].atk']) }
: { aspd: num(b1['amgoat_s_1[a].attack_speed']) };
battle.addBuff(unit, { key: 'skill:amgoat:duet', mods, tags: ['skill'] });
},
onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:amgoat:duet'); },
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
// the engine's hit on the main target is its share of the explosion (½); the RES cut came first (install)
atkScale: num(b2.atk_scale, 1),
onEachHit({ battle, unit, target, kind }) {
if (kind !== 'main' || !target) return;
const atk = unit.s.atk, x = target.x, y = target.y;
for (const e of battle.foesInRadius(x, y, IGNITE_RADIUS, true)) {
if (e === target) continue;
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale, 1), type: 'arts', isAttack: true, isSplash: true, isSkill: true, tags: [IGNITE_TAG] });
resCut(battle, unit, e);
}
if (hasHp(target)) battle.dealDamage(unit, target, { amount: atk * num(b2.atk_scale_2, num(b2.atk_scale, 1)), type: 'arts', isAttack: true, isSkill: true, tags: [IGNITE_TAG] });
battle.fx('explosion', { x, y, id: unit.id, r: IGNITE_RADIUS, dmgType: 'arts' });
},
},
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
// every enemy of x-3 is a candidate; install draws the lava targets among them
targeting: { rangeGrid: s3?.rangeGrid ?? X3, allInRange: true },
},
},
talents: [
{ install(battle, unit) { // 炎息: the field's 【术师】 ATK +atk — while she is deployed, or (CCR-X stage 2+) carried
const a = num(t0.atk);
if (!(a > 0)) return;
const opts = { key: AURA_KEY, value: a, select: isCaster, mods: { atkPct: a } };
if (carried) carriedAura(battle, unit, opts);
else installAura(battle, unit, { ...opts, interval: AURA_EVERY });
} },
{ install(battle, unit) { // 乱火: SP (and with CCR-Y stage 3 ASPD) at every deployment; the maxima with an elite in range
const spLo = num(t1.sp_min), spHi = num(t1.sp_max), asLo = num(t1.attack_speed_min), asHi = num(t1.attack_speed_max);
const maxRule = num(t1.factor) > 0;
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || !unit.alive) return;
// (a scan of the enemies: the tile index of enemiesInKeys is the last tick's)
const top = maxRule && battle.enemies.some((e) => isEliteEnemy(e) && canTargetEnemy(unit, e, unit.profile) && bodyInKeys(e, unit.rangeKeySet));
// floor of a uniform draw in [lo, hi); "取最大值" = the largest value the draw yields (hi − 1 for integer bounds)
const draw = (lo, hi) => (hi > lo ? (top ? Math.ceil(hi) - 1 : Math.floor(battle.rng.range(lo, hi))) : 0);
const sp = draw(spLo, spHi);
if (sp > 0) giveSp(unit, sp);
const as = draw(asLo, asHi);
if (as > 0) battle.addBuff(unit, { key: 'talent:amgoat:wildfire', mods: { aspd: as }, tags: ['talent'] });
}, { owner: unit });
} },
],
install(battle, unit) {
// module traits (none on the normal form): CCR-X 无视目标10点法术抗性, CCR-Y SP per hit on an elite / leader
if (!roguelikeOnly(chess)) {
const tb = traitBb(chess);
const pen = num(tb.magic_resist_penetrate_fixed);
if (pen > 0) statBuff(battle, unit, 'trait:amgoat:resIgnore', { resIgnoreFlat: pen });
const sp = num(tb.sp);
if (sp > 0) {
battle.on('damaged', (ctx) => {
const d = ctx.dmg;
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || tagged(d, IGNITE_TAG) || !isEliteEnemy(ctx.target)) return;
giveSp(unit, sp, 'trait');
}, { owner: unit });
}
}
const sid = unit.skill?.id;
if (sid === S2) {
// 点燃: "先削减主攻击目标的法术抗性" — the main target's RES cut lands before the first damage of the attack
onHitBy(battle, unit, (ctx) => {
const d = ctx.dmg;
if (d.isAttack && d.isSkill && !d.isSplash && !tagged(d, IGNITE_TAG)) resCut(battle, unit, ctx.target);
}, 50);
} else if (sid === S3) {
// 火山: "随机对攻击范围内至多N个敌人发射熔岩" — a random draw among every enemy of the range
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill?.active || ctx.targets.length <= maxLava) return;
ctx.targets = battle.rng.shuffle(ctx.targets.slice()).slice(0, maxLava);
}, { owner: unit });
}
},
};
},
};