Files
ark-stronghold-protocol/server/sim/skills.js
T
sganggs 383fe4a49e feedback5 WF: ACTIVE_RANGE also replaces the SEARCH row and covers 深巡 S2 — a skill whose running range strictly contains the own one casts with an enemy inside it (the owner's decisions of 2026-10-05)
tools/build-data.mjs resolveTrigger: a MANUAL operator skill on DEFAULT (no row, or a DEFAULT entry of the deviation
tables) or on the SEARCH row ("在初始攻击范围内存在敌人时释放技能": 解放者 / 阵法术师 / 安洁莉娜 — ACTIVE_RANGE_OVER) whose
running range strictly contains its own is ACTIVE_RANGE on that range, rawRule the official row; validateAll accepts a
DEFAULT deviation resolved to ACTIVE_RANGE (chess and stand-in tables) and ACTIVE_RANGE over SEARCH. Data rebuilt
offline, JSON-compared: only skills[].trigger rule / customRangeGrid of 薄绿 S1 (x-2), 蜜蜡 S1 (x-2), 卡涅利安 S3 (x-2),
玛恩纳 S2 (2-3), 安洁莉娜 S3 (y-4) — rawRule SEARCH — and 深巡 S2 (3-2, rawRule TAKE_DAMAGE), normal + elite (32
leaves); backups.json unchanged. Kits: 薄绿 S1 drops its DEFAULT trigger and 深巡 S2 PR #12's DEFAULT line, both read the
data now (雷蛇 S2 keeps PR #12's line). The sim needs no change (SEARCH was the DEFAULT condition on the initial range
every tick; ACTIVE_RANGE is the same on the running range). Docs: DESIGN §5.6 and the §21.20 flip row, DATA, SIM,
PLAYING, kits README, shared tier comments, skills.js header.

Tests: data (78 records / 39 skills, the five from SEARCH, the SEARCH skills that stay), feedback5-active-range (安洁莉娜
S3 on its y-4, S2 keeps SEARCH), feedback1-tank-triggers and kits_t1t2 (深巡 S2 on its 3-2, before any hit), kits_alt_t3
(薄绿 S1 on its x-2), feedback1f-fence (the idle measure uses the interval of the cooldown still running: the S2 now casts
between two attacks), docs-consistency (one PR #12 kit line left).

Golden: roster-001 / 002 (深巡), roster-016 / 044 (玛恩纳, 安洁莉娜), roster-026 / 027 (薄绿), roster-041 / 042 (卡涅利安),
bond-egirShip-low / bond-suntShip-high (深巡), boss-boss_2-pair / boss-boss_6-pair (玛恩纳, 安洁莉娜), matches solo-NORMAL-1,
coop2-NORMAL-3, coop4-FUNNY-6, coop2-NORMAL-8-serverrun — each fields one of the six skills, which now casts when an
enemy enters its running range.
2026-10-05 18:45:13 +08:00

546 lines
30 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/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6).
//
// SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'.
// No SP gain while a duration/ammo/toggle skill is active, or while the unit has the noSp flag (阻回: no SP gain of any
// kind — time, attack, hurt or granted). A stunned / frozen / levitated unit (canAct false) neither attacks nor casts, but
// its time SP keeps recovering (PRTS: only 阻回 pauses the SP cooldown; 晕眩 does not — community report #18).
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
// Trigger rules (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; data: tools/build-data.mjs resolveTrigger):
// DEFAULT — the basic strategy: ready + about to attack/heal + enemy / injured ally in the INITIAL range (or blocked by
// a melee unit) — or, checked every tick, an enemy inside one of the content trigger ranges added with
// addTriggerRange: 海嗣, 流形;
// SKILL_RANGE — a MANUAL skill with a 技能范围 of its own: "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其
// 不可选中)时释放技能": any living enemy on the trigger grid (the skill range; stealthed / untargetable / flying ones
// too), checked every tick, no attack needed. Kit option `trigger.allies` (+ `hpAtMost`, default 1): a healable,
// injured ally of the grid whose HP ratio is at most that instead (an AUTO heal skill's own rule — 古米 S1 waits in
// its heal mode until it has healed);
// ACTIVE_RANGE — the owner's rule (2026-10-05, a deliberate deviation): a MANUAL skill on the basic strategy (深巡 S2's
// DEFAULT deviation included) or on the SEARCH row (薄绿 S1, 玛恩纳 S2, 安洁莉娜 S3 …) whose attack range while it runs
// strictly contains the unit's own range checks the DEFAULT condition on that larger range (trigger grid = the running
// range, grown by the unit's permanent rangeExtend unless the skill ignores 攻击距离), every tick, no attack needed —
// an enemy the unit can target there (or one it blocks), a heal skill an injured ally;
// TAKE_DAMAGE (ready + just took a hit: 重装 "不受技能范围影响,受到伤害时释放技能"), SP_FULL/ALWAYS (as soon as ready),
// CUSTOM_RANGE (enemy inside the custom trigger grid), SEARCH (an enemy inside the INITIAL range, checked every tick
// without waiting for an attack: "不受基础策略影响,在初始攻击范围内存在敌人时释放技能" — not any enemy on the field,
// which burnt the skill on enemies that had just spawned), GDGLOW_SKILL_2 ("全场存在可选目标时释放技能": a targetable
// enemy anywhere on the field — for a heal skill an ally that needs healing — every tick), NEVER/MANUAL (never
// auto-cast: the kit calls skill.activate() itself); any unknown rule falls back to DEFAULT. Units that never attack
// (noAttack profiles) evaluate DEFAULT every tick instead. DEFAULT with `trigger.allies` (+ `hpAtMost`): the basic
// rule AND such an ally on the trigger grid — the cast replaces the attack about to be made (塞雷娅 S1 "触发时会替换当
// 次攻击"); should that ally condition fail before the attack, the cast is withdrawn and its charge returned.
// Automatic operations cool down (constants.js AUTO_OP_COOLDOWN, "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位
// 将进入冷却"): the engine auto-casts a MANUAL skill (def.skillType) no sooner than 3 s after its previous cast (so a
// charged skill spends its charges 3 s apart) or after the unit's deployment at the battle start (Battle._deploy
// `initial`). AUTO skills are exempt; a kit with its own automatic cast checks `opCooling`; a cast made by activate()
// directly is not held back (it still starts the cooldown). While a cast "next attack" waits for its attack, no
// further charge is cast.
// Kinds: duration (mods for `duration` s), ammo (mods until `ammo` attacks were made; optional duration cap),
// instant (onStart + optional one-shot attack override for the next attack), charges (= instant with charges),
// passive (always on from deployment, no SP), toggle (stays on until death once activated).
// SkillSpec fields (all optional): kind, duration, ammo, spCost, initSp, charges, spType, trigger,
// mods, flags, targeting {maxTargets, rangeGrid, priority, allInRange, rangeExtend, noRangeExtend (the range ignores
// the unit's 攻击距离), showOwnRange (rangeGrid only selects targets: the detail card keeps the unit's own range)},
// attack {dmgType, atkScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul, onHit, heal…},
// heal (bool: heal-type skill for the trigger rule), onStart(ctx), onEnd(ctx), onHit(ctx), onAttack(ctx), onTick(ctx).
// ctx passed to spec callbacks: { battle, unit, skill, bb, target?, dealt?, targets?, dt?, reason? }; onAttack's ctx
// also carries `noAmmo` (set it to true: this attack spends no ammo). onEnd runs while the skill's mods / range are
// still applied (`active` is already false); they are removed right after it (unless onEnd re-activated the skill).
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
import { AUTO_OP_COOLDOWN, COLS, ROWS } from './constants.js';
const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE', 'SKILL_RANGE', 'ACTIVE_RANGE', 'GDGLOW_SKILL_2']);
/** True when a SkillSpec `targeting` changes the unit's range while the skill runs (Battle._refreshRange). */
const changesRange = (tg) => !!(tg && (tg.rangeGrid || tg.rangeExtend || tg.noRangeExtend));
/** Enemies that satisfy a content trigger range (any targetable enemy, flyers included). */
const TRIGGER_PROFILE = Object.freeze({ canHitFly: true });
/** Every tile of the stage (GDGLOW_SKILL_2: the whole field). */
const ALL_TILES = new Set(Array.from({ length: ROWS * COLS }, (_, i) => i));
export class SkillRuntime {
/**
* @param {object} battle
* @param {object} unit
* @param {object|null} def normalised skill def (data)
* @param {object|null} spec SkillSpec from the kit
* @param {object} bb blackboard
*/
constructor(battle, unit, def, spec, bb = {}) {
Object.defineProperty(this, 'battle', { value: battle, writable: true, enumerable: false });
Object.defineProperty(this, 'unit', { value: unit, writable: true, enumerable: false });
this.def = def || {};
this.spec = spec || {};
this.bb = bb;
const s = this.spec;
const d = this.def;
this.id = s.id ?? d.id ?? 'skill';
this.name = s.name ?? d.name ?? '';
this.kind = s.kind ?? 'instant';
this.duration = Number.isFinite(+s.duration) ? +s.duration : (d.duration > 0 ? d.duration : 0);
this.ammo = Math.max(0, Math.floor(+s.ammo || 0));
this.baseSpCost = Math.max(0, Number.isFinite(+s.spCost) ? +s.spCost : (d.spCost ?? 0));
this.initSp = Math.max(0, Number.isFinite(+s.initSp) ? +s.initSp : (d.initSp ?? 0));
this.maxCharges = Math.max(1, Math.floor(s.charges ?? d.maxCharges ?? 1));
this.spType = s.spType ?? d.spType ?? 'time';
const trig = typeof s.trigger === 'string' ? { rule: s.trigger } : (s.trigger || d.trigger || {});
this.rule = String(trig.rule ?? 'DEFAULT').toUpperCase();
if (this.rule === 'ALWAYS') this.rule = 'SP_FULL';
if (this.rule === 'MANUAL') this.rule = 'NEVER';
if (this.rule.startsWith('CUSTOM_RANGE')) this.rule = 'CUSTOM_RANGE';
this.triggerGrid = trig.grid ?? trig.rangeGrid ?? d.trigger?.grid ?? null;
// kit options: an injured, healable ally of the trigger grid with an HP ratio of at most `hpAtMost` (SKILL_RANGE:
// instead of an enemy; DEFAULT: in addition to the basic rule)
this.triggerAllies = !!trig.allies;
this.triggerHpAtMost = Number.isFinite(+trig.hpAtMost) && +trig.hpAtMost > 0 ? +trig.hpAtMost : 1;
this.healSkill = s.heal ?? (unit.profile && unit.profile.dmgType === 'heal' && !!unit.profile.heal);
// the official skill strategies automate the manual 开启: only MANUAL skills wait for the operation cooldown
this.manual = String(d.skillType ?? 'MANUAL').toUpperCase() === 'MANUAL';
this.opReadyAt = -Infinity; // no automatic cast before this battle time (AUTO_OP_COOLDOWN)
if (this.kind === 'passive') this.baseSpCost = 0;
this._spCostMul = 1; // content may set spCostMul (e.g. 绝技 ×0.7) — see the accessor below
this.sp = 0;
this.charges = 0;
this.active = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.ammoMax = 0; // ammo kind: the most bullets this activation held (snapshot's draining bar)
this.pending = false; // instant/charges: next attack uses spec.attack
this.activations = 0;
this.lastStart = -Infinity;
this._buffKey = `skill:${unit.id}`;
this._trigKeys = null;
this._trigSet = null;
this.triggerRanges = []; // content trigger ranges (addTriggerRange)
this.noSkill = !spec; // unit without any skill spec
}
/**
* Extra DEFAULT-trigger range (海嗣 "攻击范围视为自身攻击范围的延伸", 流形): `fn(battle, unit)` returns a list whose
* entries are ally units (their current `rangeKeys` count while they are on the field) or arrays of absolute tile
* keys. A targetable enemy (flyers included) on those tiles satisfies the DEFAULT rule (and unknown DEFAULT-like
* rules); it is checked every tick, since the unit itself may have nothing to attack. Returns an unregister fn.
*/
addTriggerRange(fn) {
if (typeof fn !== 'function') return () => {};
this.triggerRanges.push(fn);
return () => { const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
}
/** An enemy inside one of the content trigger ranges. */
_extraTriggerSatisfied() {
const b = this.battle;
const u = this.unit;
for (const fn of this.triggerRanges) {
const list = b._safe(() => fn(b, u), 'skill.triggerRange', u);
if (!list || typeof list[Symbol.iterator] !== 'function') continue;
for (const x of list) {
let keys = null;
if (Array.isArray(x)) keys = x;
else if (x && typeof x === 'object' && x.side === 'ally' && x.alive && x.deployed && !x.hidden) keys = x.rangeKeys;
if (keys && keys.length && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length) return true;
}
}
return false;
}
get spCost() {
const c = Math.floor(this.baseSpCost * this.spCostMul + (this.unit.s ? this.unit.s.spCostFlat : 0));
// content writes spCostMul / spCostFlat: a NaN/Infinity cost would freeze SP forever — use the base cost instead
return Number.isFinite(c) ? Math.max(0, c) : this.baseSpCost;
}
/** SP cost multiplier (content-writable). Invalid values count as 1; SP/charges are re-normalised immediately. */
get spCostMul() { return this._spCostMul; }
set spCostMul(v) {
this._spCostMul = typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 1;
if (this.unit) this._normalize();
}
get ready() { return !this.noSkill && this.kind !== 'passive' && this.charges >= 1; }
get isTimed() { return this.kind === 'duration' || this.kind === 'ammo' || this.kind === 'toggle'; }
_ctx(extra) { return { battle: this.battle, unit: this.unit, skill: this, bb: this.bb, ...extra }; }
_call(fnName, extra) {
const fn = this.spec[fnName];
if (typeof fn !== 'function') return undefined;
return this.battle._safe(() => fn(this._ctx(extra)), `skill.${fnName}`, this.unit);
}
/**
* The official 技力 (PRTS 技能: "可充能X次…当前技力上限等于该技能技力需求的X倍"): the stored charges × cost plus the SP
* towards the next one — what 联防 carries (BattleResult unitsEnd `sp`; reset rebuilds the charges from it).
*/
get spTotal() {
if (this.noSkill || this.kind === 'passive') return 0;
const cost = this.spCost;
return this.charges >= this.maxCharges ? this.maxCharges * cost : this.charges * cost + this.sp;
}
/**
* Set the official 技力 to `total` (charges rebuilt, nothing fired — a 修改, not a gain): the 联防 carry, applied again
* once the deployment is done (Battle._deploy). A passive skill, or a timed one that runs already, is left as it is.
*/
setSpTotal(total) {
if (this.noSkill || this.kind === 'passive' || (this.active && this.isTimed) || !Number.isFinite(total)) return;
this.sp = 0;
this.charges = 0;
this.gainSp(Math.max(0, total), 'init', true);
if (this.spCost <= 0) this.charges = this.maxCharges;
}
/**
* Called on every (re)deployment. `carry` = { sp } for unite (联防) helpers: their 技力 at the end of their own combat
* (unitsEnd `sp` = spTotal, rebuilt into charges here). Nothing else of the skill is carried — PRTS 卫戍协议/帮助 §联防阶段
* "将对应单位的生命比例、技力修改至与上一阶段结束时相同": a skill that was running enters 联防 switched off, with the SP it
* had left (spent at its activation: 0 for a one-charge skill — PRTS 技能 "触发技能后…消耗相应的技力").
*/
reset(carry = null) {
this.active = false;
this.pending = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.ammoMax = 0;
this.charges = 0;
this.sp = 0;
this._trigKeys = null;
this._trigSet = null;
this.opReadyAt = -Infinity; // (Battle._deploy starts the operation cooldown of the battle-start deployment)
if (this.noSkill) return;
if (this.kind === 'passive') {
this._startPassive();
return;
}
this.sp = 0;
this.gainSp(carry && Number.isFinite(carry.sp) ? carry.sp : this.initSp, 'init', true);
// a free (spCost 0) non-passive skill is available once per deployment
if (this.spCost <= 0) this.charges = this.maxCharges;
}
_startPassive() {
this.active = true;
this._applyMods();
this._call('onStart', { reason: 'passive' });
}
_applyMods() {
const s = this.spec;
if (s.mods || s.flags) {
this.battle.addBuff(this.unit, { key: this._buffKey, mods: s.mods || null, flags: s.flags || null, tags: ['skill'] });
}
if (changesRange(s.targeting)) this.battle._refreshRange(this.unit);
}
_removeMods() {
this.battle.removeBuff(this.unit, this._buffKey);
if (changesRange(this.spec.targeting)) this.battle._refreshRange(this.unit);
}
/** Add SP (fires the `spGain` hook). Ignored while a duration/ammo/toggle skill runs (its bar shows the skill). */
gainSp(amount, reason = 'time', silent = false) {
if (this.noSkill || this.kind === 'passive' || !(amount > 0)) return 0;
if (this.active && this.isTimed && reason !== 'init') return 0;
// 阻回 (the operators' 凋亡 burst, damage.js): "停止并阻止任意形式的技力回复" — no SP of any kind (time, attack, hurt, gifts)
if (reason !== 'init' && this.unit.s.flags.noSp) return 0;
let cost = this.spCost;
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
let amt = amount;
const b = this.battle;
if (!silent && b._hooks.spGain) {
const ctx = { unit: this.unit, amount: amt, reason, skill: this };
b.emit('spGain', ctx);
amt = Number.isFinite(ctx.amount) ? ctx.amount : 0;
if (!(amt > 0)) return 0;
// handlers may change the cost (spCostMul), end the skill or kill the unit: re-read, never use a stale cost
if (this.kind === 'passive' || this.noSkill) return 0;
cost = this.spCost;
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
}
if (cost <= 0) return 0; // free skills never recharge (see reset)
this.sp += amt;
while (this.sp >= cost && this.charges < this.maxCharges) {
this.charges++;
if (this.charges < this.maxCharges) this.sp -= cost;
else this.sp = cost;
}
if (this.charges >= this.maxCharges) this.sp = cost;
return amt;
}
/** Re-establish sp/charges invariants after an SP-cost change (e.g. content sets spCostMul mid-battle). */
_normalize() {
if (this.noSkill || this.kind === 'passive') return;
const cost = this.spCost;
if (cost <= 0) { if (this.sp > 0) { this.sp = 0; this.charges = this.maxCharges; } return; } // became free
while (this.sp >= cost && this.charges < this.maxCharges) {
this.charges++;
this.sp = this.charges < this.maxCharges ? this.sp - cost : cost;
}
if (this.sp > cost) this.sp = cost;
}
/** Per-tick update: durations, SP regen, tick-based triggers. */
tick(dt) {
if (this.noSkill) return;
const u = this.unit;
if (this.kind !== 'passive' && this.sp > this.spCost) this._normalize();
if (this.active && this.kind !== 'passive') {
if (this.spec.onTick) this._call('onTick', { dt });
if (this.kind === 'duration' || (this.kind === 'ammo' && Number.isFinite(this.timeLeft))) {
this.timeLeft -= dt;
if (this.timeLeft <= 1e-9) { this.end('duration'); }
}
} else if (this.active && this.kind === 'passive' && this.spec.onTick) {
this._call('onTick', { dt });
}
// natural SP recovery stops only under 阻回 (noSp; a running timed skill holds it too) — not while 晕眩 / 冻结 / 浮空
// keep the unit from acting: PRTS 技能 "在阻回状态或技力条已满时,保留剩余冷却时间,计时暂停"; PRTS 异常效果 STUNNED
// "无法攻击、释放技能、阻挡敌人类单位" says nothing of SP (community report #18: 洛洛's S2 self-stun froze her SP)
if (this.spType === 'time' && !(this.active && this.isTimed) && !u.s.flags.noSp && u.alive && u.deployed && !u.hidden) {
const rate = u.s.spRecovery;
if (rate > 0) this.gainSp(rate * dt, 'time');
}
if (!u.canAct) return;
// a DEFAULT cast bound to an ally condition (塞雷娅 S1) replaces the attack about to be made: should the condition
// have failed before that attack (the ally healed meanwhile), the cast is withdrawn — no heal mode stays behind
if (this.pending && this.triggerAllies && this.rule !== 'SKILL_RANGE' && !this._allyTriggerSatisfied()) {
this.end('withdrawn');
this.addCharge(1);
}
if (!this.ready || u.s.flags.silence) return;
if (this.active && this.isTimed) return;
// a cast "next attack" still waits for its attack: another charge now would be spent on the same attack
if (this.pending || this._opCooling()) return;
if (TICK_RULES.has(this.rule)) {
if (this._tickRuleSatisfied()) this.activate(this.rule);
} else if (this.rule !== 'TAKE_DAMAGE' && this.rule !== 'NEVER') {
// DEFAULT (and unknown rules) for units that cannot attack right now: check the initial range every tick;
// content trigger ranges are checked every tick for everyone (nothing may be in the unit's own range)
const prof = u.profile;
if (prof && (prof.noAttack || (prof.noAttackUnlessSkill && !this.active))) {
if (this._defaultCondition()) this.activate('DEFAULT');
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied()) this.activate('DEFAULT');
}
}
/** The automatic operations of a MANUAL skill are cooling down (AUTO_OP_COOLDOWN). */
_opCooling() { return this.manual && this.battle.time < this.opReadyAt - 1e-9; }
/** Public form of the operation cooldown, for kits with their own automatic cast of a MANUAL skill. */
get opCooling() { return this._opCooling(); }
/**
* Absolute tile keys of the trigger grid at the unit's current tile and direction (cached, with their Set). ACTIVE_RANGE
* grows it by the unit's permanent rangeExtend (the range the skill would run with — Battle._refreshRange) unless the
* skill's range ignores 攻击距离 (targeting.noRangeExtend).
*/
_triggerKeys() {
const u = this.unit;
const tile = u.tileR * COLS + u.tileC;
const ext = this.rule === 'ACTIVE_RANGE' && !this.spec.targeting?.noRangeExtend ? (u.s.baseRangeExtend || 0) : 0;
if (!this._trigKeys || this._trigTile !== tile || this._trigDir !== u.dir || this._trigExt !== ext) {
this._trigKeys = absoluteRangeKeys(this.triggerGrid, u.tileR, u.tileC, u.dir, ext);
this._trigSet = new Set(this._trigKeys);
this._trigTile = tile;
this._trigDir = u.dir;
this._trigExt = ext;
}
return this._trigKeys;
}
/** `trigger.allies`: an injured, healable ally on the trigger grid (the unit's own range without one) ≤ hpAtMost. */
_allyTriggerSatisfied() {
const b = this.battle;
const u = this.unit;
let keys;
if (this.triggerGrid) { this._triggerKeys(); keys = this._trigSet; } else keys = u.baseRangeKeys || u.rangeKeys;
const lim = this.triggerHpAtMost + 1e-9;
return b.injuredAlliesInKeys(keys, u).some((a) => a.hpRatio <= lim);
}
_tickRuleSatisfied() {
const b = this.battle;
const u = this.unit;
if (this.rule === 'SP_FULL') return true;
// SEARCH: an enemy inside the initial attack range, every tick (librators / phalanxes and 安洁莉娜 do not attack
// while the skill is off, so DEFAULT's "about to attack" never comes)
if (this.rule === 'SEARCH') return this._defaultCondition();
if (this.rule === 'CUSTOM_RANGE') {
if (!this.triggerGrid) return this._defaultCondition();
return b.enemiesInKeys(this._triggerKeys(), u, { canHitFly: true }).length > 0;
}
if (this.rule === 'SKILL_RANGE') {
if (this.triggerAllies) return this._allyTriggerSatisfied();
if (!this.triggerGrid) return this._defaultCondition();
return b.anyEnemyInKeys(this._triggerKeys());
}
if (this.rule === 'ACTIVE_RANGE') return this._defaultCondition(this.triggerGrid ? this._triggerKeys() : null);
// GDGLOW_SKILL_2 "全场存在可选目标时释放技能": a targetable enemy anywhere (heal skill: an ally that needs healing)
if (this.rule === 'GDGLOW_SKILL_2') {
if (this.healSkill) return b.injuredAlliesInKeys(ALL_TILES, u, !!u.profile?.heal?.elementHealRatio).length > 0;
return b.enemies.some((e) => canTargetEnemy(u, e, TRIGGER_PROFILE));
}
return false;
}
/**
* DEFAULT rule condition: an enemy (or injured ally for heal skills) inside the initial range (baseRangeKeys: own
* grid + permanent rangeExtend), or an enemy inside a content trigger range (addTriggerRange; not for heal skills).
* `range` (ACTIVE_RANGE): those absolute tile keys instead of the initial range.
*/
_defaultCondition(range = null) {
const b = this.battle;
const u = this.unit;
if (b.rangeChanged(u)) b._refreshRange(u);
const keys = range || u.baseRangeKeys || u.rangeKeys;
if (keys) {
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0;
if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true;
}
// the enemies a unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy (a ranged blocker too: user playtest #6)
if (!this.healSkill && u.blocking.length && b.blockedTargets(u, u.profile).length > 0) return true;
return !this.healSkill && this.triggerRanges.length > 0 && this._extraTriggerSatisfied();
}
/** Called by the attack loop right before a normal attack/heal. Returns true when the skill fired. */
onAboutToAttack() {
if (!this.ready || this.unit.s.flags.silence) return false;
if (this.active && this.isTimed) return false;
if (this.rule === 'TAKE_DAMAGE' || this.rule === 'NEVER' || TICK_RULES.has(this.rule)) return false;
if (this.pending || this._opCooling()) return false;
if (!this._defaultCondition()) return false;
if (this.triggerAllies && !this._allyTriggerSatisfied()) return false;
return this.activate('DEFAULT');
}
/** TAKE_DAMAGE trigger + INCREASE_WHEN_TAKEN_DAMAGE SP. */
onDamaged() {
if (this.noSkill) return;
if (this.spType === 'hurt' && !(this.active && this.isTimed)) this.gainSp(1, 'hurt');
if (this.rule === 'TAKE_DAMAGE' && this.ready && !this.pending && !(this.active && this.isTimed) && this.unit.canAct && !this.unit.s.flags.silence && !this._opCooling()) {
this.activate('TAKE_DAMAGE');
}
}
/** Activate now (consumes one charge). Returns true on success. */
activate(reason = 'manual', { free = false } = {}) {
if (this.noSkill || this.kind === 'passive') return false;
if (!free && !this.ready) return false;
if (this.active && this.isTimed) return false;
const u = this.unit;
if (!u.alive || !u.deployed) return false;
if (!free) {
const wasFull = this.charges >= this.maxCharges;
this.charges--;
if (this.maxCharges === 1 || wasFull) this.sp = 0;
}
this.activations++;
this.lastStart = this.battle.time;
const b = this.battle;
if (this.manual) this.opReadyAt = b.time + AUTO_OP_COOLDOWN;
if (this.isTimed) {
this.active = true;
this.timeLeft = this.kind === 'duration' ? Math.max(0.01, this.duration) : (this.kind === 'ammo' && this.duration > 0 ? this.duration : Infinity);
this.ammoLeft = this.kind === 'ammo' ? Math.max(1, this.ammo) : 0;
this.ammoMax = this.ammoLeft;
this._applyMods();
} else {
// instant / charges
this.active = true;
this.pending = !!this.spec.attack;
// (a targeting-only instant skill must still switch to its skill range for the pending attack)
if (this.spec.mods || this.spec.flags || this.spec.targeting) this._applyMods();
}
b._ev(['skill', u.id, 1]);
u.skillAnimUntil = b.time + 0.5;
this._call('onStart', { reason });
if (b._hooks.skillStart) b.emit('skillStart', { unit: u, skill: this, reason });
// bullets added in skillStart (拉特兰's ×(1.05 + 0.015 × layers), 逃犯引渡手续, talents): the bar's full mark
// (community report #35: the extra bullets sat above a full bar until fewer than the base count were left)
if (this.active && this.ammoLeft > this.ammoMax) this.ammoMax = this.ammoLeft;
if (!this.isTimed && !this.pending) this.end('instant');
return true;
}
/**
* Called after each attack/heal performed by the unit. `noAmmo` (Battle.forceAttack opts, or `ctx.noAmmo = true`
* set by the spec's onAttack) = this attack spends no ammo of a running ammo skill (no `ammoUsed`).
*/
onAttackPerformed(targets, usedOverride, noAmmo = false) {
if (this.noSkill) return;
const b = this.battle;
// AK: attacks made by the skill (the "next attack" of an instant/charge skill, every attack of a running timed
// skill — including the one that ends it) never recover attack-type SP ⇒ a cost-N skill fires every N+1 attacks.
const skillAttack = this.active && (this.isTimed || (!!usedOverride && this.pending));
if (this.active && typeof this.spec.onAttack === 'function') {
const fn = this.spec.onAttack;
const ctx = this._ctx({ targets, noAmmo: !!noAmmo });
b._safe(() => fn(ctx), 'skill.onAttack', this.unit);
noAmmo = !!ctx.noAmmo;
}
if (this.active && this.kind === 'ammo' && noAmmo) {
// an extra attack that spends no bullet
} else if (this.active && this.kind === 'ammo') {
this.ammoLeft--;
if (b._hooks.ammoUsed) b.emit('ammoUsed', { unit: this.unit, left: this.ammoLeft, skill: this });
if (this.ammoLeft <= 0) this.end('ammo');
} else if (usedOverride && this.pending && !this.isTimed) {
this.pending = false;
this.end('instant');
}
if (this.spType === 'attack' && !skillAttack && !(this.active && this.isTimed)) this.gainSp(1, 'attack');
}
/**
* End the active skill. onEnd runs while the skill's mods / range are still applied (end-of-skill effects — finishers,
* bombardments — use the skill's stats and range; `active` is already false), then they are removed (kept when onEnd
* re-activated the skill), then `skillEnd` fires.
*/
end(reason = 'end') {
if (!this.active || this.kind === 'passive' && reason !== 'death') return;
const u = this.unit;
const b = this.battle;
this.active = false;
this.pending = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.ammoMax = 0;
const n = this.activations;
this._call('onEnd', { reason });
if (!this.active && this.activations === n) this._removeMods();
if (b._hooks.skillEnd) b.emit('skillEnd', { unit: u, skill: this, reason });
if (this.kind !== 'passive') b._ev(['skill', u.id, 0]);
}
/** The attack override to use for the next attack, or null. */
attackOverride() {
if (!this.active) return null;
if (this.isTimed || this.kind === 'passive') return this.spec.attack || null;
return this.pending ? (this.spec.attack || null) : null;
}
targetingOverride() {
if (!this.active || !this.spec.targeting) return null;
if (!this.isTimed && this.kind !== 'passive' && !this.pending) return null;
return this.spec.targeting;
}
// ---- helpers for content -------------------------------------------------------------------------------
// (non-finite arguments are ignored: a NaN timer/ammo count would keep the skill active forever)
// (bullets added above the activation's most so far raise the bar's full mark: the bar drains one bullet at a time)
addAmmo(n) {
if (!this.active || this.kind !== 'ammo' || !Number.isFinite(n)) return;
this.ammoLeft += n;
if (this.ammoLeft > this.ammoMax) this.ammoMax = this.ammoLeft;
}
extend(seconds) { if (this.active && Number.isFinite(this.timeLeft) && Number.isFinite(seconds)) this.timeLeft += seconds; }
addCharge(n = 1) { if (!Number.isFinite(n)) return; this.charges = Math.max(0, Math.min(this.maxCharges, this.charges + n)); if (this.charges >= this.maxCharges) this.sp = this.spCost; }
stop() { this.end('stopped'); }
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Skill<${this.id} ${this.kind} sp=${this.sp.toFixed(2)}/${this.spCost} ch=${this.charges}${this.active ? ' active' : ''}>`;
}
}