feedback3: merge W3A — 联防 carries HP ratio and SP only (GitHub #82); down operators take part in the 联防 devour (players' reports, owner's decision); 不屈 on 撤退 / 替身 switches; SP recovers during stun (洛洛 S2)

This commit is contained in:
sganggs committed 2026-10-04 17:57:44 +08:00
commit dcc44712ad
23 files changed
+629 -67

No files matched your search

+3 -1
View File
@@ -1546,7 +1546,9 @@ export class PlayerState {
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid);
units.push(u);
} else if (piece.kind === 'token') {
units.push({ uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid });
const u = { uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid };
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); // 联防: { sp } (unite.js)
units.push(u);
}
}
return {
+3 -2
View File
@@ -829,10 +829,11 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
const seen = new Set();
for (const u of Array.isArray(p.unitsEnd) ? p.unitsEnd : []) {
// units the sim created during the battle (no board uid) or not on this player's board carry nothing the match uses
if (!u || !Number.isInteger(u.uid) || !own.chess.has(u.uid) || seen.has(u.uid)) continue;
// (the board's operators and summon pieces: 联防 carries an operator's HP ratio and SP, a summon's SP — unite.js)
if (!u || !Number.isInteger(u.uid) || !own.all.has(u.uid) || seen.has(u.uid)) continue;
seen.add(u.uid);
if (!finiteIn(u.hpPct, 0, 1) || !finiteIn(u.sp, 0, 1e5)) return bad('unit state');
unitsEnd.push({ uid: u.uid, defId: own.chess.get(u.uid), hpPct: u.hpPct, sp: u.sp, skillActive: !!u.skillActive, alive: !!u.alive && u.hpPct > 0 });
unitsEnd.push({ uid: u.uid, defId: own.all.get(u.uid), hpPct: u.hpPct, sp: u.sp, skillActive: !!u.skillActive, alive: !!u.alive && u.hpPct > 0 });
}
const unitStats = [];
for (const u of Array.isArray(p.unitStats) ? p.unitStats : []) {
+15 -6
View File
@@ -5,15 +5,17 @@
// players chosen by most units on the field (downed included) > has an active bond (存疑) > most undowned units, then
// seat; with 2 helpers the one ranked first by most units > active bond > Σ active bond layers (存疑) > most undowned
// units "率先迎敌" on the RIGHT-hand field (colOffset +8, where the escaped_multi routes enter), the other keeps the
// left half (colOffset 0); a lone helper plays escaped_single on its own field. Their operators keep HP%, SP and a
// running timed skill from the end of their own combat (BattleResult.unitsEnd → PlayerBattleInput.units[].carryState,
// "阵地以其当前状态"). An operator knocked out at the end of its own combat (alive false) is fielded with
// left half (colOffset 0); a lone helper plays escaped_single on its own field. Their operators keep the HP ratio and
// the SP (技力, stored charges included) from the end of their own combat, nothing else — a skill still running then
// enters switched off (BattleResult.unitsEnd → PlayerBattleInput.units[].carryState `{ hpPct, sp }`, "阵地以其当前状态";
// community report #34 / GitHub #82: it used to restart for free). An operator knocked out at the end of its own combat (alive false) is fielded with
// `carryState: { down: true }`: PRTS "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,
// 上一阶段为退场状态的干员强制退场)" — the sim deploys it with everyone and forces it out at once (constants.js
// FORCED_EXIT), so it lies on its own tile with the redeploy ring and comes back like after any knock-out (user
// playtest #5 item 2: it used to be left out and vanished). Its timer is its full redeploy time (the official 联防
// setup carries only hp / tech per operator, research 09 §3 HelpBattleInfo; the user confirmed it restarts). Summons are fielded as the board has
// them (a summon's own end state is not carried: unitsEnd lists operators only). Enemies = the union of every leaker's
// setup carries only hp / tech per operator, research 09 §3 HelpBattleInfo; the user confirmed it restarts). The board's summon
// pieces are fielded as the board has them and keep only their SP ("召唤物仅修改技力": carryState `{ sp }`, unitsEnd lists
// them beside the operators; one off the field at the end enters fresh [ASSUMED]). Enemies = the union of every leaker's
// counted leaks (same stats: the SpawnSpec mods travel with the leak), routed on the escaped template (`escaped_single`
// for 1 helper, `escaped_multi` for 2): walkers on its `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` /
// `lazerd` (waves.js buildUniteWave); kill bounties keep paying the killer (a helper). No IN_BATTLE layer gains. Time
@@ -105,11 +107,18 @@ export function uniteBattleOpts(m, plan, timeLimit) {
const players = plan.helpers.map((ps, i) => {
const carry = new Map();
const r = m.lastResults.get(ps.playerId);
const summonUids = new Set();
for (const p of ps.board.values()) if (p && p.kind === 'token') summonUids.add(p.uid);
for (const u of (r && r.unitsEnd) || []) {
if (!u || u.uid == null) continue;
const sp = Number.isFinite(u.sp) ? Math.max(0, u.sp) : 0;
// a summon: its SP only ("召唤物仅修改技力"); one off the field at the end enters fresh [ASSUMED]
if (summonUids.has(u.uid)) { if (u.alive) carry.set(u.uid, { sp }); continue; }
// knocked out at the end of its own combat: 强制退场 right after the deployment (see header)
if (!u.alive) { carry.set(u.uid, { down: true }); continue; }
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp: Number.isFinite(u.sp) ? u.sp : 0, skillActive: !!u.skillActive });
// HP ratio and SP only: a skill still running at the end is not carried (unitsEnd `skillActive` stays unused —
// community report #34, GitHub #82)
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp });
}
// 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18)
const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0;
+18 -4
View File
@@ -124,6 +124,7 @@ export class Battle {
this.finished = false;
this.reason = null;
this.started = false;
this._startDeploying = false;
/** @type {Unit[]} every unit ever created */
this.units = [];
/** @type {Unit[]} alive enemies (compacted each tick) */
@@ -360,11 +361,15 @@ export class Battle {
// a summon piece flagged `deferDeploy` by content (one its owner's loadout does not make — 赫默 on S1 with a drone
// piece —, or a skill's summon when shared/constants.js SKILL_SUMMON_START_DEPLOY is off: content/tokens.js
// dockSkillSummons) stays off the field, its tile reserved (isReservedTile), until content deploys it
// (_startDeploying: a board piece content brings in during this deployment — a tactician's 流形 / 狼群 comes with
// its owner's deploy — takes its 联防 carry on that first deployment too)
this._startDeploying = true;
for (const kind of ['op', 'token']) {
const per = lists.map((l) => l.filter((u) => u.kind === kind && !u.deferDeploy));
const n = Math.max(0, ...per.map((l) => l.length));
for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]);
}
this._startDeploying = false;
// 仇恨 (targeting.js sortAllyTargets: the later deployed is attacked first): every summon that came in during the
// initial deployment — also one an operator's deploy brought along (a tactician's 援军, a start-of-battle summon)
// — ranks after all the operators (of every player on the field [ASSUMED]), in the order it came
@@ -539,10 +544,14 @@ export class Battle {
for (const ps of this.players) {
const pp = this._perPlayer[ps.playerId];
pp.perfect = !pp.leaked.some((l) => l.counted !== false);
pp.unitsEnd = ps.units.filter((u) => u.kind === 'op').map((u) => ({
// the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's
// SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该
// 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge).
// `skillActive` is reported, never carried.
pp.unitsEnd = ps.units.filter((u) => u.kind === 'op' || (u.kind === 'token' && u.uid != null)).map((u) => ({
uid: u.uid, id: u.id, defId: u.defId,
hpPct: u.alive ? Math.max(0, Math.min(1, u.hp / u.s.maxHp)) : 0,
sp: u.skill && !u.skill.noSkill ? Math.round(u.skill.sp * 100) / 100 : 0,
sp: u.skill && !u.skill.noSkill ? Math.round(u.skill.spTotal * 100) / 100 : 0,
skillActive: !!(u.skill && u.skill.active && u.skill.kind !== 'passive'),
alive: !!u.alive,
}));
@@ -883,8 +892,10 @@ export class Battle {
u.ground = this.grid.isLow(R0, C0) && !this._elevated?.has(k);
u.markDirty();
u.hp = u.s.maxHp;
const cs = initial ? (carry ?? u.carry) : null;
if (cs && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct)));
// 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"):
// an operator's HP ratio here, the SP in skill.reset and again after the `deploy` hook (below) — a summon's SP only
const cs = initial || (first && this._startDeploying) ? (carry ?? u.carry) : null;
if (cs && u.kind === 'op' && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct)));
this._occ[k] = u;
this._refreshRange(u); // also rebuilds baseRangeKeys (initial range incl. permanent rangeExtend)
if (!u.skill) this._setupUnit(u);
@@ -900,6 +911,9 @@ export class Battle {
if (first) this._ev(['spawn', unitInfo(u)]);
this._ev(['deploy', u.id]);
if (this._hooks.deploy) this.emit('deploy', { unit: u, initial });
// 联防: "部署完成后,将对应单位的…技力修改至与上一阶段结束时相同" — the carried SP is set again once the deployment is
// done, so a deploy-time SP gift (独行, 黄沙罗盘 …) does not come on top of it; later redeploys keep those gifts
if (cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp);
return true;
}
+25 -7
View File
@@ -431,24 +431,42 @@ export function install(battle) {
});
}
// 不屈
// 不屈 (PRTS 卫戍协议:盟约 下半/PRTS盟约记录, its 修正: "地面干员被击倒、撤退、切换<替身>与<本体>时,有(18+0.4×层数)%概率立刻
// 重新部署"; "※“立刻重新部署”的实现方式为:令受益者下次部署的再部署时间和费用归零"; owner's decision 2026-10-04 to follow it).
// Rolls on a knock-out ('killed') and on a 撤退 ('retreat' — 史尔特尔's 余烬, 耀骑士临光 S2, 伊内丝 S3 … — and 行商's
// 'merchant' withdrawal): a hit redeploys it at once, free, where it lies (engine rest tile, §22.15). Not on the 突袭
// retreat ('raid': it redeploys at once and free anyway — the zeroed next deployment would change nothing [ASSUMED: no
// roll]) nor on the 联防 forced exit (FORCED_EXIT: the battle's setup, not a 撤退 — §19.3). A 傀儡师 switch (hook
// `dollSwap`, to the 替身 and back) rolls too; she stays on the field, so a hit only zeroes her NEXT deployment: the
// next time she leaves the field (knocked out, withdrawn) she is back at once and free (`u.mem.indomFreeDeploy`)
// [ASSUMED: one such deployment at a time, spent by her next deployment whatever brings it]. Tier 2 (+sp SP to every
// operator on the field) stays a knock-out effect: its own line ("地面干员被击倒时使场上所有干员技力+5") was not corrected.
if (has(ID.indom)) {
const INDOM_EXITS = new Set(['killed', 'retreat', 'merchant']);
const stOf = (u) => { const st = isGroundOp(u) ? byPid[u.ownerId] : null; return st && st.tiers[ID.indom] ? st : null; };
battle.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || !isGroundOp(u)) return;
const st = byPid[u.ownerId];
if (!st || !st.tiers[ID.indom]) return;
const bb = st.bb[ID.indom];
if (st.tiers[ID.indom] >= 2) {
const banked = !!(u && u.mem && u.mem.indomFreeDeploy);
if (!INDOM_EXITS.has(c.reason) || !(stOf(u) || (banked && u.kind === 'op'))) return;
const st = stOf(u);
const bb = st ? st.bb[ID.indom] : null;
if (st && c.reason === 'killed' && st.tiers[ID.indom] >= 2) {
const sp = num(bb.sp);
if (sp > 0) for (const o of st.ops) if (onField(o) && o.skill) o.skill.gainSp(sp, 'bond');
}
if (u.alive || u.removed || u.mem[ID.indom] === battle.time) return;
if (battle.rng() < prob(bb, L(battle, st, ID.indom))) {
if (banked || (st && battle.rng() < prob(bb, L(battle, st, ID.indom)))) {
u.mem[ID.indom] = battle.time;
if (battle.redeploy(u, { free: true })) fxOn(battle, 'revive', u, 'bond:indomShip', 'redeploy');
}
}, { priority: 10 });
battle.on('dollSwap', ({ unit: u }) => {
const st = stOf(u);
if (!st || !u.alive || u.mem.indomFreeDeploy) return;
if (battle.rng() < prob(st.bb[ID.indom], L(battle, st, ID.indom))) u.mem.indomFreeDeploy = true;
});
// the zeroed deployment is the next one, whatever brings it (this redeploy, the 阿戈尔 revive, the 突袭 jump …)
battle.on('deploy', ({ unit: u }) => { if (u && u.mem && u.mem.indomFreeDeploy) u.mem.indomFreeDeploy = false; });
}
// 坚守 (tier 2) redirect + thorns, 奥术 vulnerability
+18 -3
View File
@@ -40,6 +40,7 @@ import { mitigate } from '../../damage.js';
import { spawnYanyou } from '../tokens.js';
import { kjeragColdWind } from '../devices.js';
import * as items from '../items.js';
import { FORCED_EXIT } from '../../constants.js';
const num = S.num;
@@ -343,6 +344,8 @@ function installLaterano(battle, pid, bb, members) {
* cancelled, also when it is back at once (the 5-tier 立刻复活, 不屈's 立刻重新部署, 埃芒加德 / M3茧甲): PRTS 盟约记录 "目标首次被
* 击倒后解除自身被付与但还未触发的【吞噬】效果". A marker off the field gives no further mark (the rule since 0.1.0; one knocked
* out and back in the same pass still gives its marks).
* 联防: the operators down since the end of their own combat (forced out by Battle.start) mark, are marked and resolve
* their marks like standing ones, but nothing resolves on them (below; per players' reports, owner's decision 2026-10-04).
* Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields).
* Tokens / devices / empty tiles are never devoured.
*/
@@ -352,12 +355,22 @@ function devour(battle, pid, bb, members) {
// first — row 0 is the BOTTOM row (DESIGN §3), so the top of the board is the highest row index. The order is a
// board position, independent of the members' directions (only "身前" follows each member's `dir`).
const boardCol = (u) => (u.player && u.player.mirror ? -u.tileC : u.tileC);
const order = members.filter(S.onField).sort((a, b) =>
// 联防: an operator down at the end of its own combat (carryState.down — Battle.start forced it out right before
// battleStart, FORCED_EXIT) takes part in the devour as if it stood on its tile, then stays out: it marks in its turn,
// it is "the unit in front" of another (the chain goes on through it when it is a member), its base ATK / block count
// for its marker and its own marks resolve (their 物理流失 lands, credited to it as usual); only the marks ON it resolve
// nothing — it is forced out, so it is never knocked out again, revived or devoured. Per players' reports (community
// report #3, GitHub #33 item 3), owner's decision 2026-10-04; until 0.1.2 the forced exit came first and the chain broke.
const downAtStart = (u) => S.isOp(u) && !u.alive && u.removeReason === FORCED_EXIT && !!u.carry && u.carry.down === true
&& battle.isDown(u);
const order = members.filter((u) => S.onField(u) || downAtStart(u)).sort((a, b) =>
boardCol(a) - boardCol(b) || b.tileR - a.tileR || a.id - b.id);
const opAt = (u) => {
const [r, c] = S.frontTile(u);
const a = S.allyAt(battle, r, c, pid);
return a && S.isOp(a) && a.alive ? a : null;
if (a) return S.isOp(a) && a.alive ? a : null;
const d = battle.downOn(r, c);
return d && d.ownerId === pid && downAtStart(d) ? d : null;
};
const markedBy = new Map(); // marker → [targets]
const marks = [];
@@ -392,7 +405,9 @@ function devour(battle, pid, bb, members) {
for (const [, t] of marks) if (!dep.has(t)) dep.set(t, items.deploymentOf(t));
const knocked = (t) => !t.alive || items.deploymentOf(t) !== dep.get(t);
for (const [m, t] of marks) {
if (knocked(t) || !m.alive) continue;
// (a member down since its own combat — downAtStart — resolves its marks as if it stood; a mark on it resolves nothing:
// `knocked` — it is off the field)
if (knocked(t) || !(m.alive || downAtStart(m))) continue;
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
if (!layered.has(t)) {
+4
View File
@@ -149,6 +149,7 @@ const installDollkeeper = (battle, unit) => {
unit.markDirty();
unit.hp = unit.s.maxHp;
battle.fx('swap', at({ form: null }));
if (battle.hasHook('dollSwap')) battle.emit('dollSwap', { unit, form: null });
};
const enter = () => {
if (unit.trait.doll || !unit.alive || !unit.deployed) return false;
@@ -168,6 +169,9 @@ const installDollkeeper = (battle, unit) => {
unit.hp = unit.s.maxHp;
// `dur`: until the switch back (the client times the 替身's closing clip with it)
battle.fx('substitute', at({ form: 'doll', dur }));
// `dollSwap` { unit, form }: a switch started — to the 替身 ('doll') or back to the 本体 (null); not fired when she is
// knocked out as the 替身 (不屈 rolls on both switches: PRTS 盟约记录 "切换<替身>与<本体>时")
if (battle.hasHook('dollSwap')) battle.emit('dollSwap', { unit, form: 'doll' });
return true;
};
battle.on('fatal', (ctx) => {
+37 -9
View File
@@ -1,8 +1,9 @@
// 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).
// 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
@@ -161,7 +162,34 @@ export class SkillRuntime {
return this.battle._safe(() => fn(this._ctx(extra)), `skill.${fnName}`, this.unit);
}
/** Called on every (re)deployment. `carry` = { sp, skillActive } for unite helpers. */
/**
* 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;
@@ -181,9 +209,6 @@ export class SkillRuntime {
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() {
@@ -262,11 +287,14 @@ export class SkillRuntime {
} 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) {
// 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()) {
@@ -396,7 +424,7 @@ export class SkillRuntime {
this.activations++;
this.lastStart = this.battle.time;
const b = this.battle;
if (this.manual && reason !== 'carry') this.opReadyAt = b.time + AUTO_OP_COOLDOWN;
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);