Files
ark-stronghold-protocol/server/sim/battle/status.js
T
sganggs ac6454d171 feedback5: merge feedback4 (7ee815e) — W4D ported: a sleeping enemy cannot be blocked and takes no block slot (_checkBlock in server/sim/battle/blocking.js, the applyStatus release in battle/status.js, the plain-buff release re-applied to ai.js updateEnemy's cannot-act branch after WE1's swing cut, 瑕光's comment in kits/ops/chess_char_3_12-blemsh.js, buffs.js; 酒神's 迷狂牢笼 skips a sleeper too); golden: roster-003 / 005 / 013 / 032 / 038 / 039 / 042, bond-maniShip-high, solo-FUNNY-1, coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, coop2-NORMAL-70-diy, diy-131 / 132 moved
New on feedback4 since the last merge: W4D (bbe59b9, merged as 7ee815e) and the 0.1.4 version commit (30e7ace).
feedback5 keeps its own package.json / package-lock.json / APP_VERSION / README / DEPLOY version lines (0.2.0 sets
its own version later) and takes feedback4's CHANGELOG 0.1.4 entry, with W4D's line in it.

- Port (PRTS 异常效果 SLEEPING 沉睡 = 无法行动+无敌+不可阻挡; GitHub #140; the owner's decision of 2026-10-06):
  `_checkBlock` refuses `flags.sleep` (battle/blocking.js) and `applyStatus` releases an enemy that falls asleep
  (battle/status.js). Battle.js stays feedback5's façade, and tier3.js stays deleted (the 瑕光 comment is in her kits/ops
  file). ai.js: WE1 (89f730d) cuts a swing before enemyAttack on a stun / freeze / sleep. The cannot-act branch is still
  there, and a plain-buff `flags.sleep` still makes the enemy `stun` (units.js), so the plain-buff release stays in that
  branch, after the cut, with its comment rewritten for the new order.
- New on feedback5: 酒神's 迷狂牢笼 (kits/ops/op-phatm2.js cageBlock, O22) duplicated the old three-flag 不可阻挡 check.
  It now skips a sleeper too. No golden scenario spawns a 牢笼 over a sleeper: the corpus is identical with that line
  reverted. 予愿安洁莉娜's S3 helper is unchanged, since canTargetEnemy already drops sleepers.
- Readers that follow without code: blockedTargets (blocked-first targeting, the DEFAULT trigger, hitAllBlocked), the 要塞
  branch of acquireTargets (u.blocking), “庞贝”'s clock (blockedBy), the snapshot's UF.BLOCKED and the client's block
  marker (render/units.js). 死芒 S2 折朽 (自选) lets go a linked enemy that her 悲叹的仆役 held.
- Docs: DESIGN §24.9, its §24 intro entry, the §5.5 Blocking sentence (on feedback5's 阻挡半径倍率 text) and §22.8's
  "(since §24.9)", all as on feedback4; SIM.md as on feedback4 (the plain-buff sentence merged with feedback5's
  noNewBlock / undying text).
- Golden: a probe (counting successful sleeps on a blocked enemy, plain-buff sleeps and 牢笼 spawns over sleepers) ran
  over all 283 scenarios. Every scenario that moved sleeps blocked enemies; the counts are in brackets:
  roster-003 (小满 S2 [5]: same fight, blocked 27 → 29, snapshot hashes); roster-005 (elite 小满 S2 [3]: the 140 s
  timeout with 83 kills and 1 leak, where it used to clear at 135.43 s with 84); roster-013 (缇缇 S3 [11]: same kills,
  blocked 22 → 33); roster-032 (缇缇 S1 [10]: 71 kills instead of 68, 8 leaks instead of 11, 4 knock-outs instead of 8,
  萨尔贡 layers 41 → 35); roster-038 (elite 缇缇 S2 [1]: ends at 197.40 s instead of 197.27 s); roster-039 (瑕光 S2
  normal + elite [31 + 12]: same kills and counted leaks, the 窃贼 gets away, blocked 53 → 87); roster-042 (缇缇 S2 [3]:
  ends at 168.77 s instead of 166.93 s); bond-maniShip-high (小满 [1]: blocked 29 → 30, snapshot hashes); solo-FUNNY-1
  (ai_0's 缇缇 [3]: only ai_0's damage dealt); coop4-FUNNY-6 (ai_0's 缇缇 [15 + 56]: from round 9 ai_0's 精准 / 萨尔贡
  layers and boss damage move, ai_1 loses no LP from round 11, team LP 124 → 130); coop4-ABYSS-7 (ai_2's 小满 [18]: ai_2
  one kill fewer, ai_0 one more LP lost in round 9); coop2-NORMAL-8-serverrun (ai_1's 缇缇 [83]: only ai_1's damage dealt;
  WE1 had already turned this match into a loss); coop2-NORMAL-70-diy (the 自选 seat h_0's 缇缇 [171], ai_0's 缇缇 [74]
  and 小满 [19 + 21]: h_0's layers move from round 4, then its AI 托管 buys differently (boards from round 7); ai_0 246
  kills instead of 233, same end); diy-131 (死芒 S2 at tiers 5 and 6 [1 + 1]: snapshot hashes only); diy-132 (elite
  死芒 S2 [2]: ends at 199.50 s instead of 198.13 s, same kills). One scenario sleeps a blocked enemy and did not move:
  coop2-HARD-4 [1]. ai_1's 小满 lets go the enemy it holds; another enemy takes the slot; the sleeper wakes with no room
  and is blocked further on by 2_16_a. The match digest has no snapshot hashes, and its kills, leaks, damage and funds
  are identical. fields and standins did not move. Most outcomes differ from feedback4's list because feedback5's
  baselines differ (WE1's damage frame, etc.).
- Tests: test/sim/feedback4-sleep-unblock.test.js (from feedback4) passes 5/5 on the split layout. Every case fails with
  the three sim hunks reverted, and case 3 (plain buff) fails with only the ai.js release reverted. Targeted sim tests
  pass 184/184 (blocking, block targets, enemy swing, feedback2-enemy-attack-stand, combat / statuses, robustness,
  stun SP, professions, healers, fidelity …). Content tests plus kits_layout and docs-consistency pass 658/658 (kits
  t1t2 / t3 / t5 and alt t2 / t3 / t5 for 小满 / 瑕光 / 缇缇, 酒神, 死芒, garrisons, bosses, items). Stealth / liftoff /
  enemies pass 36/36. npm run golden: all 283 match. GOLDEN_FULL=1 test/golden.test.js passes 9/9 (no stored engine
  error). lint 0 errors, check:imports and typecheck clean.
2026-10-06 17:49:10 +08:00

306 lines
18 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/sleeping enemy is released by its blocker
* (沉睡 = 无法行动+无敌+不可阻挡, PRTS 异常效果: the slot frees for the next enemy, the sleeper stays put — DESIGN §24.9).
* `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 || f.sleep)) 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); }
}