Files
ark-stronghold-protocol/server/sim/skills.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

412 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/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, while stunned, or while the unit has the noSp flag (阻回: no SP
// gain of any kind — time, attack, hurt or granted).
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
// Trigger rules: DEFAULT (ready + about to attack/heal + enemy / injured ally in the INITIAL range — or, checked every
// tick, an enemy inside one of the content trigger ranges added with addTriggerRange: 海嗣, 流形),
// 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:
// BWIKI "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放", research 03 Addendum C1 — not any enemy on
// the field, which burnt the skill on enemies that had just spawned), GDGLOW_SKILL_2 = DEFAULT, 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.
// 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},
// 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 } from './targeting.js';
const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE']);
/** Enemies that satisfy a content trigger range (any targetable enemy, flyers included). */
const TRIGGER_PROFILE = Object.freeze({ canHitFly: true });
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;
this.healSkill = s.heal ?? (unit.profile && unit.profile.dmgType === 'heal' && !!unit.profile.heal);
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.pending = false; // instant/charges: next attack uses spec.attack
this.activations = 0;
this.lastStart = -Infinity;
this._buffKey = `skill:${unit.id}`;
this._trigKeys = 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);
}
/** Called on every (re)deployment. `carry` = { sp, skillActive } for unite helpers. */
reset(carry = null) {
this.active = false;
this.pending = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.charges = 0;
this.sp = 0;
this._trigKeys = null;
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;
// unite helpers whose timed skill was running when their combat ended: it keeps running (a fresh duration/ammo),
// without spending a charge — the carried SP is what they had accumulated (0 while a skill runs).
if (carry && carry.skillActive && this.isTimed) this.activate('carry', { free: true });
}
_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 (s.targeting && (s.targeting.rangeGrid || s.targeting.rangeExtend)) this.battle._refreshRange(this.unit);
}
_removeMods() {
this.battle.removeBuff(this.unit, this._buffKey);
if (this.spec.targeting && (this.spec.targeting.rangeGrid || this.spec.targeting.rangeExtend)) 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 });
}
if (!u.canAct) return;
if (this.spType === 'time' && !(this.active && this.isTimed) && !u.s.flags.noSp) {
const rate = u.s.spRecovery;
if (rate > 0) this.gainSp(rate * dt, 'time');
}
if (!this.ready || u.s.flags.silence) return;
if (this.active && this.isTimed) 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');
}
}
_tickRuleSatisfied() {
const b = this.battle;
const u = this.unit;
if (this.rule === 'SP_FULL') return true;
// SEARCH: an enemy inside the initial attack range (research 03 Addendum C1), 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();
if (!this._trigKeys) this._trigKeys = absoluteRangeKeys(this.triggerGrid, u.tileR, u.tileC, u.dir, 0);
return b.enemiesInKeys(this._trigKeys, u, { canHitFly: true }).length > 0;
}
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).
*/
_defaultCondition() {
const b = this.battle;
const u = this.unit;
if (b.rangeChanged(u)) b._refreshRange(u);
const keys = 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 melee unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy
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._defaultCondition()) 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.active && this.isTimed) && this.unit.canAct && !this.unit.s.flags.silence) {
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.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._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 });
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;
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)
addAmmo(n) { if (this.active && this.kind === 'ammo' && Number.isFinite(n)) this.ammoLeft += n; }
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' : ''}>`;
}
}