Files
ark-stronghold-protocol/server/sim/buffs.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

188 lines
10 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/buffs.js — Buff model, mod keys, status catalogue and immunities (DESIGN §5.3).
//
// Buff = { key, source, duration (s, Infinity), timeLeft, stacks, maxStacks, refresh, mods, flags, onTick,
// interval, onExpire, onRemove, tags[], shield, shieldHits, persist, status, visible }
// refresh: 'replace' (default: new instance replaces the old one), 'extend' (keep the longer remaining time,
// take the new mods), 'stack' (stacks+1 up to maxStacks, timer reset), 'independent' (separate instances with
// their own timers, at most maxStacks alive — the oldest is dropped), 'keep' (ignore when already present).
// Additive mod keys scale with stacks (value × stacks); *Mul keys multiply (value ^ stacks).
import { COLD_ASPD, COLD_FREEZE_DURATION, FREEZE_RES_DOWN, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
/** 抵抗: "麻痹等状态每5秒流失1层" — tick of the resist buff. */
function resistPalsyDecay({ battle, unit }) {
const p = unit.findBuff('palsy');
if (!p) return;
if (--p.stacks <= 0) battle.removeBuff(unit, p); else unit.markDirty();
}
/** Additive mod keys (summed; × stacks). */
export const ADD_KEYS = Object.freeze([
'atkFlat', 'atkPct', 'defFlat', 'defPct', 'hpFlat', 'hpPct', 'resFlat', 'aspd', 'batPct', 'blockCnt',
'rangeExtend', 'defIgnoreFlat', 'defIgnorePct', 'resIgnoreFlat', 'resIgnorePct', 'dodgePhys', 'dodgeArts',
'spRecoveryFlat', 'maxTargets', 'taunt', 'hpRegen', 'hpRegenRatio', 'spCostFlat', 'moveFlat', 'massFlat',
]);
/** Multiplicative mod keys (product; ^ stacks). */
export const MUL_KEYS = Object.freeze([
'atkMul', 'defMul', 'hpMul', 'resMul', 'moveMul', 'dmgDealtMul', 'dmgTakenMul', 'physTakenMul', 'artsTakenMul',
'trueTakenMul', 'elemTakenMul', 'healingDealtMul', 'healingTakenMul', 'spRecoveryMul', 'redeployMul',
'atkScaleMul', 'physDealtMul', 'artsDealtMul',
]);
/** Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. */
export const FLAG_KEYS = Object.freeze([
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract',
]);
/**
* Status catalogue: key → template. `value` semantics per status are documented inline. Effects follow the official
* term table (gamedata_const termDescriptionDict, ba.*). `valued: default` marks "同名效果取最高" statuses: a weaker
* application never overrides a stronger running one (it resumes if it outlasts it) — see Battle.applyStatus.
* Content applies statuses with `battle.applyStatus(target, key, { duration, source, value })`.
*/
export const STATUS = Object.freeze({
// 晕眩: 无法移动、阻挡、攻击及使用技能 (a stunned operator releases the enemies it blocks)
stun: { flags: { stun: true, noBlock: true }, immune: 'stun' },
// 冻结: 无法移动、攻击及使用技能; 敌方被冻结时,法术抗性-15 (the RES cut applies to enemies only)
freeze: { flags: { freeze: true, stun: true }, mods: { resFlat: -FREEZE_RES_DOWN }, enemyOnlyMods: true, immune: 'frozen' },
cold: { flags: { cold: true }, mods: { aspd: COLD_ASPD } },
// 沉睡: 无敌且无法行动 (untargetable, takes no damage — except from `hitSleep` attackers — and blocks nothing)
sleep: { flags: { sleep: true, noBlock: true }, immune: 'sleep' },
// value = fraction of speed removed (0.8 ⇒ moveMul 0.2). Default 0.5.
slow: { mods: (v) => ({ moveMul: 1 - clamp01(v ?? 0.5) }), valued: 0.5 },
// 停顿 (sluggish): −80 % move speed.
sluggish: { mods: { moveMul: 0.2 } },
bind: { flags: { bind: true, noMove: true }, mods: { moveMul: 0 } },
// value = extra damage taken (0.3 ⇒ ×1.3). Default 0.3.
fragile: { mods: (v) => ({ dmgTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
artsFragile: { mods: (v) => ({ artsTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
physFragile: { mods: (v) => ({ physTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
elemFragile: { mods: (v) => ({ elemTakenMul: 1 + (v ?? 0.2) }), valued: 0.2 },
silence: { flags: { silence: true }, immune: 'silence' },
// 恐惧: 无法被阻挡并四散逃跑 (no attacks, unblockable, stops advancing along its route)
fear: { flags: { fear: true, unblockable: true }, immune: 'feared' },
// 战栗: 被阻挡后无法进行普通攻击
tremble: { flags: { tremble: true }, immune: 'feared' },
disarm: { flags: { disarm: true } },
stealth: { flags: { stealth: true } },
reveal: { flags: { reveal: true } },
invulnerable: { flags: { invulnerable: true } },
// 浮空: 无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半
levitate: { flags: { levitate: true, stun: true, unblockable: true }, immune: 'levitate' },
// 麻痹: value = stacks (default 1, max 3); each stack cancels one enemy normal attack; lasts until consumed
palsy: { palsy: true },
// value = taunt level delta (default +1)
taunt: { mods: (v) => ({ taunt: v ?? 1 }) },
// value = ATK fraction removed (default 0.3)
weaken: { mods: (v) => ({ atkMul: 1 - clamp01(v ?? 0.3) }), valued: 0.3 },
// value = aspd delta (negative slows attacks), default −30
aspdDown: { mods: (v) => ({ aspd: v ?? -30 }), valued: -30 },
// value = DEF fraction removed (default 0.3)
defDown: { mods: (v) => ({ defMul: 1 - clamp01(v ?? 0.3) }), valued: 0.3 },
// value = RES flat removed (default 20)
resDown: { mods: (v) => ({ resFlat: -(v ?? 20) }), valued: 20 },
unblockable: { flags: { unblockable: true } },
// 诱导 (ba.attract): 无法被阻挡并向目标位置移动 — the engine walks the enemy (own speed, grid path; flyers straight)
// to `opts.point` (default: the source's tile) and keeps it there until the status ends, then it resumes its route
attract: { flags: { attract: true, unblockable: true }, attract: true },
// 抵抗 (ba.buffres): the RESIST_STATUSES applied to the unit last (1 − value) as long, value default 0.5 (减半);
// 同名效果不叠加 — several sources never compound (strongest value wins, a weaker one resumes if it outlasts it);
// a resisting unit also loses one 麻痹 stack every RESIST_PALSY_DECAY s (Battle.applyStatus)
resist: { resist: true, mods: () => null, valued: RESIST_DEFAULT, buff: Object.freeze({ interval: RESIST_PALSY_DECAY, onTick: resistPalsyDecay }) },
// element burst states (engine-managed; listed for immunity/visibility)
burnBurst: { flags: { burstLock: true } },
neuralBurst: { flags: { burstLock: true } },
necrosisBurst: { flags: { burstLock: true } },
apoptosisBurst: { flags: { burstLock: true } },
erosionBurst: { flags: { burstLock: true } },
});
/**
* Control statuses (异常状态) shortened by 抵抗: 晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速. The
* official term lists 晕眩/寒冷/冻结/恐惧/诱导…; the rest follow the operator kits that grant 抵抗 [ASSUMED]. 麻痹 is
* stack-based: it decays instead (RESIST_PALSY_DECAY).
*/
export const RESIST_STATUSES = Object.freeze(new Set(['stun', 'freeze', 'cold', 'sleep', 'fear', 'tremble', 'attract', 'levitate',
'bind', 'silence', 'disarm', 'sluggish', 'slow']));
/** Statuses that should be reported to clients as `['status', id, key, on]`. */
export const VISIBLE_STATUS = new Set(Object.keys(STATUS));
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
let buffSeq = 0;
/** Normalise a buff descriptor (does not attach it). */
export function makeBuff(b) {
const duration = b.duration == null ? Infinity : Number(b.duration);
return {
key: String(b.key ?? 'buff'),
source: b.source ?? null,
duration: Number.isFinite(duration) || duration === Infinity ? duration : Infinity,
timeLeft: Number.isFinite(duration) || duration === Infinity ? duration : Infinity,
stacks: Math.max(1, b.stacks ?? 1),
maxStacks: Math.max(1, b.maxStacks ?? 1),
refresh: b.refresh ?? 'replace',
mods: b.mods ?? null,
flags: b.flags ?? null,
onTick: b.onTick ?? null,
interval: b.interval ?? 0,
_acc: 0,
onExpire: b.onExpire ?? null,
onRemove: b.onRemove ?? null,
tags: b.tags ?? [],
shield: b.shield ?? (b.mods && b.mods.shield) ?? 0,
shieldHits: b.shieldHits ?? 0,
persist: !!b.persist,
status: b.status ?? null,
visible: b.visible ?? false,
data: b.data ?? {},
seq: ++buffSeq,
};
}
/**
* Compute aggregated modifiers of a buff list. Returns `{ add: {k: sum}, mul: {k: product}, flags: {k: bool}, shield,
* permRangeExtend }` — permRangeExtend = the rangeExtend of persistent never-expiring buffs (talents, modules: part of
* the initial range, Battle._refreshRange).
*/
export function aggregateMods(buffs) {
const add = Object.create(null);
const mul = Object.create(null);
const flags = Object.create(null);
let shield = 0;
let permRangeExtend = 0;
// dodge sources are independent rolls: total = 1 − Π(1 − p)^stacks (one single source keeps its exact value)
const dodge = { dodgePhys: null, dodgeArts: null };
for (let i = 0; i < buffs.length; i++) {
const b = buffs[i];
const st = b.stacks;
const m = b.mods;
if (m) {
for (const k in m) {
const v = m[k];
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
if (k === 'shield') continue;
if (k.endsWith('Mul')) mul[k] = (mul[k] ?? 1) * (st === 1 ? v : Math.pow(v, st));
else if ((k === 'dodgePhys' || k === 'dodgeArts') && v > 0) {
const p = Math.min(1, v);
const d = dodge[k];
if (!d) dodge[k] = { n: st, p, miss: Math.pow(1 - p, st) };
else { d.n += st; d.miss *= Math.pow(1 - p, st); }
} else {
add[k] = (add[k] ?? 0) + v * st;
if (k === 'rangeExtend' && b.persist && b.duration === Infinity) permRangeExtend += v * st;
}
}
}
if (b.flags) for (const k in b.flags) if (b.flags[k]) flags[k] = true;
if (b.shield > 0) shield += b.shield;
}
for (const k of ['dodgePhys', 'dodgeArts']) {
const d = dodge[k];
if (d) add[k] = (add[k] ?? 0) + (d.n === 1 ? d.p : 1 - d.miss);
}
return { add, mul, flags, shield, permRangeExtend };
}