Files
ark-stronghold-protocol/server/sim/skills.js
T
sganggs 227ab9e7ca feedback7 R23-TICK: an attack interval or a time-SP cost of a whole number of frames takes exactly that many — 1 s is 30 frames, not 31 (PR #402 by @Cloudnyco, ported onto 0.2.2); golden: every family moves (roster, bonds, fields, matches, standins, diy — regenerated in the next commit)
Seen (PR #402, measured again on 0.2.2): TICK = 1/30 is not a binary fraction. An attack cooldown counted down from 1 s
stops at 2.1e-16 after thirty steps and the fire check `atkCd > 0` waited a 31st frame; 300 time gains of 1/30 SP add up
to 9.999999999999975 and `sp >= cost` charged a 10-SP skill a frame late. Over the 265 golden battles 30.85 % of the
consecutive attacks on a whole-frame interval came a frame late (control, other intervals: 0.09 %), and 4 157
end-of-tick states sat within 1e-9 under a cost. The engine already took timers within 1e-9 as done (skill timeLeft,
buffs, every() / after(), enemy abilities, nextCastAt, the 失衡 end); these two did not.

Official: PRTS 作战机制/sandbox 「帧对齐机制对攻速的影响」 — one frame = 1/30 s; since 2019-12-24 a non-integer frame count
is rounded (四舍五入); an exact 1 s is 30 frames under any rule.

Now: ai.js attackCountdown(cd, dt) (left within 1e-9 of 0 is 0) for the operators' cooldown (the canAct guard kept) and
the enemies' (not while stunned or hidden; the 0.2.2 失衡 swing cut untouched) — an enemy's whole-frame wind-up is exact
too (0.4 s: 12 frames, 13 before). The same countdown for the two strict `cd -= dt; if (cd > 0)` attack clocks of the
same shape: the 双眼皮 turrets (content/devices.js; BAT 4 s: 121 → 120 frames) and 寒檀's S2 icicles (0.5 s: 16 → 15).
SkillRuntime.gainSp takes SP within 1e-9 of the cost as full for every charge (a tiny negative remainder → 0);
_normalize compares the same way. Left as they are: a non-integer count still ends on the frame that crosses 0
(⌈30 × interval⌉ — the official 四舍五入 is an older, separate difference); the count-up content clocks that restart from
0 (bosses.js self-timers, kit auras — PR #402's list); attack / hurt SP (+1 on an integer cost) unchanged.

Tests: test/sim/tick-tolerance.test.js (new, 7: the PR's four adapted — residue, attackCountdown over bat 0.1–10 s ×
14 ASPD = ⌈30 × interval⌉, 1 s operator every 30 / 3 s ranged enemy every 90, time SP 4/10/16/20/30/50 at exactly 30 × N
gains for one and two charges — plus the 1.2 s / 1.25 s control (36 / 38), the turret at 0 layers (120) and 寒檀's
icicles (15); the four engine tests fail on the parent: 31 / 91, cost 4 at 121, 121, 16). The PR's seven test edits,
re-run: the same 8 assertions failed on the new timing and each edit passes on the parent and on the fix —
feedback2-surtr-stealth (count once no arrow is in flight), kits_alt_t2 拉普兰德 S1 (dummy ATK 300 → 250: 120 × 15 = her
1791 HP by 59.5 s), kits_alt_t6 (medic smoke at half HP; 余 S1 up to the first 燃烧 burst), op_jesca2 (the T6 shield
returns a tick after the cast), op_lmlee (hits before the burst), op_logos (count after the clear), op_narant (the first
attack thrown after the new enemies). node --test (with the golden of the next commit): 5703 tests, 5701 pass, 0 fail,
2 skipped; eslint 0 errors (135 warnings, the parent's 135); typecheck, check:imports, smoke ok.

feedback7 R23-TICK: golden for whole-frame attack intervals and SP costs (PR #402) — 281 of 283 scenarios move: roster 49/49, bonds 46/46, fields 22/22, matches 18/18, standins 10/10, diy 136/138; solo-NORMAL-1 now loses its Final Assault (below)

npm run golden:update on the previous commit. Every family moves because both sides attack on time (more enemy kinds
than operator forms sit on whole-frame intervals: 3 s, 4 s, 1 s …) and time SP charges a frame sooner. Measured over
the 265 battles: whole-frame consecutive attacks a frame late 30.85 % → 0.04 % (control 0.09 % → 0.13 %; 1 s gaps at
30 frames 256 → 3 776 of ~3 800, 3 s at 90 frames 0 → 6 657 of 6 657), SP float-short states 4 157 → 0. Moved battles'
totals: kills 10 647 → 10 625, leaks 12 398 → 12 445, deaths 2 913 → 2 944. Unchanged: diy-040, diy-069.

End results that flip (one frame cascades; the causes as measured with the matches' battles instrumented):
- solo-NORMAL-1: won → lost in round 14 (passed 14 → 13, team LP 8 → 0): the Final Assault on 卢西恩 (pool 390 000) runs
  as before for two minutes (his 0.4 s wind-up a frame shorter), then the team's damage on the pool trails — 884 left
  when the overtime drain (from 300 game s) takes the last of the 17 LP at 334 s; before, it emptied at 318 s, 8 LP left.
- solo-HARD-1: LP 10 → 12 — round 8 lets one more through (26 → 25; rounds 11–12 even it out) and the Final Assault ends
  at 300.6 s, before the overtime drain (304.8 s and 2 LP before).
- solo-NORMAL-2: LP 9 → 25 — round 5 lets one more through (26 → 25, a coin less), the bot's shop path parts from round
  6 and its later board (突袭, 232 layers at round 13 against 94) clears rounds 12–13 with no leak (7 + 18 before).
- coop2-NORMAL-3: team LP 63 → 68 — ai_1's round-13 battle leaks 1 instead of 6 (its round-12 leaker now goes to 联防).
- coop2-ABYSS-10-boosted: team LP 583 → 630 — the Final Assault ends at 606.6 s (639.6) and the Hidden Core at 335.9 s
  (397.8): 47 LP less of overtime drain.
- coop2-NORMAL-14-standins: team LP 41 → 40 — ai_0's round-13 battle lets one 潜伏者 through (23 → 22).
- roster-016: cleared at 220.3 s → time-out at 230 s (主角阵营角色 leaks instead of falling; kills 101 → 105, leaks 26 → 22).
- roster-041: time-out → cleared at 189.0 s, perfect (the last 粉碎攻坚组长 falls inside the 190 s).
- bond-suntShip-high: cleared at 164.2 s → time-out at 190 s (kills 11 → 13, deaths 4 → 2).
- standin-04: perfect → 10 leaks (deaths 8 → 10; 萨卡兹大剑组长 / 萨卡兹穿刺手 / 澪 get through).
- diy-005 / diy-008 (艾丽妮 / 赫拉格): cleared at 225.3 s → time-out at 230 s (a last 宿主重装士兵 / 深池方阵指挥官 walking).
- diy-127 (白铁 / 酒神): time-out → cleared at 225.8 s (deaths 7 → 13, leaks 57 → 72).
- diy-131 (死芒): perfect → 1 leak (a 掠海漂移体).
The other matches keep their ends (solo-FUNNY-1/2, solo-HARD-2, solo-ABYSS-1/2, coop2-HARD-4, coop3-ABYSS-5,
coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-NORMAL-70-diy). PR #402's own
0.2.1 table (coop2-NORMAL-14-standins loss → win, solo-HARD-2 / solo-ABYSS-2 earlier exits) was measured before §27 and
does not repeat here.
2026-10-09 01:37:32 +08:00

648 lines
38 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: 海嗣, 流形, 谬因 S2's beam, the summons' areas a skill acts through (麦哲伦 S1, 令 S3, 电弧 S2 / S3 —
// the owner's larger-range rule, 2026-10-06: kits/shared/summoner.js summonTriggerArea);
// 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.
// A registered ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon our attacks select) counts
// like an enemy in each of these enemy conditions for a skill that acts on it (`_allyTargetIn`, `allyTargetsOk` — the
// owner's rule of 2026-10-08): one that acts through the unit's own attacks, or whose kit flags `allyTargets: true` (its
// selectors take ally targets too); a skill that picks its own victims among enemies only is not opened by it.
// 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), allyTargets (bool: the skill acts on a registered ally target — see
// `allyTargetsOk`), 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));
/**
* The 'skill' animation window the sim reports to the client: `unit.skillAnimUntil` (snapshot.js `animOf` → ANIM.SKILL,
* and the `['skill', id, 1]` / `0` events). An instant cast and a deploy-time passive both use it.
*/
const SKILL_ANIM_WINDOW = 0.5;
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)
this.nextCastAt = -Infinity; // instant / charges with no attack of its own: no cast from the tick before (GitHub #298)
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
}
/**
* Change the trigger rule and grid mid-battle — a kit whose skill's running range changes with its own use (薇薇安娜 S3:
* "首次技能结束后,本技能的技能范围永久扩大至3-2" — ACTIVE_RANGE on 3-2 from then, DEFAULT again at her next deployment).
* `grid`: a facing-RIGHT [dRow, dCol] grid (ACTIVE_RANGE / SKILL_RANGE / CUSTOM_RANGE), or null. (0.2.0 WE2, additive.)
* @param {string} rule @param {number[][]|null} [grid]
*/
setTrigger(rule, grid = null) {
this.rule = String(rule ?? 'DEFAULT').toUpperCase();
this.triggerGrid = Array.isArray(grid) && grid.length ? grid : null;
this._trigKeys = null;
this._trigSet = null;
}
/**
* 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), arrays of absolute tile
* keys, or `{ keys, profile }` — tile keys with the enemy profile the effect selects by (`canHitFly` false: ground
* enemies only — the owner's larger-range rule through a summon's area, kits/shared/summoner.js summonTriggerArea;
* 0.2.0 WV, additive). A targetable enemy (flyers included unless the entry's profile says otherwise) 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, prof = TRIGGER_PROFILE;
if (Array.isArray(x)) keys = x;
else if (x && typeof x === 'object' && x.side === 'ally' && x.alive && x.deployed && !x.hidden) keys = x.rangeKeys;
else if (x && typeof x === 'object' && Array.isArray(x.keys)) { keys = x.keys; if (x.profile) prof = x.profile; }
if (keys && keys.length && (b.enemiesInKeys(keys, u, prof).length || this._allyTargetIn(keys))) return true;
}
}
return false;
}
/**
* A registered ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, a summon of the enemy camp our attacks select) on
* `keys` (tile keys or a Set): a non-heal skill's automatic start counts it like an enemy — the owner's rule of
* 2026-10-08 (a community report and their recall of the official mode: 干员攻击范围/技能范围内有白铁的装置时技能也自动开、自动
* 消耗,装置对干员的仇恨类似敌方单位; PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方"). It stays out of enemiesInKeys /
* canTargetEnemy (every other selector keeps to enemies); a heal skill never counts it (禁疗: not a patient).
*/
_allyTargetIn(keys) {
if (!keys || !this.allyTargetsOk) return false;
const b = this.battle;
return !!(b._allyTargets && b._allyTargets.size) && b.allyTargetsInKeys(keys, this.unit).length > 0;
}
/**
* Does this skill act on a registered ally target, so that one may start it (`_allyTargetIn`)? Never a heal skill.
* SkillSpec `allyTargets` (true / false) answers for a kit: true where its selectors take ally targets too
* (allyTargetsInKeys / allyTargetsInRadius — 蕾缪安 S3, 艾丽妮 S3, 陈 S2, 引星棘刺 S2), false where they never do although
* the skill keeps attacking. Without it: a skill that acts through the unit's own attacks — which select the device
* (ai.js acquireTargets) —, a timed one that keeps attacking (no `attack.noAttack`) or a cast that is the next attack
* (`spec.attack`), on a unit that attacks. [ASSUMED] Any other skill picks its victims itself among the enemies
* (enemiesInKeys, foesInRadius …) and would be spent on nothing: the device does not open it — it waits for an enemy.
* (Grok's review of round 22: 蕾缪安 S3 / 艾丽妮 S3 / 陈 S2 / 引星棘刺 S2 opened on the device alone and did nothing.)
*/
get allyTargetsOk() {
if (this.healSkill || this.noSkill || this.kind === 'passive') return false;
const s = this.spec;
if (s.allyTargets != null) return !!s.allyTargets;
const p = this.unit.profile;
if (p && p.noAttack) return false;
if (s.attack && s.attack.noAttack) return false;
return this.isTimed || !!s.attack;
}
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)
this.nextCastAt = -Infinity;
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;
if (this.spec.activateOnDeploy) this.activate('deploy');
}
_startPassive() {
this.active = true;
this._applyMods();
this._call('onStart', { reason: 'passive' });
// A deploy-time passive (琳琅诗怀雅 S1 仗义疏财 / S2 “见面礼”: kind 'passive' with no duration) never goes through
// `activate()`, so the client saw no 'skill' event at all and its model never played the skill clip the manifest
// carries for it (player report follow-up: the 57 instant clips with no Begin / own Idle — 2 of them passives;
// 凯瑟琳 S1 is `kind: instant` in the sim and already casts). Fire the same bounded window an instant cast uses, so
// the actor plays that clip once and then goes back to its idle with attacks on the normal clip. A passive WITH a
// duration (缄默德克萨斯 S1–S3, 野鬃 S1, 伊内丝 S3, 耀骑士临光 S2) is left alone: the sim keeps it active until
// death, so "how long should its stance show" is a separate question.
if (!this.noSkill && !(this.duration > 0)) {
const b = this.battle;
const u = this.unit;
b._ev(['skill', u.id, 1]);
u.skillAnimUntil = b.time + SKILL_ANIM_WINDOW;
b.after(SKILL_ANIM_WINDOW, () => { if (u.alive) b._ev(['skill', u.id, 0]); }, { owner: u });
}
}
_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;
// within 1e-9 of the cost is the cost (the timer tolerance of `timeLeft` below; PR #402): 300 time gains of 1/30 SP
// add up to 9.999999999999975 in floating point, which held a 10-SP charge one tick
while (this.sp > cost - 1e-9 && this.charges < this.maxCharges) {
this.charges++;
if (this.charges < this.maxCharges) this.sp = Math.max(0, 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 - 1e-9 && this.charges < this.maxCharges) { // (the 1e-9 of gainSp)
this.charges++;
this.sp = this.charges < this.maxCharges ? Math.max(0, 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;
// an instant / charge skill with no attack of its own is cast at most once per attack interval of its unit
// (activate sets nextCastAt; GitHub #298): the casts checked here wait for it — the unit's attacks are not touched
if (!this.isTimed && !this.spec.attack && this.battle.time < this.nextCastAt - 1e-9) 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();
// (CUSTOM_RANGE / SKILL_RANGE / GDGLOW_SKILL_2 / the DEFAULT condition: a registered ally target — 白铁's 铁钳号·原型机 —
// counts like an enemy for a non-heal skill, `_allyTargetIn`)
if (this.rule === 'CUSTOM_RANGE') {
if (!this.triggerGrid) return this._defaultCondition();
const keys = this._triggerKeys();
return b.enemiesInKeys(keys, u, { canHitFly: true }).length > 0 || this._allyTargetIn(this._trigSet);
}
if (this.rule === 'SKILL_RANGE') {
if (this.triggerAllies) return this._allyTriggerSatisfied();
if (!this.triggerGrid) return this._defaultCondition();
return b.anyEnemyInKeys(this._triggerKeys()) || this._allyTargetIn(this._trigSet);
}
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)) || this._allyTargetIn(ALL_TILES);
}
return false;
}
/**
* DEFAULT rule condition: an enemy (or injured ally for heal skills) inside the initial range (baseRangeKeys: own
* grid + permanent rangeExtend) — a registered ally target there too (`_allyTargetIn`: 铁钳号·原型机; not for heal
* skills) — 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 || this._allyTargetIn(keys)) 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;
this._beforeAttack = true; // the attack follows this cast in the same check (ai.js updateAlly): attacks pace it
try { return this.activate('DEFAULT'); } finally { this._beforeAttack = false; }
}
/** 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 + SKILL_ANIM_WINDOW;
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');
// an instant / charge skill with no attack of its own (no spec.attack: a throw, a heal, a buff, DP …) waits one
// attack interval of its unit before it is cast again from the tick (GitHub #298: 引星棘刺 S1 度算浪波, an SP_FULL
// cast, was recast every tick once 迅捷's refund refilled its bar at once, whatever the attack speed) — on the skill
// only: the cast is no attack, the unit's attacks / heals keep their own rhythm. [ASSUMED] one attack interval: PRTS
// prints no 前后摇 for such casts. A cast made right before an attack (DEFAULT) is paced by those attacks already;
// a cast onEnd opened again owns its own gap.
if (!this.active && !this.spec.attack && !this._beforeAttack && u.alive && u.s.interval > 0) this.nextCastAt = b.time + u.s.interval;
}
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 and `skillEnd`
* fires — neither when onEnd re-activated the skill (the new cast owns both).
*/
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 });
// onEnd started the next cast (耀骑士临光 S2 retreats on its duration end and 不屈 redeploys her inside that call; the
// deploy-timed skill starts again, PR #109): that cast owns the mods and its events — a trailing skillEnd would make
// listeners (骑士戒律) clear the new cast and the client would see the skill off while it runs
if (this.active || this.activations !== n) return;
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' : ''}>`;
}
}