Files
ark-stronghold-protocol/server/sim/battle/status.js
T

305 lines
17 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/battle/status.js — Battle methods: buffs and statuses: addBuff (refresh rules) / removeBuff, the per-tick
// buff pass (onTick, expiry, natural regeneration), catalogue statuses (immunities, 抵抗, 寒冷 → 冻结, 浮空, 诱导, 恐惧) and
// "同名效果取最高".
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { COLD_FREEZE_DURATION, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT } from '../constants.js';
import { makeBuff, STATUS, RESIST_STATUSES } from '../buffs.js';
import { palsyBuff } from '../damage.js';
import { evadesGround } from '../targeting.js';
import { stampFear } from '../fear.js';
export class BattleStatus {
addBuff(unit, b) {
if (!unit || (!unit.alive && !b.allowDead)) return null;
const buff = makeBuff(b);
const list = unit.buffs;
let idx = -1;
for (let i = 0; i < list.length; i++) if (list[i].key === buff.key) { idx = i; break; }
if (idx >= 0 && buff.refresh !== 'independent') {
const old = list[idx];
switch (buff.refresh) {
case 'keep':
return old;
case 'stack':
old.stacks = Math.min(old.stacks + buff.stacks, Math.max(old.maxStacks, buff.maxStacks));
old.maxStacks = Math.max(old.maxStacks, buff.maxStacks);
old.timeLeft = buff.duration;
old.duration = buff.duration;
if (buff.mods) old.mods = buff.mods;
if (buff.flags) old.flags = buff.flags;
unit.markDirty();
return old;
case 'extend':
old.timeLeft = Math.max(old.timeLeft, buff.duration);
old.duration = Math.max(old.duration, buff.duration);
if (buff.mods) old.mods = buff.mods;
if (buff.flags) old.flags = buff.flags;
if (buff.shield > old.shield) old.shield = buff.shield;
if (buff.shieldHits > old.shieldHits) old.shieldHits = buff.shieldHits;
unit.markDirty();
return old;
default:
list[idx] = buff;
unit.markDirty();
return buff;
}
}
if (buff.refresh === 'independent') {
const same = list.filter((x) => x.key === buff.key);
if (same.length >= buff.maxStacks) {
const oldest = same.reduce((a, c) => (a.seq < c.seq ? a : c));
this._removeBuffAt(unit, list.indexOf(oldest), true);
}
}
list.push(buff);
unit.markDirty();
if (buff.visible || buff.status) {
const key = buff.status ?? buff.key;
if (list.filter((x) => (x.status ?? x.key) === key).length === 1) this._ev(['status', unit.id, key, 1]);
}
return buff;
}
removeBuff(unit, keyOrBuff) {
if (!unit) return 0;
let n = 0;
for (let i = unit.buffs.length - 1; i >= 0; i--) {
const b = unit.buffs[i];
if (b === keyOrBuff || b.key === keyOrBuff) { this._removeBuffAt(unit, i, true); n++; }
}
return n;
}
_removeBuffAt(unit, i, callRemove = true) {
const b = unit.buffs[i];
if (!b) return;
unit.buffs.splice(i, 1);
unit.markDirty();
if ((b.visible || b.status)) {
const key = b.status ?? b.key;
if (!unit.buffs.some((x) => (x.status ?? x.key) === key)) this._ev(['status', unit.id, key, 0]);
}
if (callRemove && b.onRemove) this._safe(() => b.onRemove({ battle: this, unit, buff: b }), 'buff.onRemove', unit);
}
_tickBuffs(dt) {
const units = this.units;
for (let i = 0, n = units.length; i < n; i++) { // units created by onTick handlers start ticking next tick
const u = units[i];
if (!u.alive || u.removed) continue;
if (u.buffs.length) {
const arr = u.buffs.slice();
for (const b of arr) {
if (!u.alive) break;
if (u.buffs.indexOf(b) < 0) continue;
if (b.onTick) {
if (b.interval > 0) {
b._acc += dt;
let n = 0;
while (b._acc >= b.interval - 1e-9 && n++ < 8) {
b._acc -= b.interval;
this._safe(() => b.onTick({ battle: this, unit: u, buff: b, dt: b.interval }), 'buff.onTick', u);
}
} else this._safe(() => b.onTick({ battle: this, unit: u, buff: b, dt }), 'buff.onTick', u);
}
if (b.timeLeft !== Infinity) {
b.timeLeft -= dt;
if (b.timeLeft <= 1e-9) {
const i = u.buffs.indexOf(b);
if (i >= 0) {
this._removeBuffAt(u, i, false);
if (b.onExpire) this._safe(() => b.onExpire({ battle: this, unit: u, buff: b }), 'buff.onExpire', u);
}
}
}
}
}
// natural HP regeneration
if (u.alive && u.deployed && !u.bossPool) {
const regen = u.s.hpRegen;
if (regen > 0 && u.hp < u.s.maxHp) {
u._regenAcc = (u._regenAcc ?? 0) + regen * dt;
if (u._regenAcc >= 1 || u.hp + u._regenAcc >= u.s.maxHp) {
this.heal(u, u, u._regenAcc, { self: true, silent: true, regen: true });
u._regenAcc = 0;
}
}
}
}
}
/**
* Apply a catalogue status. opts: { duration, source, value, force, refresh, point, resistApplied, stackAs, reenter } — returns
* true when applied. Honours enemy immunities (stun/silence/sleep/frozen/levitate/feared) unless `force`. `beforeStatus`
* handlers may cancel it or change `duration` / `value`. Official rules (buffs.js STATUS): 抵抗 (the `resist` status)
* shortens the RESIST_STATUSES by its value (default half; applied after `beforeStatus`; `resistApplied` skips that
* pass — the cold-on-cold 冻结 below already used post-抵抗 lengths). A second 寒冷 while 寒冷 remains applies 冻结 for
* max(remaining, this cold after 抵抗) (PRTS 术语释义 寒冷 「持续时间取双方之中最高」). 浮空 and 缚地 last half as long on units
* heavier than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高"
* statuses (`valued`) keep the strongest value — a weaker application only extends past the stronger one's end (it
* then resumes); `stackAs` = the value such an application competes with instead of its own (its effect stays
* `value`): an effect the game stacks as another strength — Raidian S3's 虚弱, PRTS 备注 "在叠加时视为90%(1级~6级)/80%
* (7级~专精二)…的虚弱(仅影响叠加优先级,不影响实际效果)"; other statuses refresh to the longer duration. 诱导 (`attract`) walks the enemy to `point`
* ([r, c] or {x, y}; default the source's tile — a new application moves the point); 恐惧 (`fear`) stamps where it
* was applied and from where (fear.js stampFear: the fan of 恐惧可达地块 its movement uses). A stunned/sleeping operator
* releases the enemies it blocks; a feared/levitated/unblockable/attracted enemy is released by its blocker.
* `statusApplied` reports the final duration and `entered` (the target did not carry the status before) — or, with
* `reenter`, entered anyway: a pulse whose own short status the caller re-applies as a fresh one each time (缇缇 S2's
* sleep ward, DESIGN §24.8); the buff itself is refreshed as usual.
* A unit that is 无敌 and 无法选中 at once (a 重生 in progress, a hovering or 永久无敌 leader part) takes no status from
* the other side, `force` included — PRTS 无敌 "无法被不同阵营选中": so a status carried by the very hit that knocked an
* enemy out does not land after its 重生's cleanse (DESIGN §21.4). A ground enemy's status never lands on an airborne
* 起飞 ally (对地规避: targeting.js evadesGround) unless `opts.ignoreSelect`.
*/
applyStatus(target, key, opts = {}) {
if (!target || !target.alive) return false;
const src = opts.source;
if (src && src.side && src.side !== target.side && target.s.flags.invulnerable && target.s.flags.untargetable) return false;
if (!opts.ignoreSelect && target.s.flags.liftoff && evadesGround(src, target)) return false;
const tpl = STATUS[key] || { flags: { [key]: true } };
let duration = opts.duration == null ? Infinity : Number(opts.duration);
let value = opts.value;
if (!(duration > 0)) return false;
const immune = target.def && target.def.immune;
if (!opts.force && tpl.immune && immune && immune.has(tpl.immune)) return false;
// 浮空 Buff (PRTS 异常效果: "若单位数据上为飞行单位且不持有缚地异常或是持有浮空异常则Buff取消"; 行动方式 "行动类型(数据)为
// 飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"): refused on data flyers (`motion` FLY) that hold no 缚地 and on
// units already levitated — a hovering 近地悬浮 enemy is WALK in its data, so it can be levitated (no 浮空强化 in this mode)
if (key === 'levitate' && ((target.motion === 'FLY' && !target.s.flags.groundbind) || target.s.flags.levitate)) return false;
if (this._hooks.beforeStatus) {
const c = this.emit('beforeStatus', { source: opts.source ?? null, target, status: key, duration, value, cancel: false });
if (c.cancel || !target.alive) return false;
const d = Number(c.duration);
if (d === Infinity || (Number.isFinite(d) && d > 0)) duration = d;
else if (Number.isFinite(d) && d <= 0) return false;
value = c.value;
}
if (RESIST_STATUSES.has(key) && !opts.resistApplied) {
const rv = this.resistOf(target);
if (rv > 0) duration *= 1 - rv;
}
// 浮空 / 缚地 (ba.levitate, ba.groundbind): "对重量大于3的单位持续时间减半"
if ((key === 'levitate' || key === 'groundbind') && target.s.massLevel > LEVITATE_HALF_WEIGHT) duration /= 2;
if (!(duration > 0)) return false;
if (key === 'cold' && target.findBuff('cold') && !(immune && immune.has('frozen'))) {
// PRTS 术语释义 寒冷: 友方寒冷 pairs into 冻结, 「持续时间取双方之中最高」. `duration` is this cold after 抵抗;
// the cold already on the target keeps timeLeft. addBuff refresh 'extend' below sets that cold to the same max.
// COLD_FREEZE_DURATION is only the fallback when neither side has a duration. resistApplied: that max is already
// post-抵抗, so the freeze must not be halved again.
// [ASSUMED] one catalogue cold, so an enemy-applied second cold uses this max too. PRTS states it on the 友方
// line only (敌方 cold becomes 冻结 when the target already has 敌方 cold or 敌方 冻结).
const prev = target.findBuff('cold');
const spans = [prev.timeLeft, duration].filter((t) => t === Infinity || (Number.isFinite(t) && t > 0));
const freezeFor = spans.length ? Math.max(...spans) : COLD_FREEZE_DURATION;
this.applyStatus(target, 'freeze', {
duration: freezeFor, source: opts.source, force: opts.force,
...(spans.length ? { resistApplied: true } : {}),
});
if (!target.alive) return false;
}
const source = opts.source ?? null;
let entered = true;
if (opts.reenter !== true) for (const b of target.buffs) if ((b.status ?? b.key) === key) { entered = false; break; }
if (tpl.palsy) {
this.addBuff(target, { ...palsyBuff(value ?? 1), duration, source });
} else if (tpl.valued != null && typeof tpl.mods === 'function' && opts.refresh == null) {
this._applyValuedStatus(target, key, tpl, duration, value ?? tpl.valued, source, opts.stackAs);
} else {
const mods = tpl.enemyOnlyMods && target.side !== 'enemy' ? null : typeof tpl.mods === 'function' ? tpl.mods(value) : (tpl.mods || null);
const b = this.addBuff(target, { key, duration, refresh: opts.refresh ?? 'extend', mods, flags: tpl.flags || null, status: key, visible: true, source });
if (tpl.attract && b) this._setAttractPoint(target, b, opts.point ?? value, source);
// 恐惧: the hit position and the source's position of every application (fear.js — the fan of reachable tiles)
if (key === 'fear' && b && target.side === 'enemy') stampFear(this, target, b, source);
}
const f = tpl.flags;
if (f && target.side === 'enemy' && (f.levitate || f.unblockable || f.fear)) this._unblock(target);
if (f && target.side === 'ally' && f.noBlock) this.releaseBlocked(target);
if (this._hooks.statusApplied) this.emit('statusApplied', { source, target, status: key, duration, value, entered });
return true;
}
/** 抵抗 of a unit: share of a resisted status's duration removed (0 = none; the `resist` status value, ≤ 0.95). */
resistOf(unit) {
if (!unit || !unit.buffs.length) return 0;
let r = 0;
for (const b of unit.buffs) {
if (b.status !== 'resist') continue;
const v = Number.isFinite(b.data?.value) ? b.data.value : RESIST_DEFAULT;
if (v > r) r = v;
}
return Math.min(0.95, r);
}
/** 诱导 target point of an `attract` status buff (enemy walks there; see ai.js moveAttracted). */
_setAttractPoint(target, buff, point, source) {
let r = null, c = null;
if (Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1])) { r = point[0]; c = point[1]; }
else if (point && typeof point === 'object' && Number.isFinite(point.x) && Number.isFinite(point.y)) { r = point.y; c = point.x; }
else if (source && Number.isFinite(source.x) && Number.isFinite(source.y)) { r = source.y; c = source.x; }
if (r == null) { if (!buff.data.attract) buff.data = { ...buff.data, attract: null }; return; }
const R = this.rect;
r = Math.max(R.r0, Math.min(R.r1, Math.round(r)));
c = Math.max(R.c0, Math.min(R.c1, Math.round(c)));
buff.data = { ...buff.data, attract: { r, c, pts: null, i: 0, ver: -1 } };
}
/**
* "同名效果取最高" for a content effect that is not a catalogue status (no immunities, 抵抗, `beforeStatus` /
* `statusApplied` hooks or status icon): ONE instance of `key` per target whatever the number of sources — the
* strongest `value` applies, a weaker application only extends past the stronger one's end (it then resumes), an
* equal one refreshes to the longer duration. `mods(value)` builds the buff's mods. The engine's default for two
* same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). Returns true when applied.
* @param {object} target
* @param {string} key
* @param {{ duration: number, value: number, mods: (v: number) => object, source?: object|null }} opts
*/
applyStrongest(target, key, { duration, value, mods, source = null } = {}) {
if (!target || !target.alive || !(Number(duration) > 0) || !Number.isFinite(value) || typeof mods !== 'function') return false;
this._applyValuedStatus(target, key, { mods, valued: value, plain: true }, Number(duration), value, source);
return true;
}
/**
* "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). A
* `plain` template (applyStrongest) is an ordinary invisible buff, not a status. `stackAs` (finite): the strength this
* application competes with instead of its value (applyStatus opts.stackAs); kept in `data.stackAs` / the tail.
*/
_applyValuedStatus(target, key, tpl, duration, value, source, stackAs = null) {
const as = Number.isFinite(stackAs) ? stackAs : null;
const strength = (v, s = null) => Math.abs(Number.isFinite(s) ? s : Number.isFinite(v) ? v : tpl.valued);
// (no stackAs: the very objects of before — data { value, tail }, tails { value, until })
const entry = (v, s, extra) => (Number.isFinite(s) ? { value: v, stackAs: s, ...extra } : { value: v, ...extra });
const make = (v, dur, tail, s = null) => ({
...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick)
key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: tpl.plain ? null : key,
visible: !tpl.plain, source,
data: entry(v, s, { tail }),
onExpire: ({ battle, unit, buff }) => {
const t = buff.data.tail;
if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null, t.stackAs));
},
});
const old = target.buffs.find((b) => b.key === key && (tpl.plain || b.status === key));
if (!old) { this.addBuff(target, make(value, duration, null, as)); return; }
const oldV = old.data && Number.isFinite(old.data.value) ? old.data.value : tpl.valued;
const oldAs = old.data && Number.isFinite(old.data.stackAs) ? old.data.stackAs : null;
const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration;
const oldTail = old.data && old.data.tail;
const longerTail = (a, b) => (!a ? b : !b ? a : (b.until > a.until ? b : a));
if (strength(value, as) > strength(oldV, oldAs) + 1e-12) {
// stronger: takes over now; the weaker old one (or its tail) resumes if it lasts longer
const tail = longerTail(oldEnd > newEnd ? entry(oldV, oldAs, { until: oldEnd }) : null, oldTail && oldTail.until > newEnd ? oldTail : null);
this.addBuff(target, make(value, duration, tail, as));
} else if (strength(value, as) < strength(oldV, oldAs) - 1e-12) {
// weaker: never overrides; remembered as the tail when it outlasts the running one
if (newEnd > oldEnd && (!oldTail || newEnd > oldTail.until)) old.data = { ...old.data, value: oldV, tail: entry(value, as, { until: newEnd }) };
} else if (duration > old.timeLeft) {
old.timeLeft = duration;
old.duration = Math.max(old.duration, duration);
}
}
removeStatus(target, key) { return this.removeBuff(target, key); }
}