feedback5: merge WE1 — fidelity (sim rules): 秘术师 store at the attack check (深靛; 维伊 / 黑键 plug into it), enemy attacks land at their damage frame so a short stun / freeze / sleep / 浮空 / 缴械 / 恐惧 before it cancels the swing, 武者 heal per damage instance through 禁疗, 庇护 the strongest only, PHY-X 不高于 50 %, 链愈师 jumps in the 3×3, a summon readied at its owner's redeploy, Misery's duration skills, 伺夜 S2 in 战术点形态, centurions hit up to block-count targets; merge fixes: 黑键's kit keeps the up() import WE2's S2 cast needs (WE1 dropped it with its tick store — the first regeneration had stored 9288 swallowed errors in diy-114), the 自选 refusal names 集成战略 / 生息演算, tools/golden.mjs prints a missing value, test/golden.test.js fails on any stored engine error; golden: vs WE1 only the W4B / WE2 scenarios differ (roster-042, bond-egirShip-high, coop2-ABYSS-10-boosted, coop2-NORMAL-70-diy, diy-045 / 078 / 114 / 115 / 116)

This commit is contained in:
sganggs committed 2026-10-06 16:52:11 +08:00
commit e2bb090f44
60 files changed
+6888 -6103

No files matched your search

+129 -52
View File
@@ -18,9 +18,11 @@
// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"). An ally target
// (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon our operators attack, 嘲讽等级 −2) on the range comes after
// every enemy (acquireTargets) and a ranged attack flies to it like to an enemy.
// Unblocked ranged enemies attack allies within their radius and stand for each attack's clip — through its wind-up
// and until the clip ends — then walk on (attackStand, GitHub #58; ATTACK_PAUSE after the strike when no clip is known;
// 「不停止移动」 attackers never stop); the candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered
// Every enemy attack strikes at its clip's damage frame after its swing starts (attackWindup; a stun before the frame
// cuts the swing — enemyAttack, GitHub #187). Unblocked ranged enemies attack allies within their radius and stand for
// each attack's clip — through its wind-up and until the clip ends — then walk on (attackStand, GitHub #58;
// ATTACK_PAUSE after the strike when no clip is known; 「不停止移动」 attackers never stop); the candidates pass the
// enemy's own rule (`e.profile.canTarget`) and are ordered
// blocker → taunt → latest deployed (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content
// arms it (`e.profile.dmgType`: 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
@@ -79,20 +81,30 @@ export function updateAlly(b, u, dt) {
if (prof.noAttackUnlessSkill && !(sk && sk.active)) return;
if (u.s.flags.disarm) return;
if (u.atkCd > 0) return;
if (prof.canAttack && !prof.canAttack(b, u)) return;
// (a 秘术师's `canAttack` is its own target rule: false = no valid target — 深靛's bound enemies, 维伊's marked ones)
if (prof.canAttack && !prof.canAttack(b, u)) { if (prof.storeEnergy) { u.trait.hadTarget = false; storeEnergy(b, u, prof); } return; }
let targets = acquireTargets(b, u, prof);
if (!targets.length) { u.trait.hadTarget = false; return; }
if (!targets.length) { u.trait.hadTarget = false; storeEnergy(b, u, prof); return; }
u.trait.hadTarget = true;
if (sk && sk.onAboutToAttack()) {
prof = effectiveProfile(u);
if (prof.noAttack || !u.alive) return;
targets = acquireTargets(b, u, prof);
if (!targets.length) return;
if (!targets.length) { storeEnergy(b, u, prof); return; } // the cast left none (a range change): still this check
}
performAttack(b, u, prof, targets);
u.atkCd = Math.max(u.atkCd, u.s.interval);
}
/**
* The attack check of a ready unit found no valid target: a 秘术师 profile stores one energy instead (`storeEnergy`,
* professions.js installMystic — PRTS 分支特性信息 秘术师 "若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击
* 行为)": the attack interval starts again); with a full store it idles, ready to attack.
*/
function storeEnergy(b, u, prof) {
if (prof.storeEnergy && prof.storeEnergy(b, u)) u.atkCd = Math.max(u.atkCd, u.s.interval);
}
/** Release blocked enemies beyond the current block capacity (latest blocked first). */
export function enforceBlockCapacity(b, u) {
const cap = u.alive && u.deployed ? u.s.blockCnt : 0;
@@ -128,10 +140,6 @@ export function acquireTargets(b, u, prof) {
}
prof._fortressMelee = false;
}
if (prof.hitAllBlocked && u.blocking.length) {
const t = u.blocking.filter((e) => canTargetEnemy(u, e, prof));
if (t.length) return t;
}
const cands = b.enemiesInKeys(u.rangeKeys, u, prof);
// "可以选择且优先选择阻挡单位" (PRTS 选择器): the enemies a unit blocks are always selectable by it — the block radius
// (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range or behind its facing (user
@@ -143,16 +151,29 @@ export function acquireTargets(b, u, prof) {
if (prof.allInRange) return cands.concat(extra);
sortEnemyTargets(b, u, cands, prof.priority);
const all = cands.concat(extra);
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
const n = targetCount(u, prof);
return n >= all.length ? all : all.slice(0, n);
}
if (!cands.length) return cands;
if (prof.allInRange) return cands;
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
const n = targetCount(u, prof);
sortEnemyTargets(b, u, cands, prof.priority);
return n >= cands.length ? cands : cands.slice(0, n);
}
/**
* How many targets one attack of `u` takes. `hitAllBlocked` — "同时攻击阻挡的所有敌人": the 强攻手 / 重剑手 / 推击手 traits and every
* skill worded so, whose client selectors carry `_limitedMaxTargetNumToBlockedCnt` (with `_allowZeroBlockCntLimit` off) —
* takes up to its block count, never fewer than 1, from its range and the enemies it blocks, the blocked ones first
* (acquireTargets' order): PRTS 分支特性信息 强攻手 / 重剑手 / 推击手 "普通攻击最大目标数等于阻挡数(不会低于1)", PRTS 作战机制 §AOE伤害判定
* "锁定人数的无弹道AOE攻击(例如近卫分支“强攻手”)…在抬手时选取范围内的全体目标(不超过其攻击目标上限)", PRTS 忍冬 S3 备注 "可对空" (a
* flyer she cannot block). Until 0.2.0 it struck the blocked enemies only (one in range when it blocked none).
*/
function targetCount(u, prof) {
const base = prof.hitAllBlocked ? Math.max(1, Math.floor(u.s.blockCnt)) : (prof.maxTargets || 1);
return Math.max(1, Math.floor(base + u.s.maxTargets));
}
/** Perform an attack/heal with profile `prof` against `targets`. opts: { noAmmo } (Battle.forceAttack). */
export function performAttack(b, u, prof, targets, opts = null) {
const isSkill = !!prof.isSkill;
@@ -174,11 +195,13 @@ export function performAttack(b, u, prof, targets, opts = null) {
}
const ranged = !prof._fortressMelee && prof.attack === 'ranged' && prof.projectile && prof.projectile !== 'none' && prof.projectile !== 'beam';
const vis = prof._fortressMelee ? 'none' : (prof.projectile || 'none');
// 秘术师: the stored energies leave with this attack, at its main target (professions.js installMystic)
const energy = !isHeal && prof.releaseEnergy ? prof.releaseEnergy(b, u) : 0;
for (let i = 0; i < targets.length; i++) {
const t = targets[i];
b._ev(['atk', u.id, t.id, vis]);
if (isHeal) { doHeal(b, u, prof, t); continue; }
const info = { isSkill, index: i, attackId };
const info = { isSkill, index: i, attackId, energy: i === 0 ? energy : 0 };
const foe = t.side === 'enemy' || b.isAllyTarget(t);
if (ranged && foe && prof.projectile === 'boomerang') {
throwBoomerang(b, u, prof, t, info);
@@ -260,7 +283,7 @@ export function resolveHit(b, u, prof, target, info, x, y) {
if (target && target.alive) {
let mulT = skillMul;
if (prof.dmgMul) { const m = typeof prof.dmgMul === 'function' ? prof.dmgMul(b, u, target) : prof.dmgMul; if (Number.isFinite(m)) mulT *= m; }
const hits = prof.hitsFn ? prof.hitsFn(b, u) : Math.max(1, prof.hits || 1);
const hits = prof.hitsFn ? prof.hitsFn(b, u, info) : Math.max(1, prof.hits || 1);
let dealtMain = 0;
for (let h = 0; h < hits && target.alive; h++) {
dealtMain += b.dealDamage(u, target, { amount: atk * scale * mulT, type: baseType, isAttack: true, isSkill: info.isSkill, tags: prof.tags || [], attackId });
@@ -319,6 +342,27 @@ export function resolveHit(b, u, prof, target, info, x, y) {
}
}
/**
* The next unit of a 链愈师 heal chain of `healer` from `prev` — PRTS 分支特性信息 链愈师: "跳跃范围为x-4,无特殊说明的场合一次
* 治疗链不会对已跳跃过的单位重复跳跃", "优先跳跃至范围内生命比例最低>部署时间点最晚的我方单位。可选择满生命我方单位为跳跃目标,但仍受
* 禁疗制约": an ally on the 3×3 of tiles around prev's (range x-4), not yet in this chain (`seen` ids), no device, no hidden
* or 孤立 unit, no 禁疗 / 无法被治疗 one (as Battle.injuredAlliesInKeys: the healer's `healThrough` lets its own summon in —
* 凯尔希 / Mon3tr); the lowest HP ratio first — a full-HP ally too, healed for nothing, and the chain jumps on from it —,
* then the latest deployed (aggroSeq). Null when none. Shared by the profession (doHeal) and Mon3tr's kit.
*/
export function chainHealNext(b, healer, prev, seen) {
const through = healer && healer.profile && typeof healer.profile.healThrough === 'function' ? healer.profile.healThrough : null;
const r0 = prev.tileR, c0 = prev.tileC;
let best = null;
for (const a of b.allyUnits) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device' || seen.has(a.id)) continue;
if (Math.abs(a.tileR - r0) > 1 || Math.abs(a.tileC - c0) > 1) continue;
if (a !== healer && (a.s.flags.isolated || (a.s.flags.noHeal && !(through && through(healer, a))) || (a.profile && a.profile.noHeal))) continue;
if (!best || a.hpRatio < best.hpRatio - 1e-12 || (Math.abs(a.hpRatio - best.hpRatio) <= 1e-12 && a.aggroSeq > best.aggroSeq)) best = a;
}
return best;
}
function doHeal(b, u, prof, t) {
const atk = u.s.atk;
const scale = (prof.atkScale ?? 1) * (prof.healScale ?? 1) * u.s.atkScaleMul;
@@ -332,13 +376,7 @@ function doHeal(b, u, prof, t) {
let prev = t;
const n = Math.max(1, h.count || 3);
for (let k = 1; k < n; k++) {
let best = null, bd = Infinity;
for (const a of b.alliesInRadius(prev.x, prev.y, 2.5, null)) {
// 禁疗 / noHeal units are no heal target for the bounces either (as injuredAlliesInKeys; 史尔特尔's 余烬, GitHub #52)
if (seen.has(a.id) || a.hp >= a.s.maxHp || a.kind === 'device' || a.s.flags.noHeal || (a.profile && a.profile.noHeal)) continue;
const d = a.hpRatio;
if (d < bd) { bd = d; best = a; }
}
const best = chainHealNext(b, u, prev, seen);
if (!best) break;
seen.add(best.id);
b._ev(['atk', prev.id, best.id, 'chainHeal']);
@@ -487,9 +525,13 @@ export function updateEnemy(b, e, dt) {
if (!e.alive) return;
// hidden (teleporting) enemies only advance wait legs
const stunned = e.s.flags.stun;
const prevCd = e.atkCd;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
// a stun / freeze / sleep / 浮空 — or leaving the field — takes the enemy out of its attack: a swing short of its damage
// frame does not land, and the attack starts again from its wind-up afterwards (enemyAttack)
if (e.swing && (stunned || e.hidden)) e.swing = false;
// true: an unblocked ranged enemy in the wind-up of its next attack with a target in range (it stands)
const winding = !e.hidden && !stunned && enemyAttack(b, e);
const winding = !e.hidden && !stunned && enemyAttack(b, e, prevCd);
if (!e.alive) return;
// a stun / freeze / sleep cuts the attack clip short: no stand left once it ends [ASSUMED]
if (stunned && !e.hidden) { e.atkStandUntil = -Infinity; return; }
@@ -644,43 +686,31 @@ export function attackStand(e, out = { wind: 0, rest: 0 }) {
if (e.profile?.attackMoves ?? e.def?.attackMoves) return out;
const a = e.def?.attackAnim;
if (!a || !(a.dur > 0)) { out.rest = ATTACK_PAUSE; return out; }
const iv = e.s.interval;
const speed = iv > 0 && iv < a.dur ? a.dur / iv : 1;
const hit = Number.isFinite(a.hit) ? Math.min(a.dur, Math.max(0, a.hit)) : a.dur / 2;
const speed = clipSpeed(e, a);
const hit = clipHit(a);
out.wind = hit / speed;
out.rest = (a.dur - hit) / speed;
return out;
}
const STAND = { wind: 0, rest: 0 };
/** The attack clip's strike frame (s into the clip); half the clip when the manifest names none. */
const clipHit = (a) => (Number.isFinite(a.hit) ? Math.min(a.dur, Math.max(0, a.hit)) : a.dur / 2);
/** The clip plays faster when the attacks come quicker than it. */
const clipSpeed = (e, a) => { const iv = e.s.interval; return iv > 0 && iv < a.dur ? a.dur / iv : 1; };
/**
* One tick of an enemy's attack: attacks when its cooldown is over and a target is in reach. Returns true while an
* unblocked ranged enemy is in the wind-up of its next attack (cooldown ≤ its clip's wind-up, attackStand) with a
* target in range: it stands (updateEnemy).
* The damage frame of an enemy's normal attack: seconds from the start of its swing to the strike — its attack clip's
* strike frame (data/enemies.json `attackAnim.hit`, half the clip when the manifest names none), shortened with the clip
* when the attacks come quicker than it (as attackStand; a 「不停止移动」 attacker swings on the move); 0 with no clip known
* (the strike as the swing starts — the rule before 0.2.0).
*/
function enemyAttack(b, e) {
const def = e.def;
if (e.profile && e.profile.noAttack) return false;
const dmgType = (e.profile && e.profile.dmgType) || def.dmgType;
if (dmgType === 'none' || e.s.atk <= 0) return false;
if (e.s.flags.fear || e.s.flags.disarm) return false;
if (e.s.flags.tremble && e.blockedBy) return false; // 战栗: 被阻挡后无法进行普通攻击
if (dmgType === 'heal') { if (e.atkCd <= 0) enemyHeal(b, e, e.base.rangeRadius); return false; }
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
// Content may flip it with `e.profile.melee = false`.
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
const radius = melee ? 0 : e.base.rangeRadius;
// the range circle takes an ally whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积: 索敌 uses the colliders)
const reach = radius > 0 ? radius + ALLY_COLLIDER_RADIUS : 0;
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies/*.js),
// applied to the candidates before the priority sort and the target count
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
if (e.atkCd > 0) {
// the wind-up of the next attack (GitHub #58): an unblocked ranged enemy stands once a target is in range
if (e.blockedBy || radius <= 0 || !(e.atkCd <= attackStand(e, STAND).wind + 1e-9)) return false;
return b.alliesInRadius(e.x, e.y, reach, null).some((a) => canTargetAlly(e, a, true) && (!own || own(a)));
}
export function attackWindup(e) {
const a = e.def?.attackAnim;
return a && a.dur > 0 ? clipHit(a) / clipSpeed(e, a) : 0;
}
/** The allies `e` could hit now: its blocker (and, with a range, the others in reach), passing its own target rule. */
function attackTargets(b, e, radius, reach, own) {
let targets = [];
if (e.blockedBy) {
const bl = e.blockedBy;
@@ -692,8 +722,55 @@ function enemyAttack(b, e) {
targets = b.alliesInRadius(e.x, e.y, reach, null).filter((a) => canTargetAlly(e, a, true));
}
if (own && targets.length) targets = targets.filter((a) => own(a));
return targets;
}
/**
* One tick of an enemy's attack. Its cooldown (`atkCd`) counts down to the damage frame of its next attack: the swing
* starts `attackWindup` before it — the cooldown down to the wind-up with a target in reach (attackTargets) — and lasts
* while a target stays in reach; at the frame the attack is made on the targets in reach then. An enemy whose cooldown
* ran out before it had a target (walking, a swing cut short) swings from the start: the whole wind-up from that tick.
* A swing short of its frame is cut by a stun / freeze / sleep / 浮空 / leaving the field (updateEnemy), 缴械, 恐惧, 战栗
* while blocked, or losing every target: no damage, and the next swing starts from its wind-up again — PRTS 状态机
* (ATTACK / COMBAT "每帧检查异常状态,若有则切换到异常状态的状态") and 异常效果 (STUNNED, DISARMED "正在进行的普通攻击将被
* 中断"); GitHub #187 / #170 (a 0.1 s 卡西米尔 pulse, 忍冬 S3's 0.2 s stun). A strike already made stays made (a shot in
* flight lands). An enemy with no attack clip known has no wind-up: it strikes as the swing starts (the old rule).
* [ASSUMED] the targets are taken at the frame (the swing needs one in reach every tick; the official selection happens
* as the swing starts); an enemy healer's heal has no wind-up.
* Returns true while an unblocked ranged enemy swings (its wind-up): it stands (updateEnemy). `prevCd`: the cooldown
* before this tick's countdown.
*/
function enemyAttack(b, e, prevCd) {
const def = e.def;
if (e.profile && e.profile.noAttack) { e.swing = false; return false; }
const dmgType = (e.profile && e.profile.dmgType) || def.dmgType;
if (dmgType === 'none' || e.s.atk <= 0) { e.swing = false; return false; }
// 恐惧 / 缴械 / 战栗 (被阻挡后无法进行普通攻击): no normal attack, and the one swung is interrupted
if (e.s.flags.fear || e.s.flags.disarm || (e.s.flags.tremble && e.blockedBy)) { e.swing = false; return false; }
if (dmgType === 'heal') { if (e.atkCd <= 0) enemyHeal(b, e, e.base.rangeRadius); return false; }
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
// Content may flip it with `e.profile.melee = false`.
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
const radius = melee ? 0 : e.base.rangeRadius;
// the range circle takes an ally whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积: 索敌 uses the colliders)
const reach = radius > 0 ? radius + ALLY_COLLIDER_RADIUS : 0;
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies/*.js),
// applied to the candidates before the priority sort and the target count
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
const wind = attackWindup(e);
if (e.atkCd > wind + 1e-9) { e.swing = false; return false; } // the cooldown runs
let targets = attackTargets(b, e, radius, reach, own);
if (!targets.length) { e.swing = false; return false; } // ready, nobody in reach: no swing (or it is cut)
if (!e.swing) {
e.swing = true;
// ready before this tick (it walked with its cooldown over, or its swing was cut): the whole wind-up from now
if (prevCd < wind) e.atkCd = wind;
}
// the wind-up (GitHub #58): an unblocked ranged enemy stands through it (a 「不停止移动」 one walks on)
if (e.atkCd > 0) return !e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves);
e.swing = false;
if (targets.length > 1) sortAllyTargets(e, targets);
if (!targets.length) return false;
// `enemyAttackStart` { enemy, targets }: the enemy starts a normal attack, before 麻痹 may interrupt it (the client's
// ON_BEFORE_ABILITY_SPELL_ON of an attack that 麻痹 can interrupt — 酒神 堕梦 "攻击范围内的敌人普通攻击时受到70点神经损伤",
// PRTS 备注 "于敌人成功普通攻击前之前触发(意味着触发的元素爆发可打断当次普攻…)": a burst it causes interrupts this attack)
+1
View File
@@ -132,6 +132,7 @@ export class BattleSpawns {
e.atkCd = 0;
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
e.swing = false; // a normal attack swung, its damage frame not reached yet (ai.js enemyAttack)
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
// `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack)
e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null };
+2 -1
View File
@@ -320,7 +320,8 @@ Each item is a mistake this project already made once. Tick every one for every
`test/match/feedback1-placement.test.js` "#9 data: 狼群 and 流形 are owner-range summons" (DESIGN §20.1, §21.3);
`test/content/playtest6_summons.test.js`, `tokens_summons.test.js`; `feedback1b_kits.test.js` "B3 维娜·维多利亚 S3".
- [ ] **9. Status effects** — every status applied, with its official duration and stacking (寒冷 on 寒冷 ⇒ 冻结 for the
longer cold, 沉默 removes abilities, 恐惧, 睡眠, 浮空 …; "同名效果取最高" = `applyStrongest`). Examples:
longer cold, 沉默 removes abilities, 恐惧, 睡眠, 浮空 …; "同名效果取最高" = `applyStrongest`; 庇护 = the shared
`PROTECT` key, `holdProtect` in `shared/tier1.js`). Examples:
`test/sim/combat.test.js` "cold on cold ⇒ freeze for the longer cold …", "sleep: target is untargetable and
inactive; fear stops enemy attacks", "statuses: sleep = invulnerable except `hitSleep` attackers; levitate …";
`enemies_bosses.test.js` "沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced".
@@ -51,7 +51,8 @@ export default {
install(battle, unit) {
if (tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return;
// GUA-X on her skill heals (S1's heal-mode attack, S2's attack heals while it runs — herself included, she stands
// in the grid), never her natural / self regeneration
// in the grid), never her natural / self regeneration; strictly below hp_ratio (the client's sunbr_e_trait is
// set_heal_scale_by_hpratio: FilterByTargetHpRatio LT)
battle.on('heal', (ctx) => {
if (ctx.source !== unit || !unit.skill?.active || ctx.opts?.regen || ctx.opts?.self) return;
if (ctx.target.hpRatio < num(tb.hp_ratio)) ctx.amount *= num(tb.heal_scale, 1);
@@ -11,7 +11,8 @@ export default {
// 柔光缚目: attacks bind the target `duration` s with `prob`; bound enemies are never chosen as targets.
// Alternate S1 灯塔守卫者: for `duration` s the skill range (4-1), attack interval ×(1 + base_attack_time) (PRTS:
// "大幅度缩短(-80%)" ⇒ ×0.2 — a flat −0.8 s on 3 s would be less than S2's "略微缩短 ×0.7"), every attack hits for
// attack@atk_scale × ATK arts. Elite module MSC-X (store 4) is the mystic profile of the module trait (TUNE.mystic).
// attack@atk_scale × ATK arts. Elite module MSC-X (store 4) is the mystic profile of the module trait (TUNE.mystic);
// the store itself is the shared 秘术师 profile (professions.js installMystic: at the attack check, PRTS 分支特性信息).
chess_char_1_17_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const dScale = num(bb['indigo_s_2[damage].atk_scale']);
@@ -28,12 +29,11 @@ export default {
},
},
trait: {
// with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she holds her
// fire (the mystic trait stores the energy meanwhile)
// with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she has no valid
// target: she holds her fire and the mystic trait stores an energy at the attack check (professions.js
// installMystic; a bind on her only target included)
canAttack(battle, u) {
const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e));
if (!ok) u.trait.hadTarget = false;
return ok;
return battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e));
},
afterHit(battle, u, target) {
if (!target || !target.alive || target.side !== 'enemy') return;
@@ -1,7 +1,7 @@
// server/sim/content/kits/ops/chess_char_1_18-utage.js — 宴 (char_337_utage) kit, tier 1.
// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md.
import { num, talentBb, up, onHitBy, onHitOn, skillBbOf } from '../shared/tier1.js';
import { num, talentBb, up, onHitBy, onHitOn, skillBbOf, holdProtect, PROTECT_TICK_HOLD } from '../shared/tier1.js';
export default {
// ---------------------------------------------------------------------------------------------------------------
@@ -44,11 +44,14 @@ export default {
battle.addBuff(unit, { key: 'utage:serious', mods: { aspd: v }, data: { v }, tags: ['talent'] });
}, { owner: unit });
}
// 庇护 (ba.protect): "受到的物理和法术伤害降低相应比例" — physical and arts damage only
// 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") while below hp_ratio HP: the shared 庇护 — the
// strongest of every source holds (holdProtect) —, refreshed every tick and at each hit on her (a hit that takes
// her below it: the next ones already have it)
if (t.damage_resistance != null && t.hp_ratio != null) {
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < num(t.hp_ratio)) dmg.mul *= 1 - num(t.damage_resistance);
});
const dr = num(t.damage_resistance), below = num(t.hp_ratio);
const keep = () => { if (up(unit) && unit.hpRatio < below) holdProtect(battle, unit, dr, PROTECT_TICK_HOLD, unit); };
battle.on('tick', keep, { owner: unit });
onHitOn(battle, unit, keep);
}
} }],
};
@@ -136,7 +136,8 @@ export default {
if (num(tb.damage_resistance) > 0) {
kit.install = (battle, unit) => battle.addBuff(unit, { key: 'trait:blemsh_guard', mods: { dmgTakenMul: 1 - num(tb.damage_resistance) }, persist: true, allowDead: true });
} else if (num(tb.heal_scale) > 0) {
// GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%" (the HP before the heal)
// GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%" (the HP before the heal; strictly below — the client's blemsh_e_trait
// is set_heal_scale_by_hpratio, FilterByTargetHpRatio LT, unlike 黍's heal_scale_up[hpratio][LE])
kit.install = (battle, unit) => battle.on('heal', (ctx) => {
if (ctx.source === unit && ctx.target && ctx.target.hpRatio < num(tb.hp_ratio, 0.5)) ctx.amount *= num(tb.heal_scale, 1);
}, { owner: unit });
@@ -162,10 +162,10 @@ export default {
};
// S2: the pack's empowered next attack (armed by the cast; ×scale on its hits; a kill pays once)
const installGift = (battle, unit) => {
// the cast: ready, the pack standing, no unused gift on it (PRTS 备注 「仅场上存在狼群,且狼群未获得此技能的充能时可触发
// 技能」; a pack in its 战术点形态 does not count — the 备注 does not say [ASSUMED])
// the cast: ready, the pack on the field in either form, no unused gift on it (PRTS 备注 「仅场上存在狼群,且狼群未获得此技能的
// 充能时可触发技能」 — the words of S1's, whose 备注 counts the 战术点形态 [ASSUMED the same for S2])
battle.on('tick', () => {
const sk = unit.skill, w = wolfOf(unit);
const sk = unit.skill, w = packOf(unit);
if (!alive(unit) || !sk || !sk.ready || sk.active || !unit.canAct || unit.s.flags.silence || !w || w.mem.vigilGift) return;
sk.activate('SP_FULL');
}, { owner: unit });
@@ -272,18 +272,20 @@ export default {
}),
// (自动触发, PRTS 备注 「仅场上存在狼群,且狼群未获得此技能的充能时可触发技能」: no enemy needed — the kit casts it as soon
// as it is ready while the pack stands and holds no unused gift (installGift); until 0.2.0 the data's DEFAULT made it
// wait for 伺夜's own next attack, so it never fired with no enemy in his range. A pack in its 战术点形态 does not
// count: S1's 备注 lists that form, S2's says nothing of it, so S2 waits for the pack to come back [ASSUMED]; a gift
// the pack holds stays on it through the form [ASSUMED])
// wait for 伺夜's own next attack, so it never fired with no enemy in his range. A pack in its 战术点形态 counts as on the
// field — S2's 备注 uses S1's words and S1's lists that form [ASSUMED for S2; the 0.2.0 follow-up's recommended
// reading]: the cast pays its DP at once and the gift waits on the pack, its next attack once it is back (until 0.2.0
// S2 waited for the pack); a gift the pack holds stays on it through the form [ASSUMED])
skchr_vigil_2: (s) => ({
kind: 'instant',
trigger: 'NEVER',
onStart({ battle, unit }) {
dpGain(battle, unit, num(s.bb.cost, 0));
const w = wolfOf(unit);
const w = packOf(unit);
if (!w) return;
// (a pack in its 战术点形态 has no HP to restore: it comes back at full HP, the gift kept on it)
const hr = num(s.bb['vigil_wolf_s_2.hp_ratio'], 0);
if (hr > 0) battle.heal(w, w, w.s.maxHp * hr, { self: true });
if (hr > 0 && w.alive) battle.heal(w, w, w.s.maxHp * hr, { self: true });
w.mem.vigilGift = { scale: num(s.bb['vigil_wolf_s_2.atk_scale'], 1), dp: num(s.bb['vigil_wolf_s_2.cost'], 0), paid: false };
fx(battle, 'buff', w, { src: unit.id, skill: 'vigil_2' });
},
@@ -34,7 +34,7 @@ export default withDefaults({
if (t.side !== 'ally' || !t.alive || !(c.amount > 0) || isHpLoss(c.dmg) || !t.findBuff(`reckpr:guard:${unit.id}`)) return;
battle.heal(unit, t, num(bb['attack@fixed_heal_value'], 80));
}, { owner: unit });
installLowHpHealBonus(battle, unit, tb);
installLowHpHealBonus(battle, unit, tb); // PHY-X: reckpr_e_002_tr filters LT (strictly below)
},
};
},
@@ -43,7 +43,7 @@ export default withDefaults({
}, { owner: unit });
} }],
install(battle, unit) {
installLowHpHealBonus(battle, unit, tb);
installLowHpHealBonus(battle, unit, tb, { atOrBelow: true }); // PHY-X: heal_scale_up[hpratio][LE] (≤)
if (!S1) return;
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit) return;
@@ -52,7 +52,7 @@ export default {
c.amount += c.target.s.maxHp * ratio * num(unit.s.healingDealtMul, 1) * num(c.target.s.healingTakenMul, 1);
battle.fx('healAoe', { x: c.target.x, y: c.target.y, id: c.target.id, r: 0.5 });
}, { owner: unit, priority: 10 });
lowHpHealUp(battle, unit, tb);
lowHpHealUp(battle, unit, tb, { atOrBelow: true }); // PHY-X: heal_scale_up[hpratio][LE] (≤)
},
};
},
@@ -103,7 +103,7 @@ export default {
install(battle, unit) {
// S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured
if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6));
lowHpHealUp(battle, unit, tb);
lowHpHealUp(battle, unit, tb); // GUA-X: set_heal_scale_by_hpratio filters LT (strictly below)
if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) });
},
};
@@ -43,7 +43,7 @@ export default {
const b = a.findBuff('reckpr:guard');
if (b && b.data.src === unit) battle.heal(unit, a, num(b.data.value));
}, { owner: unit });
lowHpHealUp(battle, unit, tb);
lowHpHealUp(battle, unit, tb); // PHY-X: reckpr_e_002_tr filters LT (strictly below)
},
};
},
@@ -6,6 +6,7 @@ import {
num, bv, tbb, moduleBb, live, opsOf, ANY, onDefaultSkill, selectedSkill, skillGridOf, AROUND8, onElementHit,
elementDmg, aura,
} from '../shared/tier6.js';
import { holdProtect } from '../shared/tier1.js';
/**
* "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its
@@ -89,9 +90,10 @@ function yu(bb, chess, def) {
talents: [
{ install(battle, unit) { // 礼尚往来: 庇护 while blocking + DoT on blocked enemies
const dr = num(t0.damage_resistance), sc = bv(t0, 'atk_scale'), er = bv(t0, 'ep_damage_ratio'), iv = Math.max(0.1, bv(t0, 'interval', 1));
// 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例 — true / element damage is not reduced
// 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例(同名效果取最高)— true / element damage is not reduced; the shared
// 庇护 (holdProtect: the strongest of every source holds)
aura(battle, unit, 0.1, () => {
if (dr > 0 && unit.blocking.length) battle.addBuff(unit, { key: 'yu:shelter', mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr }, duration: 0.2, refresh: 'replace' });
if (dr > 0 && unit.blocking.length) holdProtect(battle, unit, dr, 0.2, unit);
});
aura(battle, unit, iv, () => {
for (const e of unit.blocking.slice()) {
@@ -5,6 +5,7 @@ import {
num, bv, tbb, tdesc, moduleBb, parseN, live, isTok, onDefaultSkill, selectedSkill, AROUND8, N4, bstate, bestTile,
pullToward,
} from '../shared/tier6.js';
import { holdProtect } from '../shared/tier1.js';
/** 莱茵生命 (character_table groupId "rhine") members of this season's pool (checked against the official table). */
const RHINE = new Set(['char_108_silent', 'char_128_plosis', 'char_202_demkni', 'char_249_mlyss', 'char_1047_halo2']);
@@ -139,7 +140,9 @@ function mlyss(bb, chess, def) {
const ranged = !!(m && m.ranged);
t.profile.hits = on && ranged && live(t) ? t.mem.mlyssHits * 2 : t.mem.mlyssHits; // 二连击
if (on && m && !ranged && live(t)) {
battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen, physTakenMul: 1 - dr, artsTakenMul: 1 - dr } });
battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen } });
// "获得15%的庇护": the shared 庇护 (holdProtect — 同名效果取最高 with every other source)
holdProtect(battle, t, dr, 0.4, unit);
}
}
}, { owner: unit });
+13 -16
View File
@@ -12,8 +12,8 @@
// - Trait (秘术师) "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射(最多3个)" (bb times; MSC-X 4):
// ranged arts bolts, hits air units (分支特性信息 "可对空"), blocks 1, ground enemies target him. The store (分支特性信息:
// "能量储存与攻击占用相同的攻击间隔:…若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为)"): when his attack is
// ready and he has no valid target he stores one energy and his attack interval starts again — this kit's own store,
// as 维伊's (op-veen.js; the engine's mystic profile stores one interval after the attack is ready). His next attack
// ready and he has no valid target he stores one energy and his attack interval starts again — the shared 秘术师
// profile (professions.js installMystic) with this kit's `storeEnergy` (his elite energy, S3), as 维伊's. His next attack
// releases them at its target ("一齐发射"), each hitting for ATK × the attack's scale × the energy scale (talent 1) as
// an arts 普通伤害 attack hit ("由储存能量形成的弹道造成攻击力100%的法术普通伤害"); they land with the main bolt (one
// volley to one target [ASSUMED], as 维伊's), the later ones only on a target still alive. [ASSUMED] a redeployment holds
@@ -65,7 +65,6 @@
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { acquireTargets, effectiveProfile } from '../../../ai.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
@@ -215,21 +214,19 @@ export default {
trait: {
hitsFn: () => 1, // the engine's main hit only: the stored energies are this kit's (land)
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main' && victim && victim.side === 'enemy') land(battle, unit, victim, hc); },
// the store (the shared profile calls it at his attack check with no valid target — professions.js installMystic:
// an attack action, the interval restarts): the trait's energies first, then the elite one; during S3 elite ones
// only (false = a full store: he idles)
storeEnergy(battle, unit) {
const v = stateOf(unit);
if (skillOn(unit, S3)) { if (v.n + v.e >= N + E) return false; v.e++; }
else if (v.n < N) v.n++;
else if (v.e < E) v.e++;
else return false;
return true;
},
install(battle, unit) {
battle.on('deploy', (ctx) => { if (ctx.unit === unit && !ctx.move) unit.trait.ebn = null; }, { owner: unit });
// the store: his attack ready and no valid target ⇒ one energy, an attack action (the interval restarts); the
// attack loop ran first this tick (a valid target was attacked instead)
battle.on('tick', () => {
if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0) return;
const prof = effectiveProfile(unit);
if (prof.noAttack || acquireTargets(battle, unit, prof).length) return;
const v = stateOf(unit);
if (skillOn(unit, S3)) { if (v.n + v.e >= N + E) return; v.e++; }
else if (v.n < N) v.n++;
else if (v.e < E) v.e++;
else return;
unit.atkCd = unit.s.interval;
}, { owner: unit });
battle.on('death', (ctx) => { if (ctx.unit && ctx.unit.side === 'enemy') stateOf(unit).q.delete(ctx.unit.id); }, { owner: unit });
},
},
+9 -19
View File
@@ -10,9 +10,8 @@
// 属性加成,损失一定比例时达最大加成(同类属性取最高)") and ba.protect 庇护 ("受到的物理和法术伤害降低相应比例(同名效果取最高)"), his
// battle skeleton char_188_helage.skel (Skill: two OnAttack, Skill_2: two, Skill_3_Loop: one).
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (professions.js `musha`: no
// heal from others, value HP per enemy an attack hits), ground-only melee on his 1-1, block 1. The branch heals on every
// damage instance he deals: the engine heals once per enemy an attack strikes, so the second hit of S1 / S2's 二连击 on
// an enemy heals once more here (the trait's `value`, a self heal).
// heal from others, `value` HP on every damage instance he deals — PRTS 分支特性信息 武者 — so the second hit of S1 / S2's
// 二连击 heals too), ground-only melee on his 1-1, block 1.
// - T1 月盈星亏 "在场时,自身获得最高+100攻击速度的坚忍(损失70%生命值时达到最大加成)": ASPD + min_attack_speed × the share of
// the HP lost up to 1 − min_hp_ratio (linear, 坚忍), refreshed every tick. SBL-X stage 3: +130 at 50 % lost (the module
// talent change).
@@ -26,7 +25,7 @@
// parts at once].
// - Module SBL-X “藏锋” "生命值低于50%时,获得25%的庇护" (a display trait part; the effect is the hidden talent part hp_ratio /
// damage_resistance, merged into talent 0's blackboard): below hp_ratio of his max HP the physical and arts damage he takes
// ×(1 − damage_resistance) (as 宴's elite module).
// ×(1 − damage_resistance) (as 宴's elite module) — 庇护, "同名效果取最高": the shared effect (tier1.js holdProtect).
// - Module SBL-Y “热的雪” "被击倒时不撤退且回复30%生命(单次部署只触发1次)" (trait bb hp_ratio): the first lethal blow of a
// deployment leaves him on the field at hp_ratio of his max HP (a kit saver, priority −50 as 斯卡蒂's module).
// - S1 新月 (AUTO, attack SP, DEFAULT): the next attack at atk_scale × ATK, twice (Skill clip: two OnAttack).
@@ -34,7 +33,7 @@
// - S3 满月 (MANUAL, time SP, data ACTIVE_RANGE on the running 1-1 + 1): `duration` s — ATK +atk, attack range +1 tile
// forward (ability_range_forward_extend), up to attack@max_target targets at once; back to 1-1 after.
import { num, traitBb, skillRec, toggleBuff, up, onHitOn } from '../shared/tier1.js';
import { num, traitBb, skillRec, toggleBuff, up, onHitOn, holdProtect, PROTECT_TICK_HOLD } from '../shared/tier1.js';
const S1 = 'skchr_helage_1';
const S2 = 'skchr_helage_2';
@@ -90,22 +89,13 @@ export default {
} },
],
install(battle, unit) {
// 武者: the trait heal on every damage instance — the engine heals once per enemy an attack strikes, so each further
// hit of the same attack on that enemy (S1, S2's 二连击) heals once more
battle.on('damaged', (c) => {
const d = c.dmg;
if (c.source !== unit || !unit.alive || !d || !d.isAttack || !d.attackId || !c.target || c.target.side !== 'enemy') return;
const m = unit.mem;
if (m.helageAttackId !== d.attackId) { m.helageAttackId = d.attackId; m.helageStruck = new Set(); }
if (!m.helageStruck.has(c.target.id)) { m.helageStruck.add(c.target.id); return; }
battle.heal(unit, unit, num(unit.profile?.selfHeal, num(tb.value, 70)), { self: true });
}, { owner: unit, priority: -10 });
// SBL-X “藏锋”: 生命值低于50%时,获得25%的庇护 (physical and arts damage taken)
// SBL-X “藏锋”: 生命值低于50%时,获得25%的庇护 — the shared 庇护 (holdProtect: the strongest of every source holds),
// refreshed every tick and at each hit on him while he is below
const dr = num(t0.damage_resistance), drBelow = num(t0.hp_ratio);
if (dr > 0 && drBelow > 0) {
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < drBelow) dmg.mul *= 1 - dr;
});
const keep = () => { if (up(unit) && unit.hpRatio < drBelow) holdProtect(battle, unit, dr, PROTECT_TICK_HOLD, unit); };
battle.on('tick', keep, { owner: unit });
onHitOn(battle, unit, keep);
}
// SBL-Y “热的雪”: 被击倒时不撤退且回复30%生命(单次部署只触发1次)
const rise = num(tb.hp_ratio);
+10 -6
View File
@@ -27,8 +27,9 @@
// - T2 余火之氅 "使自身与身后一格的友军获得18%的庇护" (damage_resistance, talent grid b-1: his tile and the one behind; CRU-X
// stage 3 "28%…且造成的物理伤害提升10%" — hodrer_equip_1_3_p2 gives damage_resistance[inf] and hodrer_equip[damage_scale_up]
// (ON_OUTPUT_DAMAGE PHYSICAL ×damage_scale) to the same targets, ally side, any motion, removed when they leave): 庇护
// (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") = phys / artsTakenMul 1 − value as one instance per ally
// (applyStrongest) with physDealtMul ×damage_scale, renewed every PROTECT_IV s on the allies of the grid while he is deployed.
// (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") = phys / artsTakenMul 1 − value — the shared 庇护 of every source
// (tier1.js holdProtect: damage_resistance[inf] is the common key) — and physDealtMul ×damage_scale (its own one instance per
// ally), renewed every PROTECT_IV s on the allies of the grid while he is deployed.
// - S1 重锋不熄 (AUTO, hit SP: INCREASE_WHEN_ATTACK, data DEFAULT): the next attack (every enemy he blocks) at atk_scale × ATK;
// hodrer_s[heal] (ON_ABILITY_SPELL_ON, HealViaMaxHpRatio) heals hp_ratio of his max HP once for that attack.
// - S2 余烬重荷 (MANUAL, data DEFAULT): "被动效果:攻击力+16%" (atk; the skill's extra ability hodrer_s_2[passive]) while it is
@@ -44,7 +45,7 @@
// then on it takes attack@damage 无来源 true damage (NoSourceDamage PURE: damage.js periodicDamage, credited to him) every
// second of its own (triggerInterval 1 s from its mark) until the skill ends (a derived buff of the skill's).
import { num, talentBb, talentGrid, moduleBb, traitBb, skillRec, statBuff, up, batMod, alliesInGridOf } from '../shared/tier1.js';
import { num, talentBb, talentGrid, moduleBb, traitBb, skillRec, statBuff, up, batMod, alliesInGridOf, holdProtect } from '../shared/tier1.js';
import { periodicDamage } from '../../../damage.js';
const S1 = 'skchr_hodrer_1';
@@ -53,7 +54,8 @@ const S3 = 'skchr_hodrer_3';
/** 余火之氅's grid when the data carries none: his tile and the one behind him (range b-1). */
const BEHIND = Object.freeze([Object.freeze([0, -1]), Object.freeze([0, 0])]);
const PROTECT_IV = 0.25;
const PROTECT_KEY = 'talent:hodrer:protect';
/** CRU-X stage 3's 物理伤害 + (hodrer_equip[damage_scale_up]): one instance per ally (the 庇护 is the shared key). */
const DMG_UP_KEY = 'talent:hodrer:dmgUp';
const S2_PASSIVE_KEY = 'skill:hodrer:s2passive';
/** hodrer_s_3[damage] / hodrer_s_3[burn]: triggerInterval 1 s, waitFirstTriggerInterval. */
const SMOKE_IV = 1;
@@ -140,10 +142,12 @@ export default {
const dr = num(t1.damage_resistance), ds = num(t1.damage_scale, 1);
const grid = talentGrid(chess, 1) ?? BEHIND;
if (!(dr > 0) && ds === 1) return;
const mods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v, ...(ds !== 1 ? { physDealtMul: ds } : {}) });
const give = () => {
if (!up(unit)) return;
for (const a of alliesInGridOf(battle, unit, grid)) battle.applyStrongest(a, PROTECT_KEY, { duration: PROTECT_IV + 0.1, value: dr, mods, source: unit });
for (const a of alliesInGridOf(battle, unit, grid)) {
holdProtect(battle, a, dr, PROTECT_IV + 0.1, unit);
if (ds !== 1) battle.applyStrongest(a, DMG_UP_KEY, { duration: PROTECT_IV + 0.1, value: ds, mods: (v) => ({ physDealtMul: v }), source: unit });
}
};
battle.every(PROTECT_IV, give, { owner: unit });
battle.on('battleStart', give, { owner: unit });
+12 -7
View File
@@ -33,7 +33,9 @@
// [ASSUMED: given here]); its DEF is ×0 while its tile is outside her attack range (kalts_t_1[token_def_down]; PHY-Y stage
// 3: inside it ASPD +attack_speed, DEF +def — kalts_t_1[token_def_down_finish]). It is withdrawn when she leaves (no
// rattle); it comes back on its tile its respawnTime (25 s) after it left, paying its cost (10 DP), only while she stands —
// her (re)deployment readies a waiting one at once (charge_token[born]) [ASSUMED: the 卫戍 auto redeploy of a placed summon].
// her (re)deployment readies a waiting one at once (charge_token[born]: RechargeToken with timing NORMAL, while its own timer
// is the summon's charge_token[finish], ON_FINISH — the one reading of every summoner kit, 傀影's twin included)
// [ASSUMED: the 卫戍 auto redeploy of a placed summon].
// - T2 不毁重构: Mon3tr knocked out (not withdrawn; S3's 流失 counts) ⇒ every selectable enemy of its 3×3 (the talent's x-4,
// air units too) takes `value` (1200) true damage (溅射, not dodgeable) and is stunned `stun` (3) s. PHY-X stage 3 (the
// token's module talent: 3.5 s, 1500, hp_ratio): also once per deployment when a hit leaves it at or below hp_ratio of
@@ -41,8 +43,9 @@
// - S1 指令:结构加固 (MANUAL, data DEFAULT — a heal skill: cast as she is about to heal): her DEF +def and Mon3tr's DEF
// +attack@def while it runs; 物理格挡 prob (damage_block[phy]: a physical damage instance she takes is blocked whole —
// not a dodge, nothing ignores it; a 流失 is no damage).
// - S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +attack_speed; Mon3tr ATK +attack@atk and each of its attacks
// strikes every enemy it blocks (one target when it blocks none [ASSUMED]).
// - S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +attack_speed; Mon3tr ATK +attack@atk and "可以攻击阻挡的所有
// 敌人": each of its attacks takes up to its block count (3) of targets, the blocked ones first (the 强攻手 rule — PRTS 分支特性信息
// "普通攻击最大目标数等于阻挡数(不会低于1)"; its mode's _limitedMaxTargetNumToBlockedCnt).
// - S3 指令:熔毁 (MANUAL, data DEFAULT, 绑定 Mon3tr, 18 s): Mon3tr DEF +attack@def, its attacks deal true damage, its ATK
// +attack@atk × the remaining share of the skill, updated every second from the cast (+190 % … +10.6 %); unless it killed
// an enemy meanwhile, at the skill's normal end it loses attack@hp_ratio × its max HP (a 流失 that may knock it out).
@@ -52,6 +55,7 @@
import { num, talentBb, traitBb, moduleOn, skillRec, up } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { acquireTargets } from '../../../ai.js';
const S1 = 'skchr_kalts_1';
const S2 = 'skchr_kalts_2';
@@ -130,11 +134,12 @@ function mon3trKit(owner, { t0, b1, b2, b3 }) {
}
holdBuff(battle, m, 'kalts:s3:mon3tr', skillOn(owner, S3), { atkPct: s3Atk, defPct: num(b3['attack@def']) });
}, { owner: m });
// S2: each attack strikes every enemy it blocks
// S2: "Mon3tr可以攻击阻挡的所有敌人" — the 强攻手 rule (its mode's _limitedMaxTargetNumToBlockedCnt): up to its block count
// of targets, the blocked ones first (ai.js acquireTargets with `hitAllBlocked`)
battle.on('beforeAttack', (c) => {
if (c.attacker !== m || !skillOn(owner, S2)) return;
const blocked = battle.blockedTargets(m, c.profile);
if (blocked.length) c.targets = blocked;
if (c.attacker !== m || !skillOn(owner, S2) || !c.profile) return;
const t = acquireTargets(battle, m, { ...c.profile, hitAllBlocked: true });
if (t.length) c.targets = t;
}, { owner: m });
// S3: true damage; a kill keeps its HP
battle.on('hit', (c) => {
+14 -26
View File
@@ -27,8 +27,9 @@
// prosts_t_1[born_charge] BLOCK_CNT ×0, interval 0.1, firstTriggerInterval 1, FixedValueDamage PURE `damage` on itself);
// buff_template_data (monstr_t_2[atk_spd] ON_OUTPUT_MODIFIER IsHeal; monstr_s_3[undead] ON_BEFORE_TRY_SET_HP_ZERO).
// - Trait (链愈师) "恢复友方单位生命,且会在3个友方单位间跳跃,每次跳跃治疗量降低25%" — the chain is this kit's (the profession's
// 2.5-tile chain is replaced: heal mode single + `chainFrom`), as her heal projectiles have it: after the heal on the main
// target the heal jumps to the lowest-HP-ratio injured ally of the 3×3 around the last one healed (x-4), each jump ×
// chain is replaced: heal mode single + `chainFrom`, its links the profession's — ai.js chainHealNext): after the heal on
// the main target the heal jumps to the lowest-HP-ratio ally (a full-HP one too — PRTS 分支特性信息 链愈师; ties: the latest
// deployed) of the 3×3 around the last one healed (x-4), each jump ×
// attack@chain.atk_scale (0.75; XAH-X 0.85), up to attack@chain.max_target units healed (3). A 重构体 (and she herself while S3
// runs: monstr[free_jump]) is a free link: healing it uses no target of the count and the next jump does not decay (XAH-X
// stage 3: the next attack@chain.extra_cnt jumps after that neither). Her heals pass 禁疗 ("治疗效果无视禁疗"); her selection
@@ -54,8 +55,9 @@
// - S3 策略:熔毁 (MANUAL, data DEFAULT, 25 s; only while her 重构体 stands — the cast is refused otherwise): the 重构体 is withdrawn
// (its piece waits: back once its tile is free again) and she 【移动】s onto its tile; meanwhile range x-4, ATK +atk, base attack
// time base_attack_time (a flat −1.5 s on 2.85 s), block +block_cnt, max HP +max_hp (the HP ratio kept), 流失 of
// damage_per_second HP/s (8 every 0.1 s); her attacks hit every ground enemy she blocks (one in range when she blocks none —
// [ASSUMED] as 凯尔希's Mon3tr S2) for ATK true damage, and each attack starts a chain heal on herself at attack@heal_scale × ATK
// damage_per_second HP/s (8 every 0.1 s); "同时攻击阻挡的所有敌人" (her selector's _limitedMaxTargetNumToBlockedCnt): each attack
// takes up to her block count of ground enemies of her range, the blocked ones first (ai.js `hitAllBlocked`, the 强攻手 rule
// of PRTS 分支特性信息) for ATK true damage, and each attack starts a chain heal on herself at attack@heal_scale × ATK
// (she is a free link). 不死: a hit or 流失 that would knock her out leaves her at 1 HP, untargetable and blocking nothing, and
// the skill ends on the next tick. At the end she 【返回】s to the tile she left (her SP emptied; the max HP drop keeps the ratio,
// so after a fatal blow she stands at ≤ 1 HP) with 1 s of 不死. The 路标形态 marker on her tile is not modelled: should another
@@ -63,6 +65,7 @@
import { num, talentBb, traitBb, skillRec, batMod, installAura, up } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { chainHealNext } from '../../../ai.js';
const S1 = 'skchr_monstr_1';
const S2 = 'skchr_monstr_2';
@@ -101,28 +104,13 @@ const prostsInRange = (battle, unit) => prostsOf(battle, unit).find((t) => up(t)
/** `a` stands on the 3×3 around `b` (x-4: a heal jump's reach, the 重构体's aura). */
const nearX4 = (a, b) => Math.abs(a.tileR - b.tileR) <= 1 && Math.abs(a.tileC - b.tileC) <= 1;
/**
* The next link of a chain heal from `cur`: the lowest-HP-ratio injured ally of its 3×3 not healed yet by this chain (her
* own 重构体 even at full HP when `fullProsts`); never a 禁疗 unit but her 重构体.
*/
function nextLink(battle, unit, cur, seen, fullProsts) {
let best = null;
for (const a of battle.allyUnits) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device' || seen.has(a.id) || !nearX4(a, cur)) continue;
const mine = isProstsOf(a, unit);
if (a !== unit && ((a.s.flags.noHeal && !mine) || (a.profile && a.profile.noHeal))) continue;
if (!(a.hp < a.s.maxHp - 1e-6) && !(fullProsts && mine)) continue;
if (!best || a.hpRatio < best.hpRatio - 1e-12 || (Math.abs(a.hpRatio - best.hpRatio) <= 1e-12 && a.deploySeq < best.deploySeq)) best = a;
}
return best;
}
/**
* A chain heal of hers from `main` (already healed by her attack when `mainDone`): up to `count` units healed, × `step` per
* jump; a free link (her 重构体; herself with `selfFree`) uses no target of the count and the next 1 + `extra` jumps keep the
* amount.
* amount. Each link is the 链愈师 one (ai.js chainHealNext — the 3×3 around the last one healed, the lowest HP ratio a
* full-HP ally included, then the latest deployed; no 禁疗 unit but her 重构体, her `healThrough`).
*/
function chainFrom(battle, unit, main, base, { count, step, extra, fullProsts = false, mainDone = false, selfFree = false }) {
function chainFrom(battle, unit, main, base, { count, step, extra, mainDone = false, selfFree = false }) {
const free = (t) => isProstsOf(t, unit) || (selfFree && t === unit);
let amount = base;
if (!mainDone) battle.heal(unit, main, amount, HEAL_OPTS);
@@ -131,7 +119,7 @@ function chainFrom(battle, unit, main, base, { count, step, extra, fullProsts =
const seen = new Set([main.id]);
let cur = main;
while (counted < count) {
const next = nextLink(battle, unit, cur, seen, fullProsts);
const next = chainHealNext(battle, unit, cur, seen);
if (!next) break;
seen.add(next.id);
if (calm > 0) calm--; else amount *= step;
@@ -212,7 +200,7 @@ export default {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess), blockCnt: num(b3.block_cnt), hpFlat: num(b3.max_hp) },
targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid } : undefined,
attack: { dmgType: 'true', attack: 'melee', projectile: 'none', canHitFly: false, groundOnly: true, hitAllBlocked: true, maxTargets: 1 },
attack: { dmgType: 'true', attack: 'melee', projectile: 'none', canHitFly: false, groundOnly: true, hitAllBlocked: true },
onStart({ battle, unit, skill }) {
const r = prostsOf(battle, unit).find(up);
if (!r) { skill.end('noToken'); skill.addCharge(1); return; } // (refused before: the activate guard)
@@ -280,12 +268,12 @@ export default {
const base = unit.s.atk * (prof.atkScale ?? 1) * (prof.healScale ?? 1) * unit.s.atkScaleMul;
for (const t of c.targets || []) {
if (!t || t.side !== 'ally') continue;
chainFrom(battle, unit, t, base, { ...chain, count: s1on ? s1Count : chain.count, fullProsts: s2on, mainDone: true });
chainFrom(battle, unit, t, base, { ...chain, count: s1on ? s1Count : chain.count, mainDone: true });
// S2: 0.6 s after her attack healed her 重构体, a second chain on it (her ATK × 1)
if (s2on && isProstsOf(t, unit)) {
battle.after(S2_EXTRA_DELAY, () => {
if (!unit.alive || !up(t)) return;
chainFrom(battle, unit, t, unit.s.atk * unit.s.atkScaleMul, { ...chain, fullProsts: skillOn(unit, S2) });
chainFrom(battle, unit, t, unit.s.atk * unit.s.atkScaleMul, chain);
}, { owner: unit });
}
}
+11 -4
View File
@@ -38,10 +38,10 @@
// each of its own deployments. When he leaves the field (knocked out, withdrawn, forced out) it is withdrawn (PRTS "强制
// 撤退"; the client's KillTokens); a twin off the field comes back on its tile — paying its cost (5 DP) as a deployment,
// the 卫戍 auto redeploy of a placed summon [ASSUMED, as 鸿雪's 打字机 / 凯尔希's Mon3tr] — once its redeploy time has
// passed since its LAST DEPLOYMENT (PRTS 备注: the timer starts at the summon's deployment) and only while he stands (it
// is recharged by his deployment, withdrawn by his leaving) [ASSUMED: his redeployment does not cut the twin's own timer
// short — the client's RechargeToken NORMAL; 虚影精通 / EXE-X would matter little otherwise; the 凯尔希 kit reads the same
// charge_token[born] as "readies at once"]. EXE-Y stage 2+ "本体和虚影同时在场时,攻击力各+10%": the twin's hidden module
// passed since its LAST DEPLOYMENT (PRTS 备注: the timer starts at the summon's deployment; the twin's charge_token[finish]
// recharges it ON_FINISH) and only while he stands; his (re)deployment readies a waiting twin at once (his
// charge_token[born]: RechargeToken with timing NORMAL — read the same way for every summoner, 凯尔希's Mon3tr included;
// until 0.2.0 this kit let the twin's own timer run on). EXE-Y stage 2+ "本体和虚影同时在场时,攻击力各+10%": the twin's hidden module
// talent `atk` — ATK +atk on both while both are on the field.
// - T2 虚影精通 "虚影的再部署时间-10秒" (EXE-X stage 3: −16): the twin's own talent respawn_time, added to its 45 s.
// - S1 暗夜魅影 (被动, at each deployment): physical dodge `prob` and a 屏障 of hp_ratio × max HP that absorbs physical damage
@@ -210,6 +210,13 @@ export default {
// "傀影退场时强制撤退场上的镜中虚影" (KillTokens): a withdrawal, not a knock-out
for (const t of twinsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'retreat' });
}, { owner: unit });
// charge_token[born] (RechargeToken, timing NORMAL): his (re)deployment readies a waiting twin at once — the
// twin's own timer is the ON_FINISH recharge of its charge_token[finish] (as every summoner kit reads it)
// [ASSUMED: NORMAL = usable now, from the enum and the first deployment]
battle.on('deploy', (c) => {
if (c.unit !== unit || c.move) return;
for (const t of twinsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.twinReadyAt = battle.time;
}, { owner: unit });
// 本体和虚影同时在场时,攻击力各+N%: the twin's hidden module talent (his text when no twin def resolves)
const both = num(tokTalentBb(battle.tokenDef(TWIN, unit), 'atk').atk, /本体和虚影同时在场时/.test(t0desc)
? num(+(/攻击力各\+(\d+)%/.exec(t0desc)?.[1]) / 100) : 0);
+3 -2
View File
@@ -10,7 +10,8 @@
// - Trait (医师) "恢复友方单位生命": the profession default (one heal on the lowest-HP% injured ally of her 3-3 range).
// Module PHY-Y 干枯剑鞘 "治疗地面单位时治疗量提升15%" (trait bb heal_scale): ×heal_scale on a heal whose target stands on
// a 地面 tile (unit.ground, as 赫默's PHY-Y); PHY-X “使徒” "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale,
// hp_ratio): ×heal_scale when the target is below hp_ratio of its max HP before the heal (as 录武官's / 华法琳's PHY-X).
// hp_ratio): ×heal_scale when the target is at or below hp_ratio of its max HP before the heal — the client's
// shining_e_003_tr is heal_scale_up[hpratio][LE] (as 华法琳's PHY-X; 录武官's reckpr_e_002_tr is LT, strictly below).
// - T1 黑恶魔的庇护 "攻击范围内的友方单位防御力+60": every ally (operators and summons: 友方单位) standing in her current
// attack range — her own tile included — DEF +def (flat). PHY-Y stage 2+ "防御力+100,地面单位防御力额外+40" (talent
// change def / def_lowland): def_lowland more on a 地面 tile. Refreshed every AURA s; two sources keep the strongest.
@@ -130,7 +131,7 @@ export default {
// module traits: PHY-Y heals on 地面 units, PHY-X heals on allies below hp_ratio, ×heal_scale
const hs = num(tb.heal_scale, 1);
if (!mod || hs === 1) return;
const boosted = mod === PHY_X ? (t) => t.hpRatio < num(tb.hp_ratio) : mod === PHY_Y ? (t) => !!t.ground : null;
const boosted = mod === PHY_X ? (t) => t.hpRatio <= num(tb.hp_ratio) + 1e-9 : mod === PHY_Y ? (t) => !!t.ground : null;
if (!boosted) return;
battle.on('heal', (ctx) => {
if (ctx.source !== unit || ctx.opts?.regen || !ctx.target || !boosted(ctx.target)) return;
+14 -18
View File
@@ -9,9 +9,9 @@
// - Trait (秘术师) "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射" (ranged arts, hits air units,
// targetable by ground enemies). PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在
// 有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为)" — when her attack is ready
// and she has no valid target she stores one energy and her attack interval starts again (this kit replaces the
// engine's mystic profile, which stores one interval after the attack is ready); the energies fly with her next attack,
// at its target ("由储存能量形成的弹道造成攻击力100%的法术普通伤害").
// and she has no valid target she stores one energy and her attack interval starts again (the shared 秘术师 profile,
// professions.js installMystic, with this kit's `storeEnergy`); the energies fly with her next attack, at its target
// ("由储存能量形成的弹道造成攻击力100%的法术普通伤害").
// T1 “在挥刀之前” "特性储存的能量达到3个时,合成为1个转置能量(至多储存等同于9倍的能量)" (trait bb merge_cnt 3, times 9):
// 3 stored make 1 转置能量, at most times / merge_cnt of those, then no more storing (备注 "转置能量数量达到上限时,停止储存
// 攻击能量"); a 转置能量 hits for merge_cnt × ATK (备注 "攻击力300%的法术普通伤害"). Every shot uses the ATK it left with
@@ -39,7 +39,7 @@
// preferring an enemy not hit yet by it, else another one, else the same (text "优先不同目标" [ASSUMED order]), each
// attack@bounce_atk_scale × the cached ATK arts. The skill ends when the bullets run out.
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } from '../shared/tier1.js';
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, toggleBuff } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
const S1 = 'skchr_veen_1';
@@ -146,23 +146,19 @@ export default {
trait: {
hitsFn: () => 0, // the volley's hits are the kit's (land)
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main' && victim && victim.side === 'enemy') land(battle, unit, victim, hc.attackId, !!hc.isSkill); },
// only enemies under her 战争技艺 in range: she holds her fire (and stores energy)
canAttack(battle, u) {
const ok = validTargets(battle, u, u.profile).length > 0;
if (!ok) u.trait.hadTarget = false;
return ok;
// only enemies under her 战争技艺 in range: no valid target — she holds her fire (and stores energy)
canAttack(battle, u) { return validTargets(battle, u, u.profile).length > 0; },
// the mystic store (the shared profile calls it at her attack check with no valid target — professions.js
// installMystic: an attack action, the interval restarts): one energy, 3 merge into a 转置能量; none once the
// 转置能量 are at their cap (a full store: she idles)
storeEnergy(battle, unit) {
const v = stateOf(unit);
if (v.t >= tMax) return false;
if (++v.s >= merge) { v.s -= merge; v.t++; }
return true;
},
install(battle, unit) {
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.veen = null; }, { owner: unit });
// the mystic store: her attack ready and no valid target ⇒ one energy, an attack action (the interval restarts);
// 3 merge into a 转置能量 (the attack loop ran first this tick: a valid target was attacked instead)
battle.on('tick', () => {
const v = stateOf(unit);
if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0 || v.t >= tMax) return;
if (validTargets(battle, unit, unit.profile).length) return;
if (++v.s >= merge) { v.s -= merge; v.t++; }
unit.atkCd = unit.s.interval;
}, { owner: unit });
battle.on('death', (ctx) => { if (ctx.unit && ctx.unit.side === 'enemy') stateOf(unit).q.delete(ctx.unit.id); }, { owner: unit });
},
},
+27 -13
View File
@@ -9,8 +9,8 @@
// `[shield_core]`, `zuole_s_3` / `[shield]`, `zuole_e_002[resistance]`, `zuole_e_003_talent`, `zuole_e_003_trait`);
// Arknights Terra Wiki "Zuo Le" (the barrier caps count each skill's own barrier only).
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (musha: no heal from
// others, `value` per enemy hit; zuole_trait heals on every damage he outputs — the profession heals a normal attack
// per target and any other damage per instance, so S1's extra strikes are separate damage instances).
// others; zuole_trait heals on every damage he outputs — the profession heals `value` per damage instance, PRTS 分支特性信息
// 武者, so S1's extra strikes heal too).
// - T1 秉烛照影 "在场时,自身获得最高+50攻击速度和技力自然回复速度+2/秒的坚忍(损失70%生命值时达到最大加成)" (bb
// min_attack_speed / min_sp_recovery_per_sec / min_hp_ratio): 坚忍 (ba.berserk "根据已损失的生命值获得相应比例的属性
// 加成") — ASPD and SP recovery × min(1, lost HP ÷ (1 − min_hp_ratio)) while he is deployed. SBL-X stage 3: +70 /
@@ -20,7 +20,8 @@
// (zuole_e_003_talent, ON_CALCULATE_DAMAGE): "…且当次攻击的攻击力提升至120%…80%": a success also scales that damage
// instance by atk_scale.
// - Module SBL-X “岂苦夜长” (trait moduleDesc "生命值低于50%时,获得25%的庇护"; the hidden part merged into the first talent's
// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例") while HP < hp_ratio
// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)": the shared effect of
// every source, tier1.js holdProtect) while HP < hp_ratio
// (zuole_e_002[resistance]: the 庇护 buff is created when HP drops below it and finished when it is back). PRTS S2 备注:
// 行险 resets it, it comes back 0.1 s later "并因此导致庇护的BUFF顺序落后于本技能的屏障" — a 庇护 created after his
// barrier cuts only what passes the barrier (the `hpDamage` hook), one created before it cuts the hit first.
@@ -29,7 +30,8 @@
// not while a 不死 already holds him ("_dontConsumeWhenUndeadable": 坚固维式重锤's window, items/battle.js holdsUndying).
// - S1 破虏 (AUTO, 可充能 2 次): the next attack at atk_scale × ATK; "自身生命低于80%时额外攻击1次,低于50%时额外攻击2次"
// (hp_ratio_double / hp_ratio_tripple): one / two more strikes on that target at the same scale, separate damage
// instances [ASSUMED: the same target — nothing when it fell]. The data's DEFAULT trigger (an AUTO "next attack").
// instances [ASSUMED: the same target — nothing when it fell; the HP read when the skill fires, before the strike's
// own trait heal]. The data's DEFAULT trigger (an AUTO "next attack").
// - S2 行险 (MANUAL, 12 s): "立即流失50%当前生命" (zuole_s_2[shield] DamageViaCurHpRatio, undeadable: never below 1 HP;
// a 流失), then a barrier of scale × max HP (ba.barrier "可以吸收一定数值的伤害") added to the one he still has, capped at
// max_scale × max HP at each gain (PRTS "上限于每次获取屏障时实时发生变化"; Terra: the cap counts this skill's barrier
@@ -45,7 +47,7 @@
// at max_scale × max HP at each gain, lasting shield_duration s from the latest gain (PRTS "每次获取屏障时重置剩余持续
// 时间为15s").
import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec } from '../shared/tier1.js';
import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec, holdProtect, PROTECT, PROTECT_TICK_HOLD } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js';
import { holdsUndying } from '../../items/battle.js';
@@ -96,10 +98,14 @@ export default {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// the HP that decides the extra strikes: when the skill fires, before its strike lands (the trait heals at each
// damage instance — professions.js musha — so after the first strike he would read healed) [ASSUMED]
onStart({ unit }) { unit.mem.zuoleS1Hp = unit.hpRatio; },
attack: {
atkScale: num(b1.atk_scale, 1),
onHit({ battle, unit, target }) {
const r = unit.hpRatio;
const r = unit.mem.zuoleS1Hp ?? unit.hpRatio;
unit.mem.zuoleS1Hp = null;
const extra = (r < num(b1.hp_ratio_double, 0.8) ? 1 : 0) + (r < num(b1.hp_ratio_tripple, 0.5) ? 1 : 0);
for (let i = 0; i < extra && target && target.alive; i++) {
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
@@ -123,9 +129,12 @@ export default {
data: { createdAt: battle.time } });
}
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
// SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier)
// SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier) — his own held 庇护
// goes now (another source's comes back with its next refresh)
P.since = null;
P.blockedUntil = battle.time + PROTECT_RESET;
const held = unit.findBuff(PROTECT);
if (held && held.source === unit) battle.removeBuff(unit, held);
},
},
[S3]: {
@@ -197,8 +206,10 @@ export default {
if (!unit.mem.zuoleS3 || c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
gainS3Barrier(battle, unit);
}, { owner: unit });
// SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险
if (protectCut > 0) {
// SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险 — the shared 庇护 (holdProtect: the
// strongest of every source holds), refreshed every tick and at each hit on him. When the 庇护 held is his own and his
// barrier is older than it, his cut comes after the barrier (the hpDamage hook) instead of before it
if (protectCut > 0 && protectCut < 1) {
const active = () => {
const now = battle.time;
if (!up(unit) || now < P.blockedUntil - 1e-9) { P.since = null; return false; }
@@ -206,13 +217,16 @@ export default {
P.since = null;
return false;
};
const keep = () => { if (active()) holdProtect(battle, unit, protectCut, PROTECT_TICK_HOLD, unit); };
const own = () => { const b = unit.findBuff(PROTECT); return !!b && b.source === unit && b.data?.value === protectCut; };
const afterBarrier = new WeakSet();
battle.on('tick', active, { owner: unit });
battle.on('tick', keep, { owner: unit });
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || !active()) return;
keep();
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || P.since == null || !own()) return;
const bar = barrierOf(unit);
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) afterBarrier.add(dmg);
else dmg.mul *= 1 - protectCut;
// (the held buff's mods cut this hit before the barrier: undo that and cut what passes the barrier instead)
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) { dmg.mul /= 1 - protectCut; afterBarrier.add(dmg); }
});
battle.on('hpDamage', (c) => { if (c.target === unit && afterBarrier.has(c.dmg)) c.amount *= 1 - protectCut; }, { owner: unit });
}
@@ -25,7 +25,7 @@ export default {
// Talents 攫升 / 超脱: content/tokens.js mapCharTalents (the strategy's Touch too); the module's 超脱 upgrade (8 SP at
// module Lv3) comes with the record's talents.
// Module PHY-X (elite, trait addition): "治疗生命值低于50%的友方单位时治疗量提升15%" — every heal of hers on an ally
// below `hp_ratio` HP (strictly: "低于") ×`heal_scale` (the trait blackboard). With S3's ×1.35 / ×1.40 it
// below `hp_ratio` HP (strictly: "低于"; the client's acmedc_e_tr filters LT) ×`heal_scale` (the trait blackboard). With S3's ×1.35 / ×1.40 it
// multiplies (×1.61 at Lv7): both are `heal_scale` blackboards, the 治疗倍率, and PRTS 游戏数据基础 §倍率 says "同种
// 倍率间叠乘" — no Touch-specific note or buff template exists. S3's extra heal is a heal of hers like any other: its
// recipient below `hp_ratio` gets the ×1.15 too (its base is taken before either bonus).
+12 -7
View File
@@ -12,8 +12,10 @@ import { bodyInKeys } from '../../../body.js';
const S1 = 'skchr_acspec_1';
const S2 = 'skchr_acspec_2';
const S3 = 'skchr_acspec_3';
const S1_KEY = 'acspec:s1';
const PULL_TAG = 'acspec:pull';
/** A 被动 ON_DEPLOY skill with a duration: a duration skill every deployment starts (no SP, never cast otherwise). */
const ON_DEPLOY = Object.freeze({ kind: 'duration', activateOnDeploy: true, spCost: 0, spType: 'none', trigger: 'NEVER' });
const s1On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S1);
/**
* 四维分离's "周围四格": her tile and the four tiles beside it [ASSUMED: her own tile counts — an enemy she blocks stands
* on it, and the talent is about her being alone with one enemy].
@@ -63,9 +65,12 @@ export default {
// attack@prob (PRTS 备注: it is her normal attack's basic logic; EXE-X levels 2 / 3 make it +atk ATK and a higher
// chance). Talent 2 四维分离: ATK +atk while exactly `cnt` enemy stands on her tile or the four beside it.
// All three skills are 被动, ON_DEPLOY — they start at every deployment (the battle-start one included, when no enemy
// is on the field yet: S3 then hits nothing, as in the official mode; it matters on a redeploy):
// S1 物理的服从: for the skill's duration (10 s) the talent-1 chance is attack@prob and physical dodge prob.
// S2 战争的恭顺: for the skill's duration (10 s) ATK +atk, ASPD +attack_speed.
// is on the field yet: S3 then hits nothing, as in the official mode; it matters on a redeploy). S1 / S2 last the
// skill's duration (10 s): duration skills started by the deployment (`activateOnDeploy`, the deploy-timed skill
// contract of PR #109 — their own skillStart / skillEnd and the client's skill on / off), not a passive holding a timed
// buff:
// S1 物理的服从: while it runs the talent-1 chance is attack@prob and physical dodge prob.
// S2 战争的恭顺: while it runs ATK +atk, ASPD +attack_speed.
// S3 空间的归依: spaceBurst above (ground only), right after each deployment (install).
// Module EXE-X (trait atk): ATK +atk while no ally stands on the four tiles beside her (PRTS: unlike the usual EXE-X;
// shared/tier4.js lonely, as 缄默德克萨斯's module of the same text).
@@ -81,11 +86,11 @@ export default {
const cnt = Math.max(1, Math.floor(num(t1.cnt, 1)));
return {
skills: {
[S1]: { kind: 'passive', onStart({ battle, unit }) { battle.addBuff(unit, { key: S1_KEY, duration: num(r1?.duration, 10), mods: { dodgePhys: num(b1.prob) }, tags: ['skill'] }); } },
[S2]: { kind: 'passive', onStart({ battle, unit }) { battle.addBuff(unit, { key: 'acspec:s2', duration: num(r2?.duration, 10), mods: { atkPct: num(b2.atk), aspd: num(b2.attack_speed) }, tags: ['skill'] }); } },
[S1]: { ...ON_DEPLOY, duration: num(r1?.duration, 10), mods: { dodgePhys: num(b1.prob) } },
[S2]: { ...ON_DEPLOY, duration: num(r2?.duration, 10), mods: { atkPct: num(b2.atk), aspd: num(b2.attack_speed) } },
[S3]: { kind: 'passive' },
},
trait: { hitsFn: (battle, unit) => (battle.rng.chance(unit.findBuff(S1_KEY) ? s1Prob : talentProb) ? 2 : 1) },
trait: { hitsFn: (battle, unit) => (battle.rng.chance(s1On(unit) ? s1Prob : talentProb) ? 2 : 1) },
talents: [
{ install(battle, unit) { statBuff(battle, unit, 'acspec:t1', { atkPct: num(t0.atk) }); } },
{ install(battle, unit) { toggleBuff(battle, unit, 'acspec:t2', () => enemiesAround4(battle, unit) === cnt, { atkPct: num(t1.atk) }); } },
+16 -1
View File
@@ -27,7 +27,7 @@
// counter {id} · crit {id} · dp {n, id} · heal {id} · taunt {id} · summon {id, token} · pull {id} · sonic {radius} ·
// shield {id} · overload {id} · takeoff {id} · sleep {id} · buff {id, kind} · reveal {id} · dodge (engine kind).
import { COLS } from '../../../constants.js';
import { COLS, TICK } from '../../../constants.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { offsetTile } from '../../../dir.js';
import { bodyInKeys, bodyTileReach } from '../../../body.js';
@@ -94,6 +94,21 @@ export function onDamagedOn(battle, unit, fn, priority = 0) {
return battle.on('damaged', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority });
}
/**
* 庇护 (gamedata_const ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)"; PRTS 术语释义 庇护): ONE effect per unit
* whoever grants it — battle.applyStrongest under this key, the strongest value holds and a weaker one resumes when it
* outlasts it. (遥's bubbles are a separate key, the client's damage_resistance[bonus] — they multiply with it.)
*/
export const PROTECT = 'protect';
/** The mods of a 庇护 value: physical and arts damage taken ×(1 − v). */
export const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
/** A hold that outlives one tick and lapses in the next: refreshed every tick while its condition lasts. */
export const PROTECT_TICK_HOLD = 1.5 * TICK;
/** Hold 庇护 `value` on `target` for `duration` s (the shared PROTECT effect). */
export function holdProtect(battle, target, value, duration, source = null) {
if (value > 0) battle.applyStrongest(target, PROTECT, { duration, value, mods: protectMods, source });
}
/** Targetable enemies inside `grid` (relative to the unit; null ⇒ current range), best targets first. */
export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canHitFly = true, groundOnly = false } = {}) {
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys;
+8 -2
View File
@@ -146,10 +146,16 @@ function pullToFront(battle, unit, e, force) {
}
/** Physician module trait (录武官, 华法琳 elites): heals on allies below hp_ratio are ×heal_scale. */
function installLowHpHealBonus(battle, unit, tb) {
/**
* The module trait "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale, hp_ratio): the client's module buff filters the
* target's HP before the heal with its own comparison — `atOrBelow` for the template heal_scale_up[hpratio][LE] (华法琳's
* PHY-X), strictly below otherwise (录武官's reckpr_e_002_tr: LT). [ASSUMED as before: not on herself]
*/
function installLowHpHealBonus(battle, unit, tb, { atOrBelow = false } = {}) {
const s = num(tb.heal_scale, 0), r = num(tb.hp_ratio, 0);
if (!(s > 0) || !(r > 0)) return;
battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && c.target.hpRatio < r) c.amount *= s; }, { owner: unit });
const low = atOrBelow ? (t) => t.hpRatio <= r + 1e-9 : (t) => t.hpRatio < r;
battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && low(c.target)) c.amount *= s; }, { owner: unit });
}
// DESIGN §5.6 documents `(bb, chess)`; content/index.js also passes the normalised def — rebuild it when absent.
+8 -3
View File
@@ -150,13 +150,18 @@ function burstDamageUp(battle, unit, mul) {
}, { owner: unit });
}
/** Modules PHY-X / GUA-X: heals on allies below hp_ratio ×heal_scale. */
function lowHpHealUp(battle, unit, tb) {
/**
* Modules PHY-X / GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%": heals on allies below hp_ratio ×heal_scale. The client's
* module buff picks its comparison: `atOrBelow` for heal_scale_up[hpratio][LE] (华法琳's PHY-X), strictly below for 塞雷娅's
* GUA-X (set_heal_scale_by_hpratio: LT) and 录武官's PHY-X (reckpr_e_002_tr: LT).
*/
function lowHpHealUp(battle, unit, tb, { atOrBelow = false } = {}) {
const mul = num(tb.heal_scale), thr = num(tb.hp_ratio);
if (!(mul > 1) || !(thr > 0)) return;
const low = atOrBelow ? (t) => t.hpRatio <= thr + 1e-9 : (t) => t.hpRatio < thr;
battle.on('heal', (c) => {
if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally') return;
if (c.target.hpRatio < thr) c.amount *= mul;
if (low(c.target)) c.amount *= mul;
}, { owner: unit });
}
+67 -29
View File
@@ -8,7 +8,8 @@
// projectile 'none'|'beam'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'|'boomerang' ('beam': an instant hit drawn as a
// line; 'boomerang': out to the target and back to the thrower, ai.js throwBoomerang)
// boomerang bool (回环射手: keeps 'boomerang')
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy)
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool ("同时攻击阻挡的所有敌人": up to the block count of
// targets, blocked ones first — ai.js targetCount)
// allInRange bool (every enemy on the range at once) splashRadius tiles (around the struck target)
// rangeAoe bool (a 锁定攻击范围 AoE without a projectile — SUB table or kit trait, applied by resolveProfile after
// every override: allInRange + instant 'beam' hits on a ranged profile; only selectable enemies are
@@ -24,7 +25,10 @@
// skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位")
// healThrough(healer, ally) → bool (a healer that selects and heals that ally through its 禁疗 — Battle
// injuredAlliesInKeys, damage.js heal; 凯尔希 on her Mon3tr)
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
// hitsFn(battle, unit, info) → n (info: the hit's { isSkill, index, attackId, energy })
// install(battle, unit) — per-unit hooks, called once at setup
// storeEnergy(battle, unit) → bool / releaseEnergy(battle, unit) → n (秘术师: the attack check found no valid target ⇒
// store one energy, false when full; the energies leaving with an attack — installMystic)
// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait)
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
// Behaviour per subprofession is documented in docs/SIM.md §Professions. Front / side tests use the unit's direction
@@ -55,26 +59,46 @@ export const PROFESSION_DEFAULTS = Object.freeze({
// install helpers (per-unit hooks). All use engine helpers only; tunables come from unit.profile.
/**
* 武者 / 收割者 self-heal ("每次攻击到敌人回复自身50生命"; 收割者 adds "最大生效数等于阻挡数"). Normal attacks arrive as one
* 'attack' event whose `targets` are every enemy hit.
* 武者 (musha) trait "每次攻击到敌人后回复自身N生命" (bb value) with the branch rules of PRTS 分支特性信息 武者: "特性治疗于干员每次
* 输出伤害时触发(不局限于攻击)" and "常态持有禁疗;通过自身特性/天赋/技能产生的作用于自身的治疗效果会无视自身的禁疗". The official
* trait buffs (buff_template_data `utage_trait`, `helage_trait`, `zuole_trait`) fire on ON_OUTPUT_DAMAGE: one heal per damage
* instance the unit deals to an enemy — every hit of a normal attack (the second hit of a double strike, a splash or
* chain victim) and every skill / item / bond damage instance credited to it; a dodged hit deals none (no `damaged`).
* Not a 流失 (no damage dealt), not an element 损伤 (a gauge fill, no HP damage — as 咒愈师), not a buff-made damage
* (talent / DoT tags: attackType BUFF — [ASSUMED] as before; only 隐德来希's template filters it and no 武者 kit deals such
* damage). The heal ignores 禁疗 (`ignoreHealFree`: an abnormal flag is one switch whoever set it — PRTS 异常效果).
*/
const installMushaHeal = (battle, unit) => {
battle.on('damaged', (c) => {
if (c.source !== unit || !unit.alive || !c.target || c.target.side !== 'enemy') return;
const dmg = c.dmg;
if (!dmg || isHpLoss(dmg) || c.type === 'element') return;
const tags = dmg.tags || [];
if (tags.includes('talent') || tags.includes('dot') || tags.includes('periodic')) return;
battle.heal(unit, unit, unit.profile.selfHeal ?? 50, { self: true, ignoreHealFree: true });
}, { owner: unit, priority: -10 });
};
/**
* 收割者 (reaper) self-heal ("每次攻击到敌人回复自身50生命,最大生效数等于阻挡数"). Normal attacks arrive as one 'attack' event
* whose `targets` are every enemy hit.
*
* The official trait is a buff ON the operator that fires on ON_OUTPUT_DAMAGE — buff_template_data `etlchi_trait`,
* `excu2_trait`, `utage_trait`, `helage_trait`, `zuole_trait` all react to any damage the unit outputs, and 隐德来希's
* filters the attackType BUFF out (damage produced by a buff/talent — e.g. her own 萃血 DoT). So skill damage heals too:
* 隐德来希's S2 blood sickles restore her life although the skill stops her attacks (`attack: { noAttack: true }`) — the
* sickle's AOEDamage nodes are attackType NORMAL and PRTS 备注 says "伤害来源始终视为隐德来希". Every such hit arrives as
* one 'damaged' event per enemy, so the reaper cap ("最大生效数") is applied per instant here: the official `[heal_fake]`
* window is 0.05 s and its stack count is the block number (`SetStackCountViaBlockNum`). [ASSUMED] the sim uses the same
* `battle.time` as its window, so simultaneous hits (both 血镰, an AoE) share the block-count cap; a normal attack has its
* own event and its own cap, as before.
* `excu2_trait` react to any damage the unit outputs, and 隐德来希's filters the attackType BUFF out (damage produced by a
* buff/talent — e.g. her own 萃血 DoT). So skill damage heals too: 隐德来希's S2 blood sickles restore her life although the
* skill stops her attacks (`attack: { noAttack: true }`) — the sickle's AOEDamage nodes are attackType NORMAL and PRTS 备注
* says "伤害来源始终视为隐德来希". Every such hit arrives as one 'damaged' event per enemy, so the reaper cap ("最大生效数") is
* applied per instant here: the official `[heal_fake]` window is 0.05 s and its stack count is the block number
* (`SetStackCountViaBlockNum`; PRTS 分支特性信息 收割者 "触发后的0.05s内最多只能累加等于自身阻挡数的治疗次数"). [ASSUMED] the sim uses
* the same `battle.time` as its window, so simultaneous hits (both 血镰, an AoE) share the block-count cap; a normal attack
* has its own event and its own cap, as before. Like the 武者's, the heal ignores 禁疗 (PRTS 分支特性信息 收割者 "通过自身特性/
* 天赋/技能产生的作用于自身的治疗效果会无视自身的禁疗").
*/
const installSelfHealOnHit = (capByBlock) => (battle, unit) => {
const heal = (n) => { if (n > 0 && unit.alive) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true }); };
const installReaperHeal = (battle, unit) => {
const heal = (n) => { if (n > 0 && unit.alive) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true, ignoreHealFree: true }); };
battle.on('attack', (ctx) => {
if (ctx.attacker !== unit || !unit.alive) return;
let n = ctx.targets.length;
if (capByBlock) n = Math.min(n, Math.max(1, unit.s.blockCnt));
heal(n);
heal(Math.min(ctx.targets.length, Math.max(1, unit.s.blockCnt)));
}, { owner: unit, priority: -10 });
battle.on('damaged', (c) => {
if (c.source !== unit || !unit.alive || !c.target || c.target.side !== 'enemy') return;
@@ -84,7 +108,7 @@ const installSelfHealOnHit = (capByBlock) => (battle, unit) => {
if (tags.includes('talent') || tags.includes('dot') || tags.includes('periodic')) return; // attackType BUFF
const mem = unit.mem;
if (mem.selfHealAt !== battle.time) { mem.selfHealAt = battle.time; mem.selfHealN = 0; }
if (capByBlock && mem.selfHealN >= Math.max(1, unit.s.blockCnt)) return;
if (mem.selfHealN >= Math.max(1, unit.s.blockCnt)) return;
mem.selfHealN++;
heal(1);
}, { owner: unit, priority: -10 });
@@ -362,16 +386,23 @@ const installFunnel = (battle, unit) => {
unit.trait.funnelScale = unit.profile.funnel?.init ?? 0.2;
};
/**
* 秘术师 (mystic) trait "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射(最多3个)" (bb times; 深靛's
* MSC-X 4) with the branch rules of PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在
* 有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为);仅没有有效目标,且能量储存数已满的
* 情况下才进入待机状态". The attack loop (ai.js updateAlly) calls the profile's `storeEnergy` at its attack check — the attack
* ready, the unit able to act and not disarmed (PRTS 异常效果 缴械 "秘术师储存能量同样无法进行") — when it finds no valid target
* (no target, or the profile's `canAttack` false: a 秘术师 kit's own target rule — 深靛 never picks a bound enemy, so a bind
* on her only target is no valid target); a stored energy restarts the attack interval, a full store idles (the next
* valid target is attacked at once). The energies leave with the next attack that happens (`releaseEnergy`, called by
* performAttack after `beforeAttack`: "攻击被打断且弹道未能成功生成的情况下,储存的能量不会被消耗") with its main target and land
* with its main hit, one damage instance each (`hitsFn`; "由储存能量形成的弹道造成攻击力100%的法术普通伤害" — each as the
* main hit). [ASSUMED] a redeployment holds no energy (a free move — 乌尔比安 S3 — keeps them). Kits with their own store
* (维伊's 转置能量, 黑键's elite energies) replace `storeEnergy` and release theirs from an `attack` hook.
*/
const installMystic = (battle, unit) => {
unit.trait.stored = 0;
const max = unit.profile.storeMax ?? 3;
battle.on('tick', () => {
if (!unit.canAct) return;
if (unit.atkCd <= 0 && !unit.trait.hadTarget && unit.trait.stored < max) {
unit.trait.storeAcc = (unit.trait.storeAcc ?? 0) + battle.dt;
if (unit.trait.storeAcc >= unit.s.interval) { unit.trait.storeAcc = 0; unit.trait.stored++; }
}
}, { owner: unit });
battle.on('deploy', (ctx) => { if (ctx.unit === unit && !ctx.move) unit.trait.stored = 0; }, { owner: unit });
};
/** Refresh period / lifetime (s) of a bard trait's 生命回复速度 buff: it lapses within BARD_REGEN_DUR once the bard stops. */
@@ -480,7 +511,14 @@ export const SUB = Object.freeze({
return unit.trait.funnelScale;
} }),
mystic: P({ install: installMystic,
hitsFn: (battle, unit) => { const n = 1 + (unit.trait.stored ?? 0); unit.trait.stored = 0; return n; } }),
storeEnergy: (battle, unit) => {
const n = unit.trait.stored ?? 0;
if (n >= (unit.profile.storeMax ?? 3)) return false;
unit.trait.stored = n + 1;
return true;
},
releaseEnergy: (battle, unit) => { const n = unit.trait.stored ?? 0; unit.trait.stored = 0; return n; },
hitsFn: (battle, unit, info) => 1 + (info?.energy ?? 0) }),
phalanx: P({ noAttackUnlessSkill: true, rangeAoe: true, install: installPhalanx }),
primcaster: P({}),
corecaster: P({}),
@@ -520,8 +558,8 @@ export const SUB = Object.freeze({
const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir);
return bodyOnTile(target, unit.tileR, unit.tileC) || bodyOnTile(target, fr, fc) ? 1 : (unit.profile.rangedScale ?? 0.8);
} }),
musha: P({ noHeal: true, install: installSelfHealOnHit(false) }),
reaper: P({ noHeal: true, allInRange: true, install: installSelfHealOnHit(true) }),
musha: P({ noHeal: true, install: installMushaHeal }),
reaper: P({ noHeal: true, allInRange: true, install: installReaperHeal }),
sword: P({ hits: 2 }),
artsfghter: P({ dmgType: 'arts' }),
swordmaster: P({ hits: 2 }),