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

303 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-chen3.js — 赤刃明霄陈 (char_1050_chen3) 自选 operator kit: 6★ 术战者 (近卫), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (AFT-X) at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_1050_chen3, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, AFT-X at stage 1 (tier 5) or 3 (tier 6). Full potential (the owner's decision of 2026-10-07).
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); the client's battle
// logic — buff_template_data (chen3_t2 / [timer] / [evade], chen3_s1[derived_silence], chen3_s2[dead_listener] /
// [record_position] / [try_resapwn] / [respawn_buff], chen3_s3[ensure_dmg] / [finish_projectile]), her charpack (talent 1
// chen3_t1 + common_weak_damage; S1 attack `_additionalTimes` 1; S3 attack `_additionalTimes` 2, ground selector), the
// skill prefabs (skchr_chen3_2 S2MultiMeleeAttack: `_triggerDelta` 0.4, `s2_extend_time` 0.4 / `s2_times_add_one` 1 per
// lost target; skchr_chen3_3: the sword wave) and projectile_chr_chen3_s3 (speed 1.5, no lifetime of its own — ended by
// the skill —, `_hitDistance` 0.25, turns at high ground / map edge / 侵入点 / 保护目标, clears its hit list when it turns)
// — and PRTS 赤刃明霄陈 (talent 1 修正 "造成的物理和法术伤害变为弱点伤害"; talent 2 备注 "闪避优先级为-1000,不可叠加…二技能
// 移动时可继承闪避效果"; S2 / S3 备注), Terra wiki Ch'en the Dawnstreak (the wave's enemy hitbox 1.3).
// - Trait (术战者) "攻击造成法术伤害": the artsfghter profile (melee arts, ground only), range 1-1, blocks 1. Module AFT-X
// “记忆残页” adds "未阻挡敌人时攻击速度+8" (trait attack_speed): ASPD + while she blocks nobody.
// - T1 形意洞照 "攻击力+13%,攻击速度+13,造成的物理和法术伤害变为弱点伤害" (PRTS 修正; full potential: +16% / +16): a permanent ATK /
// ASPD buff, and every physical or arts damage instance she deals becomes 弱点伤害 (ba.weaknessatk "根据目标防御力和法术抗性变更伤害类
// 型"): the type the target resists less (items/battle.js weaknessRetype, as 双模机械臂 / 陈's band 以己之长).
// - T2 寒暑觉知 "未受到伤害时,每N秒随机治疗自身一定(攻击力的X%~Y%)生命值,并闪避下次物理与法术攻击": a counter +1 every
// second (chen3_t2[timer]: triggerInterval 1, the first a second in), back to 0 with every damage instance she takes
// (ON_APPLIED_MODIFIER IsDamage: not a 流失, not an element fill; a dodged hit lands nothing); at stack_time it restarts and
// she heals herself ATK × a random whole percent in [heal_atk_scale_min, heal_atk_scale_max) (RandomSetter, PRTS
// "最小单位为1%") and holds one dodge (chen3_t2[evade]: STACK, maxStackCnt 1 — "不可叠加") that cancels the next physical
// or arts damage instance that can be dodged (Evade PHYSICAL_AND_MAGICAL), then is spent. Its priority is −1000 (PRTS;
// onEventPriority LOW_PRIORITY): her other dodges (S2's) are rolled first, and the held dodge is kept when one of them
// wins. S2's 【移动】 keeps it (PRTS "二技能移动时可继承闪避效果") — and the counter, as the engine's 【移动】 keeps the rest
// (DESIGN §22.3). AFT-X stage 2+: the module talent's numbers (stage 3 at full potential: 6 s, 55 %–205 %).
// - S1 赤霄·奔夜 (MANUAL, data DEFAULT): ATK +atk for `duration` s, every attack hits twice (二连击) and 特殊能力失效 — 沉默 —
// its target until the skill ends (chen3_s1[derived_silence]: derived from the skill's holder buff, independentCharacter
// Source): the status for the skill's time left; an early end (she leaves) lifts the ones she set.
// - S2 赤霄·绝影-驰 (MANUAL, 技能范围 x-1, data SKILL_RANGE): she slashes the nearest enemy she can select in the x-1 (air units
// too) `times` (10) times, 0.4 s apart, for atk_scale × ATK arts each (the first at the cast [ASSUMED], so the slashing lasts
// 0.4 s × its slashes); a target that leaves the field (or hides [ASSUMED]) hands the slashes to the selectable enemy
// nearest it within 1.7 of it, else in her x-1, and adds one slash (+0.4 s); with nobody to select the slashing ends.
// Meanwhile (PRTS): 无敌, 不可阻挡 (she blocks nothing), 阻回, 静默, stun / freeze immunity, no normal attack. Then always a
// 【移动】 (Battle.moveRedeploy): onto the tile its last target stood on at its last slash (record_position) when that target
// still stands and the tile takes a melee operator, else back onto her own tile (PRTS "否则返回原位置"); the move's own
// travel and deploy clip (Start_2, 1 s) are not modelled [ASSUMED: instant, as every deployment of the engine]. From
// then the `duration` (6 s, "不包括斩击所耗时间"): ATK +respawn_buff.atk and respawn_buff.prob physical and arts dodge.
// - S3 赤霄·天喟 (MANUAL, data ACTIVE_RANGE on its 3-12): range 3-12, every attack hits up to attack@max_target ground enemies
// three times (`_additionalTimes` 2) for attack@atk_scale × ATK arts, `duration` s. At the cast a sword wave leaves her
// tile in her direction at 1.5 tiles/s and roams until the skill ends (FinishManagedProjectiles) or she leaves: when its
// leading edge (0.25 ahead) would enter a high tile, the map's edge — the battle's field [ASSUMED: a battle's map is its
// field, the stage tiles beyond it are another player's half] —, an 侵入点 or a 保护目标 it turns 90° clockwise (four
// turns at most per tick; boxed in on every side it waits) and forgets whom it hit; each enemy it can select within 1.3
// (air too) is hit once per straight run for the larger of hp_ratio × its current HP (the unit's own HP, PRTS) and
// projectile_min_atk_scale × her ATK at the hit, arts (RES applies — PRTS "非伤害保底或无视法术抗性"; 普通伤害). Her
// begin clips (S1 0.2 s, S3 0.433 s) are not modelled [ASSUMED: the engine starts skills at once].
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js';
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyDist } from '../../../body.js';
import { isHpLoss } from '../../../damage.js';
import { weaknessRetype } from '../../items/battle.js';
const S1 = 'skchr_chen3_1';
const S2 = 'skchr_chen3_2';
const S3 = 'skchr_chen3_3';
/** The skill ranges when a record carries none (data: x-1, 3-12). */
const X_1 = 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 R3_12 = Object.freeze([[1, 0], [1, 1], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1]]);
/** skchr_chen3_2 S2MultiMeleeAttack `_triggerDelta` / dead_listener `s2_extend_time`: the time per slash. */
const SLASH_IV = 0.4;
/** PRTS S2 备注: a lost target hands over to "该目标半径1.7范围内的另一个目标". */
const RETARGET_RADIUS = 1.7;
/** projectile_chr_chen3_s3: speed; its terrain probe (`_hitDistance`); Terra wiki: its enemy hitbox. */
const WAVE_SPEED = 1.5;
const WAVE_PROBE = 0.25;
const WAVE_HIT_RADIUS = 1.3;
/** S1's early end: a silence of hers that ends within this of the skill's planned end ended with it. */
const SILENCE_SLACK = 0.1;
const AIR = Object.freeze({ canHitFly: true });
const DASH_KEY = 'skill:chen3:dash';
const AFTER_KEY = 'skill:chen3:respawn';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** Absolute tile keys of a skill range at the unit's tile and facing (a 技能范围 ignores 攻击距离). */
const keysOf = (unit, grid) => absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
/** An S2 target that left the field — dead, removed or hidden [ASSUMED: a hidden one counts as gone]. */
const gone = (e) => !e || !e.alive || e.removed || e.hidden;
/** The enemy of `list` nearest to (x, y) (body distance; ties by spawn order), or null. */
function nearest(list, x, y) {
let best = null, bd = Infinity;
for (const e of list) {
const d = bodyDist(e, x, y);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
}
return best;
}
/** A tile S2's 【移动】 may land on: in the field, melee-deployable, free (her own tile aside), no operator lying there. */
function landable(battle, unit, r, c) {
if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c) || !battle.grid.canStand(r, c)) return false;
if (battle.downOn(r, c)) return false;
return (r === unit.tileR && c === unit.tileC) || !battle.isReservedTile(r, c);
}
/**
* S3's wave turns before a high tile, the map's edge — the battle's field (`rect`): the official map of a battle is that
* field, the stage tiles beyond it (another player's half) are not part of it —, an 侵入点 or a 保护目标.
*/
function waveBlocked(battle, w) {
const r = Math.round(w.y + w.dr * WAVE_PROBE), c = Math.round(w.x + w.dc * WAVE_PROBE);
if (!battle.grid.inRect(r, c)) return true;
const t = battle.grid.tile(r, c);
return t.height === 'HIGH' || t.special === 'start' || t.special === 'end';
}
export default {
char_1050_chen3: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const grid2 = skillRec(chess, S2)?.rangeGrid ?? X_1, grid3 = skillRec(chess, S3)?.rangeGrid ?? R3_12;
const slashes = Math.max(1, Math.floor(num(b2.times, 10)));
/** S2: the selectable enemies (air too) she may slash next — around the lost target, else in her x-1. */
const nextTarget = (battle, unit, from) => {
const near = battle.foesInRadius(from.x, from.y, RETARGET_RADIUS).filter((e) => e !== from.e && canTargetEnemy(unit, e, AIR));
if (near.length) return nearest(near, from.x, from.y);
const inRange = battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR).filter((e) => e !== from.e);
return nearest(inRange, from.x, from.y);
};
/** S2: the slashing is over — the 【移动】, then the 6 s state. */
const landS2 = (battle, unit, skill, m) => {
battle.removeBuff(unit, DASH_KEY);
m.phase = 2;
const keep = m.target && !m.lost && !gone(m.target) && m.at;
const [r, c] = keep && landable(battle, unit, m.at[0], m.at[1]) ? m.at : [unit.tileR, unit.tileC];
battle.moveRedeploy(unit, r, c);
battle.fx('blink', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:respawn' });
if (!unit.alive || !skill.active) return;
const p = num(b2['chen3_s2[respawn_buff].prob']);
battle.addBuff(unit, { key: AFTER_KEY, mods: { atkPct: num(b2['chen3_s2[respawn_buff].atk']), dodgePhys: p, dodgeArts: p }, tags: ['skill'] });
skill.timeLeft = Math.max(0.01, skill.duration);
};
/** S2: one slash on the current target; a target that falls hands over (+1 slash) or ends the slashing. */
const slash = (battle, unit, m) => {
const e = m.target;
m.n++;
m.at = [Math.round(e.y), Math.round(e.x)]; // record_position (each slash)
battle.fx('slash', { x: e.x, y: e.y, id: unit.id, n: 1, skill: 'chen3:slash' });
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'chen3:slash'] });
};
/** S2: the current target left — retarget (+1 slash, +0.4 s) or end the slashing (it was lost). */
const handOver = (m, battle, unit) => {
const from = { e: m.target, x: m.target.x, y: m.target.y };
const next = nextTarget(battle, unit, from);
if (!next) { m.lost = true; return false; }
m.target = next;
m.lost = false;
m.hits++;
return true;
};
return {
// S2's slashes replace her attacks
trait: { canAttack: (b, u) => !(u.mem.chen3S2 && u.mem.chen3S2.phase === 1) },
skills: {
[S1]: {
kind: 'duration',
mods: { atkPct: num(b1.atk) },
attack: {
hits: 2, // 二连击
onHit({ battle, unit, skill, target }) { // 特殊能力失效 until the skill ends
if (!target || !target.alive || target.side !== 'enemy' || !skill.active) return;
if (battle.applyStatus(target, 'silence', { duration: Math.max(0.01, skill.timeLeft), source: unit })) unit.mem.chen3S1Silenced?.add(target);
},
},
onStart({ battle, unit, skill }) { unit.mem.chen3S1End = battle.time + skill.timeLeft; unit.mem.chen3S1Silenced = new Set(); },
onEnd({ battle, unit, reason }) {
const hit = unit.mem.chen3S1Silenced;
unit.mem.chen3S1Silenced = null;
if (reason === 'duration' || !hit) return;
// an early end lifts the silences she set (derived from the skill: _finishDerivedBuffIfParentFinish) — the ones
// that would have ended with it (a tick of slack: the status and the skill count down in different phases)
const left = num(unit.mem.chen3S1End) - battle.time + SILENCE_SLACK;
for (const e of hit) {
for (const s of [...e.buffs]) if (s.key === 'silence' && s.source === unit && s.timeLeft <= left) battle.removeBuff(e, s);
}
},
},
[S2]: {
kind: 'duration',
onStart({ battle, unit, skill }) {
const m = unit.mem.chen3S2 = { phase: 1, t: 0, n: 0, hits: slashes, target: null, at: null, lost: false };
battle.addBuff(unit, { key: DASH_KEY, flags: { invulnerable: true, noBlock: true, noSp: true, silence: true }, tags: ['skill'] });
battle.releaseBlocked(unit);
m.target = nearest(battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR), unit.x, unit.y);
battle.fx('dash', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:dash' });
if (!m.target) landS2(battle, unit, skill, m);
},
onTick({ battle, unit, skill, dt }) {
const m = unit.mem.chen3S2;
if (!m || m.phase !== 1) return;
skill.timeLeft = Math.max(0.01, skill.duration) + dt; // the 6 s start after the move
m.t += dt;
if (gone(m.target) && !handOver(m, battle, unit)) { landS2(battle, unit, skill, m); return; }
while (m.n < m.hits && m.t + 1e-9 >= SLASH_IV * m.n) {
slash(battle, unit, m);
if (!unit.alive || !skill.active) return;
if (gone(m.target) && !handOver(m, battle, unit)) { landS2(battle, unit, skill, m); return; }
}
if (m.n >= m.hits && m.t + 1e-9 >= SLASH_IV * m.hits) landS2(battle, unit, skill, m);
},
onEnd({ battle, unit }) {
battle.removeBuff(unit, DASH_KEY);
battle.removeBuff(unit, AFTER_KEY);
unit.mem.chen3S2 = null;
},
},
[S3]: {
kind: 'duration',
targeting: { rangeGrid: grid3, maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 3))) },
attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: 3 }, // `_additionalTimes` 2
onStart({ battle, unit, skill }) {
const [dr, dc] = unit.fwd;
unit.mem.chen3Wave = { x: unit.x, y: unit.y, dr, dc, hit: new Set(), act: skill.activations, seq: unit.deploySeq };
battle.fx('dash', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:wave' });
},
onEnd({ unit }) { unit.mem.chen3Wave = null; },
},
},
talents: [
{ install(battle, unit) { // 形意洞照: ATK / ASPD; every physical / arts damage of hers is 弱点伤害
statBuff(battle, unit, 'talent:chen3:insight', { atkPct: num(t0.atk), aspd: num(t0.attack_speed) });
battle.on('hit', (c) => { if (c.source === unit && c.target && c.target.side === 'enemy') weaknessRetype(c.dmg, unit, c.target); }, { owner: unit, priority: 5 });
} },
{ install(battle, unit) { // 寒暑觉知: every stack_time s without damage: a random self-heal and one held dodge
const st = Math.max(1, Math.floor(num(t1.stack_time, 7))), lo = num(t1.heal_atk_scale_min), hi = num(t1.heal_atk_scale_max, lo + 1);
const fresh = () => ({ count: 0, acc: 0, evade: false });
unit.mem.chen3T2 = fresh();
// a 【移动】 (S2) keeps it — the dodge by PRTS, the counter as the engine's 【移动】 keeps the rest
battle.on('deploy', (c) => { if (c.unit === unit && !c.move) unit.mem.chen3T2 = fresh(); }, { owner: unit });
battle.on('tick', ({ dt }) => {
const m = unit.mem.chen3T2;
if (!up(unit) || !m) return;
m.acc += dt;
while (m.acc >= 1 - 1e-9) {
m.acc -= 1;
if (++m.count < st) continue;
m.count = 0;
m.evade = true;
const pct = lo + battle.rng.int(Math.max(1, Math.floor(hi - lo)));
battle.heal(unit, unit, unit.s.atk * pct / 100, { self: true });
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:evade' });
}
}, { owner: unit });
battle.on('damaged', (c) => {
const m = unit.mem.chen3T2;
if (c.target !== unit || !m || !c.dmg || isHpLoss(c.dmg) || c.type === 'element') return;
m.count = 0;
m.acc = 0;
}, { owner: unit });
// the held dodge: after her other dodges (priority −1000), on a dodgeable physical / arts damage instance
battle.on('hit', (c) => {
const m = unit.mem.chen3T2, d = c.dmg;
if (c.target !== unit || !m || !m.evade || !d || d.cancel || !d.canDodge || (d.type !== 'phys' && d.type !== 'arts')) return;
const p = d.type === 'phys' ? unit.s.dodgePhys : unit.s.dodgeArts;
if (!(p > 0 && battle.rng() < p)) m.evade = false; // one of her other dodges first: then it is kept
d.cancel = true;
battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id });
battle.emit('dodge', { source: c.credit ?? c.source, target: unit, dmg: d });
}, { owner: unit, priority: -1000 });
} },
],
install(battle, unit) {
// AFT-X “记忆残页”: 未阻挡敌人时攻击速度+8
const as = num(tb.attack_speed);
if (as) toggleBuff(battle, unit, 'trait:chen3:unblocked', () => unit.blocking.length === 0, { aspd: as });
// S2: 眩晕 / 冻结 immunity while she slashes
battle.on('beforeStatus', (c) => {
if (c.target === unit && unit.mem.chen3S2 && unit.mem.chen3S2.phase === 1 && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true;
}, { owner: unit });
// S3: the sword wave
if (unit.skill?.id !== S3) return;
const ratio = num(b3.hp_ratio), minScale = num(b3.projectile_min_atk_scale);
battle.on('tick', ({ dt }) => {
const w = unit.mem.chen3Wave;
if (!w) return;
const sk = unit.skill;
if (!up(unit) || unit.deploySeq !== w.seq || !sk || !sk.active || sk.activations !== w.act) { unit.mem.chen3Wave = null; return; }
for (let i = 0; i < 4 && waveBlocked(battle, w); i++) { // turn 90° clockwise (row up, col right)
const dr = w.dr;
w.dr = 0 - w.dc; // (`0 - x`: never a negative zero)
w.dc = dr;
w.hit.clear();
}
if (!waveBlocked(battle, w)) { w.x += w.dc * WAVE_SPEED * dt; w.y += w.dr * WAVE_SPEED * dt; }
for (const e of battle.foesInRadius(w.x, w.y, WAVE_HIT_RADIUS)) {
if (w.hit.has(e) || !canTargetEnemy(unit, e, AIR)) continue;
w.hit.add(e);
const amount = Math.max(ratio * e.hp, minScale * unit.s.atk);
battle.fx('slash', { x: e.x, y: e.y, id: unit.id, n: 1, skill: 'chen3:wave' });
battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', 'chen3:wave'] });
if (!unit.alive || unit.mem.chen3Wave !== w) return;
}
}, { owner: unit });
},
};
},
};