diff --git a/docs/SIM.md b/docs/SIM.md index 67b576a8..1f61825f 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -509,12 +509,14 @@ per source — DoTs and slows do that on purpose; a damage-taken / DEF / RES mod ## 3. Buffs, mods, statuses `battle.addBuff(unit, { key, duration=Infinity, refresh='replace'|'extend'|'stack'|'independent'|'keep', stacks, maxStacks, -mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, shieldHits, persist, visible, data, allowDead })` +mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, shieldHits, shieldType, persist, visible, data, allowDead })` - `replace`: new instance replaces the old; `extend`: keep the longer remaining time, take the new mods; `stack`: +stacks up to maxStacks, timer reset; `independent`: separate timers, at most `maxStacks` alive (oldest dropped); `keep`: ignore. - Additive mods scale with stacks (`value × stacks`), `*Mul` mods multiply (`value ^ stacks`). - `onTick({battle, unit, buff, dt})` every tick, or every `interval` s. `persist: true` survives death/redeploy. -- `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated. +- `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated; + `shieldType` ('phys' | 'arts' | 'true' | 'elemental') = a 屏障 that absorbs that damage type only (夜莺 S2 法术护盾 "能吸收… + 法术伤害"); none = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Shields are spent oldest first. - `visible: true` emits `['status', id, key, 1/0]` client events. `battle.removeBuff(unit, key|buff)`. **Mod keys** — additive: `atkFlat atkPct defFlat defPct hpFlat hpPct resFlat aspd batPct blockCnt rangeExtend @@ -678,7 +680,7 @@ PRTS 异常效果: 无法选择 effects "仅在选择时生效"); checked before with `ceil(final) ≥ BOSS_HIT_LIMIT` (300000, shared/constants.js) is cancelled whole: returns 0 before shields (阿利斯泰尔's `boss:vest` barrier stays untouched; a `hit`-step block such as 假想敌:再生's aura acts earlier) [ASSUMED order], no HP / pool loss, no credit or stats, no `dmg` event, no `damaged` / `fatal` / kill; an fx `hitCap` `{ id, n }` marks it and -draws nothing; research 11) → shields → HP loss +draws nothing; research 11) → shields (a typed one — buff `shieldType` — only for its damage type) → HP loss (boss units: routed to `sharedBoss.damage(playerId, amount)`; a pool left under 1 HP is emptied) → if HP ≤ 0: **`fatal`** (`ctx.prevented = true` keeps the unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`. @@ -1141,7 +1143,9 @@ S3 未照耀的荣光 — its CUSTOM_RANGE trigger also counts flyers). A stun / 3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃; droneBomb 5 = 暴鸰's bomb, the official projectile_bombd); melee/`none` hits are instant, and so are `'beam'` hits (a 锁定攻击范围 AoE without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`, -`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays +`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `skipEnemy(e)` (an enemy the unit never +selects — its attacks, the enemies it blocks and its skill-trigger targets: targeting.js canTargetEnemy; 嵯峨 "不攻击重伤 +单位"), `boomerang` (the projectile stays `'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (applied after every override: sets `allInRange` and, on a ranged profile, the instant `'beam'`) (see the header of professions.js). diff --git a/server/sim/buffs.js b/server/sim/buffs.js index b69b6189..abc890d3 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -155,6 +155,9 @@ export function makeBuff(b) { tags: b.tags ?? [], shield: b.shield ?? (b.mods && b.mods.shield) ?? 0, shieldHits: b.shieldHits ?? 0, + // a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental'; null = + // every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") — 夜莺 S2 法术护盾 "能吸收…法术伤害" + shieldType: b.shieldType ?? null, persist: !!b.persist, status: b.status ?? null, visible: b.visible ?? false, diff --git a/server/sim/damage.js b/server/sim/damage.js index 66d64343..bc4251a1 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -5,7 +5,8 @@ // → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none) // → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul // → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled) -// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook +// → shields (hit-negating barriers first, then HP shields; a typed one — buff `shieldType` — only its damage type) +// → HP loss (boss pool routing) → 'damaged' hook // → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill. // Damage-dealt stats (the source's `stats.dmg`, the player's `damageDealt`) count only HP removed from the other side: // self and friendly damage (a 源石溶剂 drain, an operator's own 流失) is the target's `taken` and keeps the kill credit. @@ -180,14 +181,19 @@ export function leaderHitCancelled(battle, target, amount) { return true; } -/** Absorb damage with shields on `target`. Returns the remaining amount. */ -export function absorbShields(battle, target, amount) { +/** + * Absorb damage with shields on `target`. Returns the remaining amount. `type` = the damage type: a shield buff with a + * `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害"); one without absorbs every type (PRTS 术语释义 屏障 + * "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order: "优先消耗先生成的屏障"). + */ +export function absorbShields(battle, target, amount, type = null) { if (amount <= 0) return 0; let changed = false; let rest = amount; + const absorbs = (b) => !b.shieldType || b.shieldType === type; for (let i = 0; i < target.buffs.length && rest > 0; i++) { const b = target.buffs[i]; - if (b.shieldHits > 0) { + if (b.shieldHits > 0 && absorbs(b)) { b.shieldHits--; rest = 0; if (b.shieldHits <= 0 && !(b.shield > 0) && !b.mods && !b.flags) { battle._removeBuffAt(target, i); i--; } @@ -197,7 +203,7 @@ export function absorbShields(battle, target, amount) { } for (let i = 0; i < target.buffs.length && rest > 0; i++) { const b = target.buffs[i]; - if (b.shield > 0) { + if (b.shield > 0 && absorbs(b)) { const take = Math.min(b.shield, rest); b.shield -= take; rest -= take; @@ -247,7 +253,7 @@ export function dealDamage(battle, source, target, dmgIn) { // recognise their own (tagged) damage — never re-create such a loss with a fresh loseHp. if (ts.flags.hitCount || ts.flags.hitCountArts) { const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys'); - return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg); + return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0, type), dmg); } // 无来源 damage (element bursts) takes nothing from its source's stats; the source still gets the credit below const ss = source && source.s && !dmg.sourceless ? source.s : null; @@ -267,7 +273,7 @@ export function dealDamage(battle, source, target, dmgIn) { // 限伤: a leader's hit of ≥ BOSS_HIT_LIMIT in a boss / hidden battle is cancelled before it reaches shields / HP — what // ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens if (final > 0 && leaderHitCancelled(battle, target, final)) return 0; - final = absorbShields(battle, target, final); + final = absorbShields(battle, target, final, type); return applyHpLoss(battle, source, target, final, dmg); } diff --git a/server/sim/professions.js b/server/sim/professions.js index b06355e2..2337df4c 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -21,6 +21,7 @@ // noHeal bool (cannot be healed by others) blockFly bool onHitStatus {key, duration, value} // dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y}) // canAttack(battle, unit) → bool afterAttack(battle, unit, targets) +// skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位") // hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup // dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait) // tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites) diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 5d2ebcf9..c714f86f 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -74,7 +74,11 @@ export function enemyStealthed(e) { return false; } -/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */ +/** + * Can `attacker` (ally) target enemy `e` at all (ignoring range)? A profile's own exclusion `skipEnemy(e)` (a kit trait: + * 嵯峨 劝善 "嵯峨不攻击重伤单位") keeps such an enemy out of the unit's normal targets, the enemies it blocks + * (blockedTargets) and the target condition of its skill triggers (they pass `unit.profile`). + */ export function canTargetEnemy(attacker, e, profile) { if (!e.alive || e.hidden || !e.deployed) return false; const f = e.s.flags; @@ -82,6 +86,7 @@ export function canTargetEnemy(attacker, e, profile) { if (f.stealth && enemyStealthed(e)) return false; if (e.isFlying && !(profile && profile.canHitFly)) return false; if (profile && profile.groundOnly && e.isFlying) return false; + if (profile && typeof profile.skipEnemy === 'function' && profile.skipEnemy(e)) return false; return true; } diff --git a/test/sim/feedback5-o8-engine.test.js b/test/sim/feedback5-o8-engine.test.js new file mode 100644 index 00000000..edf13208 --- /dev/null +++ b/test/sim/feedback5-o8-engine.test.js @@ -0,0 +1,84 @@ +// test/sim/feedback5-o8-engine.test.js — two additive engine rules the 0.2.0 自选 kits of 夜莺 and 嵯峨 need: +// - a 屏障 of one damage type (buff `shieldType`, damage.js absorbShields): 夜莺 S2 法术护盾 "能吸收…法术伤害"; a shield +// without a type absorbs every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"); older shields first; +// - a profile's own target exclusion (`skipEnemy(e)`, targeting.js canTargetEnemy): 嵯峨 "不攻击重伤单位" — no normal +// attack on such an enemy, not as a block target, and the DEFAULT trigger condition ignores it. +// Run: node --test test/sim/feedback5-o8-engine.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js'; +import { canTargetEnemy } from '../../server/sim/targeting.js'; + +const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) }; +const CHESS = { test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 10000, atk: 300, def: 0, res: 0 }, skill: { duration: 5, spCost: 1, initSp: 0, trigger: { rule: 'DEFAULT' } } }) }; + +function field(kits = undefined) { + const h = makeBattle({ + defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, kits, + hooks: ['damaged', 'skillStart'], captureNoisy: true, + units: [{ uid: 1, chessId: 'test_guard_a', row: 10, col: 5 }], + }); + h.step(); + return { h, u: h.unit(1) }; +} + +test('typed shield: an arts 屏障 absorbs arts damage only; an untyped one every type; the older shield is spent first', () => { + const { h, u } = field(); + h.b.addBuff(u, { key: 'test:artsBarrier', shield: 100, shieldType: 'arts', duration: 30 }); + for (const type of ['phys', 'true', 'elemental']) { + const hp = u.hp; + h.b.dealDamage(null, u, { amount: 10, type, canDodge: false }); + assert.equal(u.hp, hp - 10, `${type} passes the arts barrier`); + } + assert.equal(u.findBuff('test:artsBarrier').shield, 100); + assert.equal(u.s.shield, 100, 'it counts in the shield bar'); + let hp = u.hp; + h.b.dealDamage(null, u, { amount: 40, type: 'arts', canDodge: false }); + assert.deepEqual([u.hp, u.findBuff('test:artsBarrier').shield], [hp, 60], 'arts absorbed'); + // an untyped shield added later: arts goes to the older (typed) one first, physical to the untyped one + h.b.addBuff(u, { key: 'test:barrier', shield: 50, duration: 30 }); + h.b.dealDamage(null, u, { amount: 30, type: 'arts', canDodge: false }); + assert.deepEqual([u.findBuff('test:artsBarrier').shield, u.findBuff('test:barrier').shield], [30, 50], 'older first'); + h.b.dealDamage(null, u, { amount: 20, type: 'phys', canDodge: false }); + assert.deepEqual([u.findBuff('test:artsBarrier').shield, u.findBuff('test:barrier').shield], [30, 30], 'physical: the untyped one only'); + hp = u.hp; + h.b.dealDamage(null, u, { amount: 100, type: 'arts', canDodge: false }); + assert.equal(u.findBuff('test:artsBarrier'), null, 'spent and removed'); + assert.equal(u.findBuff('test:barrier'), null, 'the rest on the untyped one, spent too'); + assert.equal(u.hp, hp - 40, '100 − 30 − 30 reaches HP'); + // a typed hit-negating barrier + h.b.addBuff(u, { key: 'test:physBlock', shieldHits: 1, shieldType: 'phys', duration: 30 }); + hp = u.hp; + h.b.dealDamage(null, u, { amount: 25, type: 'arts', canDodge: false }); + assert.equal(u.hp, hp - 25, 'arts passes a physical block'); + h.b.dealDamage(null, u, { amount: 25, type: 'phys', canDodge: false }); + assert.equal(u.hp, hp - 25, 'the physical hit is negated'); + assert.equal(u.findBuff('test:physBlock'), null); + checkInvariants(h.b); +}); + +test('profile skipEnemy: the unit never selects such an enemy — no attack, no block target, no DEFAULT trigger — and other enemies stay its targets', () => { + const skip = new Set(); + const { h, u } = field({ test_guard_a: () => ({ skill: { kind: 'duration' }, trait: { skipEnemy: (e) => skip.has(e) } }) }); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + skip.add(e); + assert.equal(canTargetEnemy(u, e, u.profile), false); + assert.equal(canTargetEnemy(u, e, { canHitFly: false }), true, 'another profile still may'); + h.run(3); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0, 'never attacked'); + assert.equal(u.skill.activations, 0, 'DEFAULT: no target'); + // blocked: still not a target + const w = h.spawn('enemy_dummy', { pos: [10, 5] }); + skip.add(w); + h.run(1); + assert.ok(u.blocking.includes(w), 'she blocks it'); + assert.equal(h.b.blockedTargets(u, u.profile).length, 0, 'not a block target'); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0); + // an ordinary enemy is attacked + const o = h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.target === o), 3), 'attacks the other one'); + assert.ok(h.hooksOf('damaged').filter((c) => c.source === u).every((c) => c.target === o), 'only that one'); + assert.ok(u.skill.activations >= 1, 'DEFAULT fires for it'); + checkInvariants(h.b); +});