Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-shu.js
T

286 lines
20 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-shu.js — 黍 (char_2025_shu) 自选 operator kit: 6★ 守护者 (重装), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_2025_shu, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, GUA-X 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); PRTS 黍 (百谷长青 备注
// "本天赋效果为永久地块效果,自身退场时清空所有地块的播种效果,不对装置职业的目标生效。生命恢复的提供方式为增加位于地块上干员的
// “生命回复速度”属性,不受治疗加成和禁疗影响"; 天有四时 修正 "在场时" / "编入队伍且"; S1 修正 "小于等于一半"; S2 备注 "实际效果为技能
// 期间普通攻击改为治疗友方单位(受攻速和缴械影响,非停止攻击),“特殊间隔”指攻击间隔+1.3"; S3 备注 "技能持续期间视野扩大至治疗范围,
// 自身普通攻击同时触发治疗,无攻击目标时普通攻击可直接进行治疗", the 【播种标记】 and its teleport, "攻击力/攻击速度提升效果无视孤立");
// PRTS 卫戍协议/帮助 §技能操作 (黍【离离枯荣】 "技能范围内存在可治疗的我方单位时释放技能"); the client's battle data: buff templates
// shu_t_1 / shu_t_1[char] / shu_farm_buff[char] / shu_s_2 / shu_s_3 / shu_s_3[in_range] / shu_s_3[heal_in_attack] /
// shu_s_3[enemy][enter|leave|mark] / heal_scale_up[hpratio][LE], her charpack (the S2 / S3 modes' Attack / Heal
// abilities, DoFarm / EnemyListener tile abilities and their selectors, the 天有四时 auras and deck part) and the skill
// prefabs skchr_shu_1..3; Arknights Terra Wiki "Shu".
// - Trait (守护者) "技能可以治疗友方单位": her normal attack is a melee physical strike (ground-only, block 3, range 0-1 — her own
// tile); only her skills heal. Module GUA-X “钦天司时” "治疗生命值低于50%的友方单位时治疗量提升15%" (trait bb heal_scale /
// hp_ratio): every heal of hers on a target at or below hp_ratio ×heal_scale (the client template is heal_scale_up
// [hpratio][LE]: at or below — the text says 低于), never an HP-regen tick.
// - T1 百谷长青 (shu_t_1): every heal she outputs (S1 / S2 / S3; never a regen tick) sows the healed unit's tile and the four
// next to it (DoFarm, range x-5) — tiles that can be deployed on or walked (the tile selector: buildable or passable). A
// sown tile stays sown until she leaves the field (death / retreat clears them all). Every allied unit standing on one —
// operators and summons, not 装置 (professionMask 767) nor a 孤立 unit (no ignoreTargetFree) — has 生命回复速度
// +hp_recovery_per_sec (an hpRegen buff, checklist 11: 禁疗 / 无法被友方治疗 do not stop it — 斥罪 too) and 庇护
// damage_resistance (the shared applyStrongest key PROTECT), refreshed every FARM_EVERY s (and at once on a sowing and at
// S2's start / end); both × S2's extra_extend_scale while S2 runs (shu_s_2 sets DoFarm's extra_extend_scale). Two 黍 of a
// shared field: the stronger regeneration and 庇护 hold. GUA-X stage 3: 80 / s, 15 % and "部署时立即给所处地块播种该效果"
// (bbStr born_range_id 0-1: her own tile at every deployment).
// - T2 天有四时: while she is deployed, ≥ PROF_NEEDED different professions among the allied operators on the field ⇒ every
// allied operator's max HP +max_hp; ≥ PROF_NEEDED operators of one profession ⇒ ASPD +attack_speed (charpack auras:
// operators only, 孤立 included); if her squad (her player's operators, deployed or not) holds ≥ SUI_NEEDED 【岁】 operators
// (character_table groupId 'sui': 年 夕 令 重岳 黍 余 望) ⇒ every operator of the squad ATK +atk for the battle and +sp SP
// every `interval` s of each deployment (modify_sp[trigger]; an SP gift — a 决战者's 阻回 refuses it), whether she is
// deployed or not (Terra). The counts 3 / 4 exist in the text and the charpack only (minCount / minCntToTrig).
// - S1 化被草木 (AUTO, time SP, 可充能 1 / 2 次; data DEFAULT): the client prefab is 塞雷娅 S1 急救's (a SkillTrigger on an
// ally of HP ≤ half — _maxHpRatio 0.5 — _cancelIfSearchTargetFailed), whose PRTS 备注 reads "此技能仅在周围有符合血量条件的友方
// 单位时可触发,触发时会替换当次攻击": the engine's DEFAULT with `allies` / `hpAtMost` on the skill range x-4 — at her attack,
// with an ally of x-4 at ≤ half HP, that attack heals the lowest such ally for heal_scale × ATK instead (as 塞雷娅's kit).
// - S2 嘉禾盈仓 (MANUAL; data DEFAULT — the owner's 重装 exception of 2026-10-05, rawRule TAKE_DAMAGE): `duration` s, her
// attacks become heals of up to attack@max_target injured allied units on the skill range x-1 (her range meanwhile:
// _rangeIdModeIndex 1) for ATK, ATK +atk, block +block_cnt, attack interval +base_attack_time s (a flat +1.3 on her 1.2
// s; ASPD and 缴械 still count), T1 ×extra_extend_scale.
// - S3 离离枯荣 (MANUAL): her range becomes the skill range x-2 (_rangeIdModeIndex 2; "视野扩大至治疗范围"), ATK +atk; every
// attack on an enemy also heals the most injured allied unit of that range other than herself for ATK
// (shu_s_3[heal_in_attack]: the Heal selector excluding her); with no enemy to attack her attack heals the most injured
// allied unit of the range, herself included (the mode's Attack-else-Heal ability). While a ground enemy stands on a
// sown tile every allied operator of her range — 孤立 ones too — ATK +e_atk and ASPD +e_attack_speed (× min(enemies,
// e_max_stack_cnt)); a ground enemy that enters a sown tile is marked with that tile (once) and each time it then enters
// a tile at a Manhattan distance ≥ max_distance from it, it is put back on that tile's centre (unblocked, its route
// re-planned from there; waypoints already passed stay passed), stealth or 无敌 notwithstanding; a 消失 (hidden) enemy loses
// its mark and every mark ends with the skill (the client attaches it to shu_s_3). No enemy of the mode holds
// 免疫传送 (data otherImmunities). Trigger: the data's — her own row TRY_SEARCH_ALLY_SKILL (PRTS "技能范围内存在可治疗的我方
// 单位时释放技能"), which tools/build-data.mjs (TRIGGER_ALLY_RULES) writes as the engine's SKILL_RANGE + `allies` on the
// skill range x-2 (an injured, healable allied unit there, herself included; 古米 S1's option).
import { num, traitBb, skillRec, batMod, up, giveSp, holdProtect } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { effectiveProfile, acquireTargets, performAttack } from '../../../ai.js';
const S1 = 'skchr_shu_1';
const S2 = 'skchr_shu_2';
const S3 = 'skchr_shu_3';
/** The skill ranges when the data carries none: x-4 (S1, 周围), x-1 (S2), x-2 (S3) — range_table. */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
const X1 = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2],
[-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]);
/** shu_t_1 sows the healed unit's tile and the four next to it (range x-5, symmetric). */
const SOW = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]);
/** Range ids a talent's bbStr may name (GUA-X stage 3 born_range_id '0-1': her own tile). */
const RANGE_IDS = Object.freeze({ '0-1': Object.freeze([[0, 0]]) });
/** S1 "血量不足一半" — PRTS 修正 小于等于, the prefab's _maxHpRatio 0.5. */
const HALF_HP = 0.5;
/** T1's tile effect refresh and hold. */
const FARM_EVERY = 0.25;
const FARM_HOLD = 0.35;
/** T2 "三名不同 / 相同职业干员", "四名【岁】干员" (the charpack's minCount / minCntToTrig; no blackboard key). */
const PROF_NEEDED = 3;
const SUI_NEEDED = 4;
/** 【岁】: character_table groupId 'sui' (data/backups.json diy.operators powers carry it for the 自选 picks). */
const SUI = new Set(['char_2014_nian', 'char_2015_dusk', 'char_2023_ling', 'char_2024_chyue', 'char_2025_shu', 'char_2026_yu', 'char_2027_wang']);
const FARM = 'talent:shu:farm';
const HARVEST = 'skill:shu:harvest';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const gridOf = (chess, id, fallback) => { const g = skillRec(chess, id)?.rangeGrid; return g?.length ? g : fallback; };
/** The talent record with data index `i` (null when absent). */
const talentRec = (chess, i) => (chess?.talents ?? []).find((t) => t && t.index === i) ?? null;
/** The tile key of a unit's position (an enemy: its 判定中心). */
const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
/** An allied unit her heals and tiles may take: on the field, not a device. */
const onField = (a) => !!a && a.alive && a.deployed && !a.hidden && a.kind !== 'device';
export default {
char_2025_shu: (bb, chess) => {
const tb = traitBb(chess);
const r0 = talentRec(chess, 0), t0 = r0?.bb ?? {}, t1 = talentRec(chess, 1)?.bb ?? {};
const born = RANGE_IDS[r0?.bbStr?.born_range_id] ?? null;
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1);
const g1 = gridOf(chess, S1, X4), g2 = gridOf(chess, S2, X1), g3 = gridOf(chess, S3, X2);
/** The sown tiles of her current deployment (a Set of tile keys). */
const sown = (unit) => {
if (unit.mem.shuSownSeq !== unit.deploySeq || !unit.mem.shuSown) { unit.mem.shuSown = new Set(); unit.mem.shuSownSeq = unit.deploySeq; }
return unit.mem.shuSown;
};
/** T1's tile effect on every allied unit standing on a sown tile (× S2's extra_extend_scale while it runs). */
const farm = (battle, unit) => {
if (!up(unit)) return;
const tiles = sown(unit);
if (!tiles.size) return;
const scale = unit.skill?.active && unit.skill.id === S2 ? num(b2.extra_extend_scale, 1) : 1;
const hp = num(t0.hp_recovery_per_sec) * scale, dr = num(t0.damage_resistance) * scale;
for (const a of battle.allyUnits) {
if (!onField(a) || !tiles.has(a.tileR * COLS + a.tileC) || !battle.allySelectable(a, unit)) continue;
const cur = a.findBuff(FARM);
if (hp > 0 && !(cur && cur.source !== unit && (cur.data?.v ?? 0) > hp && cur.timeLeft > 0.05)) {
battle.addBuff(a, { key: FARM, duration: FARM_HOLD, mods: { hpRegen: hp }, source: unit, data: { v: hp }, tags: ['talent'] });
}
holdProtect(battle, a, dr, FARM_HOLD, unit); // 庇护: the shared effect (同名效果取最高)
}
};
/** Sow the tiles of `grid` around (r, c) — only tiles that can be deployed on or walked. Returns the new ones. */
const sow = (battle, unit, r, c, grid) => {
const tiles = sown(unit);
let n = 0;
for (const [dr, dc] of grid) {
const rr = r + dr, cc = c + dc;
if (!battle.grid.inRect(rr, cc)) continue;
const t = battle.grid.tile(rr, cc);
if (!t || (t.build === 'NONE' && t.pass !== 'ALL')) continue;
const k = rr * COLS + cc;
if (!tiles.has(k)) { tiles.add(k); n++; }
}
if (n > 0) { battle.fx('healField', { x: c, y: r, id: unit.id }); farm(battle, unit); }
return n;
};
/** S3: the most injured healable allied unit of her current range (`others`: not herself). */
const healTarget = (battle, unit, others) => {
const list = battle.injuredAlliesInKeys(unit.rangeKeySet || new Set(unit.rangeKeys || []), unit);
return others ? list.find((a) => a !== unit) ?? null : list[0] ?? null;
};
return {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
trigger: { rule: 'DEFAULT', grid: g1, allies: true, hpAtMost: HALF_HP },
targeting: { rangeGrid: g1 },
attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(b1.heal_scale, 1), projectile: 'none' },
},
[S2]: {
kind: 'duration',
mods: { atkPct: num(b2.atk), blockCnt: num(b2.block_cnt), batPct: batMod(b2.base_attack_time, chess) },
targeting: { rangeGrid: g2, maxTargets: Math.max(1, Math.floor(num(b2['attack@max_target'], 2))) },
attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: 1, projectile: 'none' },
onStart({ battle, unit }) { farm(battle, unit); },
onEnd({ battle, unit }) { farm(battle, unit); },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
targeting: { rangeGrid: g3 },
onStart({ unit }) { unit.mem.shuMarks = new Map(); },
onAttack({ battle, unit, targets }) {
// shu_s_3[heal_in_attack]: an attack on an enemy also heals the most injured allied unit of her range but her
if (!targets?.length || targets[0].side !== 'enemy') return;
const a = healTarget(battle, unit, true);
if (a) battle.heal(unit, a, unit.s.atk * unit.s.atkScaleMul);
},
onTick({ battle, unit }) {
if (!up(unit)) return;
const tiles = sown(unit);
// 有地面敌人处于播种地块时: the allied operators of her range ATK / ASPD + (孤立 ones too)
let onSown = 0;
for (const e of battle.enemies) if (e.alive && !e.hidden && !e.isFlying && tiles.has(tileKey(e))) onSown++;
const stacks = Math.min(onSown, Math.max(1, Math.floor(num(b3.e_max_stack_cnt, 1))));
if (stacks > 0) {
const keys = unit.rangeKeySet || new Set(unit.rangeKeys || []);
for (const a of battle.allyUnits) {
if (a.kind !== 'op' || !onField(a) || !keys.has(a.tileR * COLS + a.tileC)) continue;
battle.addBuff(a, { key: HARVEST, duration: 0.1, mods: { atkPct: num(b3.e_atk) * stacks, aspd: num(b3.e_attack_speed) * stacks }, source: unit, tags: ['skill'] });
}
}
// 【播种标记】: a ground enemy entering a sown tile records it; ≥ max_distance (Manhattan) away it is put back there
const marks = unit.mem.shuMarks ?? (unit.mem.shuMarks = new Map());
const far = Math.max(1, num(b3.max_distance, 4));
for (const e of battle.enemies) {
const m = marks.get(e.id);
if (!e.alive || e.hidden) { if (m) marks.delete(e.id); continue; }
const k = tileKey(e);
if (!m) {
if (!e.isFlying && tiles.has(k)) marks.set(e.id, { r: Math.round(e.y), c: Math.round(e.x), last: k });
continue;
}
if (k === m.last) continue;
m.last = k;
const dist = Math.abs(Math.floor(k / COLS) - m.r) + Math.abs((k % COLS) - m.c);
if (dist < far - 1e-9) continue;
e.x = m.c;
e.y = m.r;
battle._unblock(e);
if (e.route) e.route.pts = null;
m.last = m.r * COLS + m.c;
battle.fx('teleport', { x: m.c, y: m.r, id: e.id, src: unit.id });
}
// no enemy to attack: her attack heals the most injured allied unit of her range (herself included)
if (unit.atkCd > 0 || !unit.canAct || unit.s.flags.disarm || !unit.profile) return;
const prof = effectiveProfile(unit);
if (acquireTargets(battle, unit, prof).length) return;
const a = healTarget(battle, unit, false);
if (!a) return;
performAttack(battle, unit, { ...prof, dmgType: 'heal', heal: { mode: 'single' }, atkScale: 1, healScale: 1, projectile: 'none', splashRadius: 0, chain: null, hits: 1 }, [a]);
unit.atkCd = Math.max(unit.atkCd, unit.s.interval);
},
onEnd({ unit }) { unit.mem.shuMarks = null; },
},
},
talents: [
{ install(battle, unit) { // 百谷长青: every heal she outputs sows the healed unit's x-5; the sown tiles regenerate / 庇护
battle.on('heal', (c) => {
if (c.source !== unit || !up(unit) || c.opts?.regen || !(c.amount > 0) || !onField(c.target)) return;
sow(battle, unit, c.target.tileR, c.target.tileC, SOW);
}, { owner: unit, priority: -20 });
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit) return;
sown(unit).clear();
if (born) sow(battle, unit, unit.tileR, unit.tileC, born); // GUA-X stage 3: 部署时立即给所处地块播种该效果
}, { owner: unit });
battle.on('death', (ctx) => { // 自身退场时清空所有地块的播种效果
if (ctx.unit !== unit) return;
sown(unit).clear();
for (const a of battle.allyUnits) if (a.findBuff(FARM)?.source === unit) battle.removeBuff(a, FARM);
}, { owner: unit });
battle.every(FARM_EVERY, () => farm(battle, unit), { owner: unit, immediate: true });
} },
{ install(battle, unit) { // 天有四时
const hpUp = num(t1.max_hp), asUp = num(t1.attack_speed);
battle.every(FARM_EVERY, () => {
if (!up(unit)) return;
const ops = battle.allyUnits.filter((a) => a.kind === 'op' && onField(a));
const per = new Map();
for (const a of ops) { const p = a.def?.profession ?? '?'; per.set(p, (per.get(p) ?? 0) + 1); }
const many = per.size >= PROF_NEEDED, same = Math.max(0, ...per.values()) >= PROF_NEEDED;
for (const a of ops) {
if (many && hpUp) battle.addBuff(a, { key: 'talent:shu:seasons:hp', duration: FARM_HOLD, mods: { hpPct: hpUp }, source: unit, tags: ['talent'] });
if (same && asUp) battle.addBuff(a, { key: 'talent:shu:seasons:aspd', duration: FARM_HOLD, mods: { aspd: asUp }, source: unit, tags: ['talent'] });
}
}, { owner: unit, immediate: true });
// 编入队伍且编队中有四名【岁】干员: her squad's operators ATK + for the battle, +sp SP every `interval` s deployed
const squadOf = () => battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId);
const atk = num(t1.atk), sp = num(t1.sp), iv = num(t1.interval);
const spBuff = (a) => {
if (sp > 0 && iv > 0) battle.addBuff(a, { key: 'talent:shu:suiSp', interval: iv, onTick: ({ unit: x }) => giveSp(x, sp), source: unit, tags: ['talent'] });
};
const apply = () => {
if (unit.mem.shuSuiDone) return;
unit.mem.shuSuiDone = true;
const squad = squadOf();
if (squad.filter((a) => SUI.has(a.def?.charId)).length < SUI_NEEDED) return;
const members = new Set(squad);
for (const a of squad) {
if (atk) battle.addBuff(a, { key: 'talent:shu:sui', mods: { atkPct: atk }, persist: true, allowDead: true, source: unit, tags: ['talent'] });
if (onField(a)) spBuff(a);
}
battle.on('deploy', (ctx) => { if (members.has(ctx.unit)) spBuff(ctx.unit); }, { owner: unit });
};
if (battle.started) apply();
else battle.on('battleStart', apply, { owner: unit });
} },
],
install(battle, unit) {
// GUA-X: 治疗生命值低于50%的友方单位时治疗量提升15% (heal_scale_up[hpratio][LE]: at or below hp_ratio)
const hs = num(tb.heal_scale, 1), hr = num(tb.hp_ratio);
if (hs !== 1 && hr > 0) {
battle.on('heal', (c) => {
if (c.source === unit && !c.opts?.regen && c.target && c.target.hpRatio <= hr + 1e-9) c.amount *= hs;
}, { owner: unit });
}
},
};
},
};