feedback5 O8: two additive engine rules for the 自选 kits — a 屏障 of one damage type and a profile's own target exclusion

- buffs.js makeBuff / damage.js absorbShields: a shield buff with `shieldType` ('phys' | 'arts' | 'true' | 'elemental')
  absorbs that damage type only (夜莺 S2 法术护盾 "屏障能吸收…法术伤害"); a shield without a type absorbs every type as
  before (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"); shields are still spent oldest first.
- targeting.js canTargetEnemy: a profile's `skipEnemy(e)` (a kit trait) keeps such an enemy out of the unit's normal
  targets, its block targets and its skill-trigger target condition (嵯峨 劝善 "嵯峨不攻击重伤单位").
- docs/SIM.md §3 / §4 / §8, professions.js profile field list.
No content uses either yet: golden unchanged (roster 49, bonds 46, fields 22, matches 17, standins 10, diy 6 match).
Test: test/sim/feedback5-o8-engine.test.js (2).
This commit is contained in:
sganggs committed 2026-10-05 23:55:13 +08:00
1 parent 742f478526
commit db7a177164
6 files changed
+115 -12

No files matched your search

+84
View File
@@ -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);
});