diff --git a/docs/SIM.md b/docs/SIM.md index 80b3e3ce..85886c4a 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -573,7 +573,7 @@ application competes with instead of its `value`, which stays its effect (Raidia catalogue status the same rule (one invisible buff `key` per target whatever applies it — no immunity, 抵抗, status hooks or icon): the engine default for two same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). 奥术 uses it, so the two players of a pair field compete for one instance instead of multiplying, and so do 灵知's 坚冰 and the -莱恩哈特 / 缄默德克萨斯 RES cuts, once keyed per unit (DESIGN §20.10). "Strongest" = the largest |value|. PRTS 盟约记录's +莱恩哈特 / 缄默德克萨斯 RES cuts, once keyed per unit (DESIGN §20.10). 庇护 (gamedata_const ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") is one such effect whoever grants it: every kit holds it under the shared key (`kits/shared/tier1.js` `PROTECT` / `holdProtect`, mods phys / artsTakenMul ×(1 − v)) — 宴, 余, 缪尔赛思, 赫拉格, 左乐 and 赫德雷 since 0.2.0 (they used private multipliers that multiplied with each other) —, except 遥's bubbles (the client's damage_resistance[bonus], which multiplies with the common damage_resistance[inf]). "Strongest" = the largest |value|. PRTS 盟约记录's 奥术 note "※同一单位仅可对同一目标同时施加1个该盟约法术伤害提升效果" limits each unit to one instance per target; read with the engine default — a newer same-named buff waits inert until the earlier ones end (PRTS 常见同名状态 "默认叠加方式") — and 巴哈姆特 12316 ("共享型buff會跟對面搶"), one instance is effective at a time, which is what applyStrongest keeps; strongest diff --git a/server/sim/content/kits/README.md b/server/sim/content/kits/README.md index 26455376..e0498cf3 100644 --- a/server/sim/content/kits/README.md +++ b/server/sim/content/kits/README.md @@ -319,7 +319,8 @@ Each item is a mistake this project already made once. Tick every one for every `test/match/feedback1-placement.test.js` "#9 data: 狼群 and 流形 are owner-range summons" (DESIGN §20.1, §21.3); `test/content/playtest6_summons.test.js`, `tokens_summons.test.js`; `feedback1b_kits.test.js` "B3 维娜·维多利亚 S3". - [ ] **9. Status effects** — every status applied, with its official duration and stacking (寒冷 on 寒冷 ⇒ 冻结 for the - longer cold, 沉默 removes abilities, 恐惧, 睡眠, 浮空 …; "同名效果取最高" = `applyStrongest`). Examples: + longer cold, 沉默 removes abilities, 恐惧, 睡眠, 浮空 …; "同名效果取最高" = `applyStrongest`; 庇护 = the shared + `PROTECT` key, `holdProtect` in `shared/tier1.js`). Examples: `test/sim/combat.test.js` "cold on cold ⇒ freeze for the longer cold …", "sleep: target is untargetable and inactive; fear stops enemy attacks", "statuses: sleep = invulnerable except `hitSleep` attackers; levitate …"; `enemies_bosses.test.js` "沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced". diff --git a/server/sim/content/kits/ops/chess_char_1_18-utage.js b/server/sim/content/kits/ops/chess_char_1_18-utage.js index 336daed5..8c2041f6 100644 --- a/server/sim/content/kits/ops/chess_char_1_18-utage.js +++ b/server/sim/content/kits/ops/chess_char_1_18-utage.js @@ -1,7 +1,7 @@ // server/sim/content/kits/ops/chess_char_1_18-utage.js — 宴 (char_337_utage) kit, tier 1. // Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md. -import { num, talentBb, up, onHitBy, onHitOn, skillBbOf } from '../shared/tier1.js'; +import { num, talentBb, up, onHitBy, onHitOn, skillBbOf, holdProtect, PROTECT_TICK_HOLD } from '../shared/tier1.js'; export default { // --------------------------------------------------------------------------------------------------------------- @@ -44,11 +44,14 @@ export default { battle.addBuff(unit, { key: 'utage:serious', mods: { aspd: v }, data: { v }, tags: ['talent'] }); }, { owner: unit }); } - // 庇护 (ba.protect): "受到的物理和法术伤害降低相应比例" — physical and arts damage only + // 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") while below hp_ratio HP: the shared 庇护 — the + // strongest of every source holds (holdProtect) —, refreshed every tick and at each hit on her (a hit that takes + // her below it: the next ones already have it) if (t.damage_resistance != null && t.hp_ratio != null) { - onHitOn(battle, unit, ({ dmg }) => { - if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < num(t.hp_ratio)) dmg.mul *= 1 - num(t.damage_resistance); - }); + const dr = num(t.damage_resistance), below = num(t.hp_ratio); + const keep = () => { if (up(unit) && unit.hpRatio < below) holdProtect(battle, unit, dr, PROTECT_TICK_HOLD, unit); }; + battle.on('tick', keep, { owner: unit }); + onHitOn(battle, unit, keep); } } }], }; diff --git a/server/sim/content/kits/ops/chess_char_6_03-yu.js b/server/sim/content/kits/ops/chess_char_6_03-yu.js index 6adb3907..8f1cfea3 100644 --- a/server/sim/content/kits/ops/chess_char_6_03-yu.js +++ b/server/sim/content/kits/ops/chess_char_6_03-yu.js @@ -6,6 +6,7 @@ import { num, bv, tbb, moduleBb, live, opsOf, ANY, onDefaultSkill, selectedSkill, skillGridOf, AROUND8, onElementHit, elementDmg, aura, } from '../shared/tier6.js'; +import { holdProtect } from '../shared/tier1.js'; /** * "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its @@ -89,9 +90,10 @@ function yu(bb, chess, def) { talents: [ { install(battle, unit) { // 礼尚往来: 庇护 while blocking + DoT on blocked enemies const dr = num(t0.damage_resistance), sc = bv(t0, 'atk_scale'), er = bv(t0, 'ep_damage_ratio'), iv = Math.max(0.1, bv(t0, 'interval', 1)); - // 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例 — true / element damage is not reduced + // 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例(同名效果取最高)— true / element damage is not reduced; the shared + // 庇护 (holdProtect: the strongest of every source holds) aura(battle, unit, 0.1, () => { - if (dr > 0 && unit.blocking.length) battle.addBuff(unit, { key: 'yu:shelter', mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr }, duration: 0.2, refresh: 'replace' }); + if (dr > 0 && unit.blocking.length) holdProtect(battle, unit, dr, 0.2, unit); }); aura(battle, unit, iv, () => { for (const e of unit.blocking.slice()) { diff --git a/server/sim/content/kits/ops/chess_char_6_11-mlyss.js b/server/sim/content/kits/ops/chess_char_6_11-mlyss.js index e6cf1b34..74860964 100644 --- a/server/sim/content/kits/ops/chess_char_6_11-mlyss.js +++ b/server/sim/content/kits/ops/chess_char_6_11-mlyss.js @@ -5,6 +5,7 @@ import { num, bv, tbb, tdesc, moduleBb, parseN, live, isTok, onDefaultSkill, selectedSkill, AROUND8, N4, bstate, bestTile, pullToward, } from '../shared/tier6.js'; +import { holdProtect } from '../shared/tier1.js'; /** 莱茵生命 (character_table groupId "rhine") members of this season's pool (checked against the official table). */ const RHINE = new Set(['char_108_silent', 'char_128_plosis', 'char_202_demkni', 'char_249_mlyss', 'char_1047_halo2']); @@ -139,7 +140,9 @@ function mlyss(bb, chess, def) { const ranged = !!(m && m.ranged); t.profile.hits = on && ranged && live(t) ? t.mem.mlyssHits * 2 : t.mem.mlyssHits; // 二连击 if (on && m && !ranged && live(t)) { - battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen, physTakenMul: 1 - dr, artsTakenMul: 1 - dr } }); + battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen } }); + // "获得15%的庇护": the shared 庇护 (holdProtect — 同名效果取最高 with every other source) + holdProtect(battle, t, dr, 0.4, unit); } } }, { owner: unit }); diff --git a/server/sim/content/kits/ops/op-helage.js b/server/sim/content/kits/ops/op-helage.js index 90012cb6..de4b175f 100644 --- a/server/sim/content/kits/ops/op-helage.js +++ b/server/sim/content/kits/ops/op-helage.js @@ -25,7 +25,7 @@ // parts at once]. // - Module SBL-X “藏锋” "生命值低于50%时,获得25%的庇护" (a display trait part; the effect is the hidden talent part hp_ratio / // damage_resistance, merged into talent 0's blackboard): below hp_ratio of his max HP the physical and arts damage he takes -// ×(1 − damage_resistance) (as 宴's elite module). +// ×(1 − damage_resistance) (as 宴's elite module) — 庇护, "同名效果取最高": the shared effect (tier1.js holdProtect). // - Module SBL-Y “热的雪” "被击倒时不撤退且回复30%生命(单次部署只触发1次)" (trait bb hp_ratio): the first lethal blow of a // deployment leaves him on the field at hp_ratio of his max HP (a kit saver, priority −50 as 斯卡蒂's module). // - S1 新月 (AUTO, attack SP, DEFAULT): the next attack at atk_scale × ATK, twice (Skill clip: two OnAttack). @@ -33,7 +33,7 @@ // - S3 满月 (MANUAL, time SP, data ACTIVE_RANGE on the running 1-1 + 1): `duration` s — ATK +atk, attack range +1 tile // forward (ability_range_forward_extend), up to attack@max_target targets at once; back to 1-1 after. -import { num, traitBb, skillRec, toggleBuff, up, onHitOn } from '../shared/tier1.js'; +import { num, traitBb, skillRec, toggleBuff, up, onHitOn, holdProtect, PROTECT_TICK_HOLD } from '../shared/tier1.js'; const S1 = 'skchr_helage_1'; const S2 = 'skchr_helage_2'; @@ -89,12 +89,13 @@ export default { } }, ], install(battle, unit) { - // SBL-X “藏锋”: 生命值低于50%时,获得25%的庇护 (physical and arts damage taken) + // SBL-X “藏锋”: 生命值低于50%时,获得25%的庇护 — the shared 庇护 (holdProtect: the strongest of every source holds), + // refreshed every tick and at each hit on him while he is below const dr = num(t0.damage_resistance), drBelow = num(t0.hp_ratio); if (dr > 0 && drBelow > 0) { - onHitOn(battle, unit, ({ dmg }) => { - if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < drBelow) dmg.mul *= 1 - dr; - }); + const keep = () => { if (up(unit) && unit.hpRatio < drBelow) holdProtect(battle, unit, dr, PROTECT_TICK_HOLD, unit); }; + battle.on('tick', keep, { owner: unit }); + onHitOn(battle, unit, keep); } // SBL-Y “热的雪”: 被击倒时不撤退且回复30%生命(单次部署只触发1次) const rise = num(tb.hp_ratio); diff --git a/server/sim/content/kits/ops/op-hodrer.js b/server/sim/content/kits/ops/op-hodrer.js index 2588e432..38a751e7 100644 --- a/server/sim/content/kits/ops/op-hodrer.js +++ b/server/sim/content/kits/ops/op-hodrer.js @@ -27,8 +27,9 @@ // - T2 余火之氅 "使自身与身后一格的友军获得18%的庇护" (damage_resistance, talent grid b-1: his tile and the one behind; CRU-X // stage 3 "28%…且造成的物理伤害提升10%" — hodrer_equip_1_3_p2 gives damage_resistance[inf] and hodrer_equip[damage_scale_up] // (ON_OUTPUT_DAMAGE PHYSICAL ×damage_scale) to the same targets, ally side, any motion, removed when they leave): 庇护 -// (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") = phys / artsTakenMul 1 − value as one instance per ally -// (applyStrongest) with physDealtMul ×damage_scale, renewed every PROTECT_IV s on the allies of the grid while he is deployed. +// (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") = phys / artsTakenMul 1 − value — the shared 庇护 of every source +// (tier1.js holdProtect: damage_resistance[inf] is the common key) — and physDealtMul ×damage_scale (its own one instance per +// ally), renewed every PROTECT_IV s on the allies of the grid while he is deployed. // - S1 重锋不熄 (AUTO, hit SP: INCREASE_WHEN_ATTACK, data DEFAULT): the next attack (every enemy he blocks) at atk_scale × ATK; // hodrer_s[heal] (ON_ABILITY_SPELL_ON, HealViaMaxHpRatio) heals hp_ratio of his max HP once for that attack. // - S2 余烬重荷 (MANUAL, data DEFAULT): "被动效果:攻击力+16%" (atk; the skill's extra ability hodrer_s_2[passive]) while it is @@ -44,7 +45,7 @@ // then on it takes attack@damage 无来源 true damage (NoSourceDamage PURE: damage.js periodicDamage, credited to him) every // second of its own (triggerInterval 1 s from its mark) until the skill ends (a derived buff of the skill's). -import { num, talentBb, talentGrid, moduleBb, traitBb, skillRec, statBuff, up, batMod, alliesInGridOf } from '../shared/tier1.js'; +import { num, talentBb, talentGrid, moduleBb, traitBb, skillRec, statBuff, up, batMod, alliesInGridOf, holdProtect } from '../shared/tier1.js'; import { periodicDamage } from '../../../damage.js'; const S1 = 'skchr_hodrer_1'; @@ -53,7 +54,8 @@ const S3 = 'skchr_hodrer_3'; /** 余火之氅's grid when the data carries none: his tile and the one behind him (range b-1). */ const BEHIND = Object.freeze([Object.freeze([0, -1]), Object.freeze([0, 0])]); const PROTECT_IV = 0.25; -const PROTECT_KEY = 'talent:hodrer:protect'; +/** CRU-X stage 3's 物理伤害 + (hodrer_equip[damage_scale_up]): one instance per ally (the 庇护 is the shared key). */ +const DMG_UP_KEY = 'talent:hodrer:dmgUp'; const S2_PASSIVE_KEY = 'skill:hodrer:s2passive'; /** hodrer_s_3[damage] / hodrer_s_3[burn]: triggerInterval 1 s, waitFirstTriggerInterval. */ const SMOKE_IV = 1; @@ -140,10 +142,12 @@ export default { const dr = num(t1.damage_resistance), ds = num(t1.damage_scale, 1); const grid = talentGrid(chess, 1) ?? BEHIND; if (!(dr > 0) && ds === 1) return; - const mods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v, ...(ds !== 1 ? { physDealtMul: ds } : {}) }); const give = () => { if (!up(unit)) return; - for (const a of alliesInGridOf(battle, unit, grid)) battle.applyStrongest(a, PROTECT_KEY, { duration: PROTECT_IV + 0.1, value: dr, mods, source: unit }); + for (const a of alliesInGridOf(battle, unit, grid)) { + holdProtect(battle, a, dr, PROTECT_IV + 0.1, unit); + if (ds !== 1) battle.applyStrongest(a, DMG_UP_KEY, { duration: PROTECT_IV + 0.1, value: ds, mods: (v) => ({ physDealtMul: v }), source: unit }); + } }; battle.every(PROTECT_IV, give, { owner: unit }); battle.on('battleStart', give, { owner: unit }); diff --git a/server/sim/content/kits/ops/op-zuole.js b/server/sim/content/kits/ops/op-zuole.js index 69e6fab8..5b851374 100644 --- a/server/sim/content/kits/ops/op-zuole.js +++ b/server/sim/content/kits/ops/op-zuole.js @@ -20,7 +20,8 @@ // (zuole_e_003_talent, ON_CALCULATE_DAMAGE): "…且当次攻击的攻击力提升至120%…80%": a success also scales that damage // instance by atk_scale. // - Module SBL-X “岂苦夜长” (trait moduleDesc "生命值低于50%时,获得25%的庇护"; the hidden part merged into the first talent's -// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例") while HP < hp_ratio +// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)": the shared effect of +// every source, tier1.js holdProtect) while HP < hp_ratio // (zuole_e_002[resistance]: the 庇护 buff is created when HP drops below it and finished when it is back). PRTS S2 备注: // 行险 resets it, it comes back 0.1 s later "并因此导致庇护的BUFF顺序落后于本技能的屏障" — a 庇护 created after his // barrier cuts only what passes the barrier (the `hpDamage` hook), one created before it cuts the hit first. @@ -46,7 +47,7 @@ // at max_scale × max HP at each gain, lasting shield_duration s from the latest gain (PRTS "每次获取屏障时重置剩余持续 // 时间为15s"). -import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec } from '../shared/tier1.js'; +import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec, holdProtect, PROTECT, PROTECT_TICK_HOLD } from '../shared/tier1.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { isHpLoss } from '../../../damage.js'; import { holdsUndying } from '../../items/battle.js'; @@ -128,9 +129,12 @@ export default { data: { createdAt: battle.time } }); } battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); - // SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier) + // SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier) — his own held 庇护 + // goes now (another source's comes back with its next refresh) P.since = null; P.blockedUntil = battle.time + PROTECT_RESET; + const held = unit.findBuff(PROTECT); + if (held && held.source === unit) battle.removeBuff(unit, held); }, }, [S3]: { @@ -202,8 +206,10 @@ export default { if (!unit.mem.zuoleS3 || c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return; gainS3Barrier(battle, unit); }, { owner: unit }); - // SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险 - if (protectCut > 0) { + // SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险 — the shared 庇护 (holdProtect: the + // strongest of every source holds), refreshed every tick and at each hit on him. When the 庇护 held is his own and his + // barrier is older than it, his cut comes after the barrier (the hpDamage hook) instead of before it + if (protectCut > 0 && protectCut < 1) { const active = () => { const now = battle.time; if (!up(unit) || now < P.blockedUntil - 1e-9) { P.since = null; return false; } @@ -211,13 +217,16 @@ export default { P.since = null; return false; }; + const keep = () => { if (active()) holdProtect(battle, unit, protectCut, PROTECT_TICK_HOLD, unit); }; + const own = () => { const b = unit.findBuff(PROTECT); return !!b && b.source === unit && b.data?.value === protectCut; }; const afterBarrier = new WeakSet(); - battle.on('tick', active, { owner: unit }); + battle.on('tick', keep, { owner: unit }); onHitOn(battle, unit, ({ dmg }) => { - if ((dmg.type !== 'phys' && dmg.type !== 'arts') || !active()) return; + keep(); + if ((dmg.type !== 'phys' && dmg.type !== 'arts') || P.since == null || !own()) return; const bar = barrierOf(unit); - if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) afterBarrier.add(dmg); - else dmg.mul *= 1 - protectCut; + // (the held buff's mods cut this hit before the barrier: undo that and cut what passes the barrier instead) + if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) { dmg.mul /= 1 - protectCut; afterBarrier.add(dmg); } }); battle.on('hpDamage', (c) => { if (c.target === unit && afterBarrier.has(c.dmg)) c.amount *= 1 - protectCut; }, { owner: unit }); } diff --git a/server/sim/content/kits/shared/tier1.js b/server/sim/content/kits/shared/tier1.js index e70fa915..8d4405fd 100644 --- a/server/sim/content/kits/shared/tier1.js +++ b/server/sim/content/kits/shared/tier1.js @@ -27,7 +27,7 @@ // counter {id} · crit {id} · dp {n, id} · heal {id} · taunt {id} · summon {id, token} · pull {id} · sonic {radius} · // shield {id} · overload {id} · takeoff {id} · sleep {id} · buff {id, kind} · reveal {id} · dodge (engine kind). -import { COLS } from '../../../constants.js'; +import { COLS, TICK } from '../../../constants.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; import { offsetTile } from '../../../dir.js'; import { bodyInKeys, bodyTileReach } from '../../../body.js'; @@ -94,6 +94,21 @@ export function onDamagedOn(battle, unit, fn, priority = 0) { return battle.on('damaged', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority }); } +/** + * 庇护 (gamedata_const ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)"; PRTS 术语释义 庇护): ONE effect per unit + * whoever grants it — battle.applyStrongest under this key, the strongest value holds and a weaker one resumes when it + * outlasts it. (遥's bubbles are a separate key, the client's damage_resistance[bonus] — they multiply with it.) + */ +export const PROTECT = 'protect'; +/** The mods of a 庇护 value: physical and arts damage taken ×(1 − v). */ +export const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); +/** A hold that outlives one tick and lapses in the next: refreshed every tick while its condition lasts. */ +export const PROTECT_TICK_HOLD = 1.5 * TICK; +/** Hold 庇护 `value` on `target` for `duration` s (the shared PROTECT effect). */ +export function holdProtect(battle, target, value, duration, source = null) { + if (value > 0) battle.applyStrongest(target, PROTECT, { duration, value, mods: protectMods, source }); +} + /** Targetable enemies inside `grid` (relative to the unit; null ⇒ current range), best targets first. */ export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canHitFly = true, groundOnly = false } = {}) { const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys; diff --git a/test/content/feedback5-protect.test.js b/test/content/feedback5-protect.test.js new file mode 100644 index 00000000..949c8cee --- /dev/null +++ b/test/content/feedback5-protect.test.js @@ -0,0 +1,81 @@ +// test/content/feedback5-protect.test.js — 庇护 is one effect per unit whoever grants it (gamedata_const ba.protect / PRTS 术语 +// 庇护 "受到的物理和法术伤害降低相应比例(同名效果取最高)"): the older kits — 宴, 余, 缪尔赛思 (chess), 赫拉格, 左乐, 赫德雷 (自选) — +// hold it through the shared key the other 自选 kits use (kits/shared/tier1.js PROTECT / holdProtect → battle.applyStrongest), +// so the strongest value applies instead of a product of every source. 遥's bubbles keep their own key (the client's +// damage_resistance[bonus], which multiplies with the common damage_resistance[inf]). +// Run: node --test test/content/feedback5-protect.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } 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'; + +const ds = getDefaultSource(); +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); }; + +/** 宴 (elite: 庇护 below hp_ratio) at 20 % HP with nothing to hit her. */ +function utage() { + const h = makeBattle({ units: [{ chessId: 'chess_char_1_18_b', row: 9, col: 5 }], autoFinish: false, timeLimit: 60, seed: 3 }); + h.step(); + const u = h.unit('chess_char_1_18_b'); + u.hp = u.s.maxHp * 0.2; + h.step(); + return { h, u, dr: ds.rawChess('chess_char_1_18_b').talents.filter((t) => t.index !== -1)[0].bb.damage_resistance }; +} +/** Physical damage she takes from a 1000-point hit (her DEF taken off). */ +const physTaken = (h, u) => { const hp = u.hp; h.b.dealDamage(null, u, { amount: 1000, type: 'phys', canDodge: false }); const d = hp - u.hp; u.hp = hp; return d; }; + +test('宴 below her threshold holds the shared 庇护; a stronger one from another source replaces it (no product), a weaker one waits under it', () => { + const { h, u, dr } = utage(); + assert.ok(dr > 0 && dr < 0.5); + const b = u.findBuff(PROTECT); + assert.ok(b && b.source === u && b.data.value === dr, 'her own 庇护, on the shared key'); + const base = 1000 - u.s.def; + approx(physTaken(h, u), base * (1 - dr), 'hers alone'); + // a stronger 庇护 from another source: the highest counts + holdProtect(h.b, u, 0.5, 10, { id: 'other' }); + h.step(); + approx(physTaken(h, u), base * (1 - 0.5), 'the stronger one only — not ×(1 − dr) as well'); + assert.equal(u.buffs.filter((x) => x.key === PROTECT).length, 1, 'one 庇护 effect'); + done(h); + // a weaker one: hers holds + const w = utage(); + holdProtect(w.h.b, w.u, 0.1, 10, { id: 'other' }); + w.h.step(); + approx(physTaken(w.h, w.u), (1000 - w.u.s.def) * (1 - w.dr), 'hers — the weaker 庇护 waits'); + // back above the threshold her own lapses within a tick; the weaker one resumes for its remaining time + w.u.hp = w.u.s.maxHp; + w.h.step(3); + const after = w.u.findBuff(PROTECT); + assert.ok(after && after.data.value === 0.1, 'the weaker 庇护 resumed'); + done(w.h); +}); + +test('余 (blocking), 缪尔赛思\'s melee copies, 赫拉格 / 左乐 (SBL-X, below 50 %) and 赫德雷 (余火之氅) all hold 庇护 on the shared key', () => { + const src = (f) => readFileSync(new URL(`../../server/sim/content/kits/ops/${f}`, import.meta.url), 'utf8'); + for (const f of ['chess_char_1_18-utage.js', 'chess_char_6_03-yu.js', 'chess_char_6_11-mlyss.js', 'op-helage.js', 'op-zuole.js', 'op-hodrer.js']) { + assert.match(src(f), /holdProtect\(/, `${f} uses the shared 庇护`); + assert.doesNotMatch(src(f), /physTakenMul: 1 - dr|dmg\.mul \*= 1 - (dr|protectCut|num\(t\.damage_resistance\))/, `${f}: no private 庇护 multiplier left`); + } + // 遥's bubble stays its own effect (damage_resistance[bonus]) + assert.doesNotMatch(src('op-haruka.js'), /holdProtect\(/); + // 赫拉格 (SBL-X, tier 5) at 40 % HP: his 庇护 on the shared key + const h = makeBattle({ + defs: { enemies: { e: enemyRec({ key: 'e', hp: 1e9, speed: 0 }) } }, + units: [{ diy: { slot: 5, charId: 'char_188_helage', skillIndex: 0, uniEquipId: 'uniequip_002_helage' }, elite: true, row: 10, col: 5 }], + autoFinish: false, timeLimit: 30, seed: 3, + }); + h.step(); + const u = h.b.allyUnits.find((x) => x.def?.charId === 'char_188_helage'); + u.hp = u.s.maxHp * 0.4; + h.step(); + const b = u.findBuff(PROTECT); + assert.ok(b && b.source === u && b.data.value === 0.25, 'his SBL-X 庇护'); + u.hp = u.s.maxHp; + h.step(3); + assert.equal(u.findBuff(PROTECT), null, 'gone above 50 %'); + done(h); +}); diff --git a/test/content/kits_alt_t6.test.js b/test/content/kits_alt_t6.test.js index aeac445a..fdf44379 100644 --- a/test/content/kits_alt_t6.test.js +++ b/test/content/kits_alt_t6.test.js @@ -708,7 +708,11 @@ test('6_11 缪尔赛思 S2 生态耦合: +cost DP; melee copies regenerate and t const eco = tok().findBuff('mlyss:eco'); assert.ok(eco, 'melee copy buffed'); approx(eco.mods.hpRegenRatio, bb.hp_recovery_per_sec_by_max_hp_ratio); - approx(eco.mods.physTakenMul, 1 - bb.damage_resistance); + // 庇护: the shared effect of every source (同名效果取最高 — kits/shared/tier1.js holdProtect) + const protect = tok().findBuff('protect'); + assert.ok(protect && protect.source === u, 'the 庇护 she grants'); + approx(protect.mods.physTakenMul, 1 - bb.damage_resistance); + approx(protect.mods.artsTakenMul, 1 - bb.damage_resistance); done(h); // ranged copy: a sniper to copy diff --git a/test/content/kits_t6.test.js b/test/content/kits_t6.test.js index b92b3b76..0d225237 100644 --- a/test/content/kits_t6.test.js +++ b/test/content/kits_t6.test.js @@ -281,8 +281,8 @@ test('余: blocked enemies take 40 % ATK arts + 12 % burn per second, 庇护 whi assert.ok(dot.length >= 2); const burn = h.dmg.filter((d) => d.src === u.id && d.element === 'burn'); assert.ok(burn.length >= 2); - // 庇护 (ba.protect): physical and arts damage only - assert.ok(u.findBuff('yu:shelter') && near(u.s.physTakenMul, 1 - t0.damage_resistance) && near(u.s.artsTakenMul, 1 - t0.damage_resistance)); + // 庇护 (ba.protect): physical and arts damage only — the shared effect of every source (kits/shared/tier1.js holdProtect) + assert.ok(u.findBuff('protect')?.source === u && near(u.s.physTakenMul, 1 - t0.damage_resistance) && near(u.s.artsTakenMul, 1 - t0.damage_resistance)); assert.equal(u.s.trueTakenMul, 1, '庇护 does not reduce true damage'); assert.equal(u.s.dmgTakenMul, 1); const base = u.base.maxHp; diff --git a/test/content/op_hodrer.test.js b/test/content/op_hodrer.test.js index 5348ea1d..ce60f9b9 100644 --- a/test/content/op_hodrer.test.js +++ b/test/content/op_hodrer.test.js @@ -20,7 +20,8 @@ const FORMS = BACKUPS.units[HOD].forms; const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; const CRUX = 'uniequip_002_hodrer', CRUY = 'uniequip_003_hodrer'; const S1 = 'skchr_hodrer_1', S2 = 'skchr_hodrer_2', S3 = 'skchr_hodrer_3'; -const PROTECT = 'talent:hodrer:protect'; +const PROTECT = 'protect'; // the shared 庇护 of every source (kits/shared/tier1.js holdProtect) +const DMG_UP = 'talent:hodrer:dmgUp'; // CRU-X stage 3's 物理伤害 + (its own one instance per ally) const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); @@ -173,7 +174,7 @@ test('T2 余火之氅: he and the ally on the tile behind him get 18 % 庇护 (C assert.ok(m, `${label(f)}: ${a.def.charId} protected`); approx(m.physTakenMul, 1 - dr, `${label(f)}: phys`); approx(m.artsTakenMul, 1 - dr, `${label(f)}: arts`); - assert.equal(m.physDealtMul ?? 1, x3 ? 1.1 : 1, `${label(f)}: 物理伤害`); + assert.equal(a.findBuff(DMG_UP)?.mods.physDealtMul ?? 1, x3 ? 1.1 : 1, `${label(f)}: 物理伤害`); } assert.equal(beside.findBuff(PROTECT), null, `${label(f)}: not beside him`); const hp = behind.hp;