diff --git a/server/sim/content/kits/ops/op-haruka.js b/server/sim/content/kits/ops/op-haruka.js index 7caaaea0..80f72beb 100644 --- a/server/sim/content/kits/ops/op-haruka.js +++ b/server/sim/content/kits/ops/op-haruka.js @@ -47,7 +47,7 @@ // invulnerable / untargetable one) and while it stays levitated takes atk_scale × her ATK arts damage per second (the // first after 0.5 s, then every `interval` s; 持续伤害, not dodgeable; 无来源 at her last ATK once she left). -import { num, up, traitBb, talentBb, skillRec, batMod, giveSp } from '../shared/tier1.js'; +import { num, up, traitBb, talentBb, skillRec, batMod, giveSp, protectMods } from '../shared/tier1.js'; import { absoluteRangeKeys } from '../../../targeting.js'; import { COLS } from '../../../constants.js'; @@ -58,7 +58,6 @@ const S3 = 'skchr_haruka_3'; export const BUBBLE = 'haruka:bubble'; /** The S3 levitation damage (haruka_s_3_debuff). */ export const FLOAT_DOT = 'haruka:float'; -const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); /** 浮光泡影: the break delay when the talent carries no `interval`. */ const BREAK_DELAY = 0.5; /** S3's levitation damage: the first tick (haruka_s_3_debuff firstTriggerInterval). */ diff --git a/server/sim/content/kits/ops/op-judge.js b/server/sim/content/kits/ops/op-judge.js index f615ce74..3a5e10bf 100644 --- a/server/sim/content/kits/ops/op-judge.js +++ b/server/sim/content/kits/ops/op-judge.js @@ -47,7 +47,7 @@ // - S3 披荆斩棘 (MANUAL, hurt SP; data DEFAULT, rawRule TAKE_DAMAGE): at once barrier +hp_ratio × max HP (topping T1's, // capped), then `duration` s ATK +atk, attack interval +base_attack_time s (a flat +0.9 on 1.6 s), taunt +taunt_level. -import { num, traitBb, talentBb, skillRec, toggleBuff, batMod, up, enemiesInGrid } from '../shared/tier1.js'; +import { num, traitBb, talentBb, skillRec, toggleBuff, batMod, up, enemiesInGrid, holdProtect, PROTECT } from '../shared/tier1.js'; import { isHpLoss } from '../../../damage.js'; const S1 = 'skchr_judge_1'; @@ -60,9 +60,6 @@ const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, /** judge_s_2[aoe]: firstTriggerInterval 0.9 s, then triggerInterval 1 s. */ const S2_FIRST = 0.9; const S2_EVERY = 1; -/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest). */ -const PROTECT = 'protect'; -const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; const tagged = (d, tag) => !!(d && Array.isArray(d.tags) && d.tags.includes(tag)); @@ -79,7 +76,7 @@ export default { /** S2's 庇护 (damage_resistance[inf] for the skill's time). */ const protect = (battle, unit, skill) => { const dr = num(b2.damage_resistance); - if (dr > 0) battle.applyStrongest(unit, PROTECT, { duration: Math.max(0.05, skill.timeLeft), value: dr, mods: protectMods, source: unit }); + holdProtect(battle, unit, dr, Math.max(0.05, skill.timeLeft), unit); // 庇护: the shared effect (同名效果取最高) }; /** judge_t_1's shield_scale: 1, + S2's shield_scale while S2 runs. */ const shieldScale = (unit) => 1 + (unit.skill?.active && unit.skill.id === S2 ? num(b2.shield_scale) : 0); diff --git a/server/sim/content/kits/ops/op-nasti.js b/server/sim/content/kits/ops/op-nasti.js index 89cfc9db..ca876805 100644 --- a/server/sim/content/kits/ops/op-nasti.js +++ b/server/sim/content/kits/ops/op-nasti.js @@ -71,7 +71,7 @@ // [ASSUMED] (beyond op-ironmn.js's): the 监工专员 loses its 1 HP per pulse with or without an operator in range (dec_blood is a // passive buff of the ability); the combined range one step deep; the 小工程师's 1 s heal / SP timer runs from its deployment. -import { num, talentBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js'; +import { num, talentBb, traitBb, skillRec, up, giveSp, holdProtect } from '../shared/tier1.js'; import { absoluteRangeKeys } from '../../../targeting.js'; import { COLS } from '../../../constants.js'; import { frontOf } from '../../../dir.js'; @@ -86,9 +86,6 @@ export const NSTBLD = 'token_10061_nasti_nstbld'; const DEVICES = new Set([NSTDEF, NSTCHR, NSTBLD]); /** A device's 1-1 when its def carries none. */ const FRONT1 = Object.freeze([[0, 0], [0, 1]]); -/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest; op-judge.js, op-zumama.js). */ -const PROTECT = 'protect'; -const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); /** 注意安全's 庇护 refresh: a little longer than the check period, so it never lapses while it holds. */ const T2_PERIOD = 0.25; const PROTECT_HOLD = T2_PERIOD + 0.05; @@ -402,13 +399,13 @@ export default { if (!up(unit)) { triggered = false; timers.clear(); return; } if (!triggered) triggered = battle.enemies.some((e) => e.alive && !e.hidden && RANGED_WAYS.has(String(e.def?.applyWay ?? '').toUpperCase())); if (!triggered) { - if (resBase > 0) battle.applyStrongest(unit, PROTECT, { duration: PROTECT_HOLD, value: resBase, mods: protectMods, source: unit }); + holdProtect(battle, unit, resBase, PROTECT_HOLD, unit); // 庇护: the shared effect (同名效果取最高) return; } const who = [unit, ...battle.allyUnits.filter((a) => a !== unit && up(a) && !a.hidden && a.kind === 'op' && RANGED_POS.has(String(a.def?.position ?? '').toUpperCase()))]; for (const a of [...timers.keys()]) if (!who.includes(a)) timers.delete(a); for (const a of who) { - if (resPlus > 0) battle.applyStrongest(a, PROTECT, { duration: PROTECT_HOLD, value: resPlus, mods: protectMods, source: unit }); + holdProtect(battle, a, resPlus, PROTECT_HOLD, unit); if (!(spEvery > 0)) continue; if (!timers.has(a)) { timers.set(a, battle.time + spEvery); continue; } if (battle.time + 1e-9 < timers.get(a)) continue; diff --git a/server/sim/content/kits/ops/op-shu.js b/server/sim/content/kits/ops/op-shu.js index e72179ae..4f9350c5 100644 --- a/server/sim/content/kits/ops/op-shu.js +++ b/server/sim/content/kits/ops/op-shu.js @@ -54,7 +54,7 @@ // 单位时释放技能"), which tools/build-data.mjs (TRIGGER_ALLY_RULES) writes as the engine's SKILL_RANGE + `allies` on the // skill range x-2 (an injured, healable allied unit there, herself included; 古米 S1's option). -import { num, traitBb, skillRec, batMod, up, giveSp } from '../shared/tier1.js'; +import { num, traitBb, skillRec, batMod, up, giveSp, holdProtect } from '../shared/tier1.js'; import { COLS } from '../../../constants.js'; import { effectiveProfile, acquireTargets, performAttack } from '../../../ai.js'; @@ -80,9 +80,6 @@ const PROF_NEEDED = 3; const SUI_NEEDED = 4; /** 【岁】: character_table groupId 'sui' (data/backups.json diy.operators powers carry it for the 自选 picks). */ const SUI = new Set(['char_2014_nian', 'char_2015_dusk', 'char_2023_ling', 'char_2024_chyue', 'char_2025_shu', 'char_2026_yu', 'char_2027_wang']); -/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest). */ -const PROTECT = 'protect'; -const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); const FARM = 'talent:shu:farm'; const HARVEST = 'skill:shu:harvest'; @@ -122,7 +119,7 @@ export default { if (hp > 0 && !(cur && cur.source !== unit && (cur.data?.v ?? 0) > hp && cur.timeLeft > 0.05)) { battle.addBuff(a, { key: FARM, duration: FARM_HOLD, mods: { hpRegen: hp }, source: unit, data: { v: hp }, tags: ['talent'] }); } - if (dr > 0) battle.applyStrongest(a, PROTECT, { duration: FARM_HOLD, value: dr, mods: protectMods, source: unit }); + holdProtect(battle, a, dr, FARM_HOLD, unit); // 庇护: the shared effect (同名效果取最高) } }; /** Sow the tiles of `grid` around (r, c) — only tiles that can be deployed on or walked. Returns the new ones. */ diff --git a/server/sim/content/kits/ops/op-slent2.js b/server/sim/content/kits/ops/op-slent2.js index bcc4ab9b..56efaac8 100644 --- a/server/sim/content/kits/ops/op-slent2.js +++ b/server/sim/content/kits/ops/op-slent2.js @@ -59,7 +59,7 @@ // held like the hammer's (PRIO_UNDYING_HELD −99). // Statuses: none applied to enemies. Damage: arts normal attacks only. -import { num, up, traitBb, moduleBb, talentBb, skillRec, once } from '../shared/tier1.js'; +import { num, up, traitBb, moduleBb, talentBb, skillRec, once, holdProtect } from '../shared/tier1.js'; import { releaseSkillSummon } from '../../tokens.js'; import { holdsUndying, PRIO_UNDYING_HELD, PRIO_REVIVE, PRIO_RESPAWN } from '../../items/battle.js'; import { absoluteRangeKeys } from '../../../targeting.js'; @@ -69,9 +69,6 @@ const S1 = 'skchr_slent2_1'; const S2 = 'skchr_slent2_2'; const S3 = 'skchr_slent2_3'; export const LAMP = 'token_10029_slent2_protrb'; -/** 庇护 (ba.protect, the client's damage_resistance[inf]): one "同名效果取最高" effect per unit (battle.applyStrongest). */ -export const PROTECT = 'protect'; -const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); /** The client's trigger period of slent2_t_1 / slent2_t_2 (talent abilities, triggerInterval 0.1). */ export const TALENT_IV = 0.1; /** Lifetime of a refreshed effect: a little longer than the refresh period, so it never lapses while it holds. */ @@ -136,7 +133,7 @@ function lampKit(t, owner, t1) { const keys = new Set(absoluteRangeKeys(grid, lamp.tileR, lamp.tileC, lamp.dir, 0)); for (const a of alliesOn(battle, lamp, keys)) { const v = shelterOf(tb, a, scale); - if (v > 0) battle.applyStrongest(a, PROTECT, { duration: HOLD, value: v, mods: protectMods, source: lamp }); + holdProtect(battle, a, v, HOLD, lamp); // 庇护 (the client's damage_resistance[inf]): the shared effect } }, { owner: lamp, immediate: true }); }, @@ -194,7 +191,7 @@ export default { const now = new Set(allies); for (const a of allies) { const v = shelterOf(t1, a, scale); - if (v > 0) battle.applyStrongest(a, PROTECT, { duration: HOLD, value: v, mods: protectMods, source: unit }); + holdProtect(battle, a, v, HOLD, unit); const mult = isRhine(a) ? 2 : 1; if (maxHp > 0) battle.applyStrongest(a, MAXHP_KEY, { duration: HOLD, value: maxHp * mult, mods: (x) => ({ hpPct: x }), source: unit }); if (regenRatio > 0 && a.hpRatio < regenBelow - 1e-9) { diff --git a/server/sim/content/kits/ops/op-zumama.js b/server/sim/content/kits/ops/op-zumama.js index 8d955841..4e01ad4b 100644 --- a/server/sim/content/kits/ops/op-zumama.js +++ b/server/sim/content/kits/ops/op-zumama.js @@ -42,7 +42,7 @@ // she is stunned `stun` s (zumama_s_3 ON_BUFF_FINISH 'stun'; a stunned operator blocks nobody). // - Melee physical, ground-only (data canHitFly false), block 1, range 1-1; ground enemies target her (no 起飞 / 迷彩). -import { num, traitBb, skillRec, toggleBuff, batMod, up } from '../shared/tier1.js'; +import { num, traitBb, skillRec, toggleBuff, batMod, up, holdProtect } from '../shared/tier1.js'; const S1 = 'skchr_zumama_1'; const S2 = 'skchr_zumama_2'; @@ -51,9 +51,6 @@ const S3 = 'skchr_zumama_3'; const T1_PERIOD = 0.1; /** 庇护 refresh: a little longer than the check period, so it never lapses while it holds. */ const PROTECT_HOLD = T1_PERIOD + 0.05; -/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest). */ -const PROTECT = 'protect'; -const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); /** S2's blockee stun, renewed every tick while it holds (three ticks long). */ const STUN_PULSE = 0.1; const VALOR = 'talent:zumama:valor'; @@ -101,7 +98,7 @@ export default { const has = unit.findBuff(VALOR); if (high && !has && scale !== 1) battle.addBuff(unit, { key: VALOR, mods: { atkScaleMul: scale }, tags: ['talent'] }); else if (!high && has) battle.removeBuff(unit, VALOR); - if (up(unit) && !high && dr > 0) battle.applyStrongest(unit, PROTECT, { duration: PROTECT_HOLD, value: dr, mods: protectMods, source: unit }); + if (up(unit) && !high) holdProtect(battle, unit, dr, PROTECT_HOLD, unit); // 庇护: the shared effect }; battle.every(T1_PERIOD, check, { owner: unit, immediate: true }); battle.on('deploy', (ctx) => { if (ctx.unit === unit) check(); }, { owner: unit }); diff --git a/test/content/feedback5-protect.test.js b/test/content/feedback5-protect.test.js index 949c8cee..3631c2f6 100644 --- a/test/content/feedback5-protect.test.js +++ b/test/content/feedback5-protect.test.js @@ -7,7 +7,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; import { PROTECT, holdProtect } from '../../server/sim/content/kits/shared/tier1.js'; import { getDefaultSource } from '../../server/sim/simdata.js'; @@ -79,3 +79,14 @@ test('余 (blocking), 缪尔赛思\'s melee copies, 赫拉格 / 左乐 (SBL-X, b assert.equal(u.findBuff(PROTECT), null, 'gone above 50 %'); done(h); }); + +test('0.2.0 WV: no kit keeps its own copy of the shared 庇护 key or mods — 淬羽赫默, 黍, 斥罪, 森蚺, 娜斯提 hold it through holdProtect, 遥\'s bubbles share only the mods', () => { + const dir = new URL('../../server/sim/content/kits/ops/', import.meta.url); + for (const f of readdirSync(dir).filter((x) => x.endsWith('.js'))) { + const s = readFileSync(new URL(f, dir), 'utf8'); + assert.doesNotMatch(s, /const (PROTECT|protectMods) =|applyStrongest\([^)]*['"]protect['"]|applyStrongest\(\w+, PROTECT\b/, `${f}: the shared tier1.js PROTECT / protectMods / holdProtect`); + } + for (const f of ['op-slent2.js', 'op-shu.js', 'op-judge.js', 'op-zumama.js', 'op-nasti.js']) { + assert.match(readFileSync(new URL(f, dir), 'utf8'), /holdProtect\(/, `${f} uses holdProtect`); + } +});