feedback5 O16: two additive engine rules for the 自选 kits of 机械师 and 凯尔希 — a 屏障 limited to a list of damage types, a healer that heals one ally through its 禁疗
- buffs.js makeBuff / damage.js absorbShields: `shieldType` may be a list of damage types — the shield absorbs those only (机械师's 屏障 absorb physical and arts damage, not true: buff_template_data mcnist_t_2 / mcnist_s_2_shield BlockDamage PHYSICAL_AND_MAGICAL); a single type and an untyped shield work as before. - damage.js heal / Battle.injuredAlliesInKeys: a healer profile's `healThrough(healer, ally)` names allies its heals reach through their 禁疗 (the `noHeal` flag of a summon's 禁疗, the `healFree` flag; a profile `noHeal` — 无法被友方治疗 — still stops it), and its heal selection takes them (凯尔希 on her Mon3tr: PRTS Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希… 无视)"; her charpack attack `_ignoreHealFree`); every other healer still skips them. - docs/SIM.md §3 / §4 / §8, professions.js profile field list. No content uses either yet: golden unchanged. Test: test/sim/feedback5-o16-engine.test.js (2).
This commit is contained in:
1 parent
fef4ea3d4b
commit
5888f153ac
6 files changed
+101
-12
No files matched your search
+6
-3
@@ -516,7 +516,8 @@ mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield,
|
||||
- `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;
|
||||
`shieldType` ('phys' | 'arts' | 'true' | 'elemental') = a 屏障 that absorbs that damage type only (夜莺 S2 法术护盾 "能吸收…
|
||||
法术伤害"); none = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Shields are spent oldest first.
|
||||
法术伤害"), or a list of them = those types only (机械师's 屏障 `['phys', 'arts']`: BlockDamage PHYSICAL_AND_MAGICAL — true
|
||||
damage passes); 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
|
||||
@@ -688,7 +689,9 @@ unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `d
|
||||
|
||||
`battle.heal(source, target, amount, { overheal=false, self, silent, regen, ignoreHealFree })`: no-op on `noHeal` targets
|
||||
(unless self — 禁疗 / 孤立 summons carry the flag, §3) and on `healFree` ones, self included (史尔特尔's 余烬), unless `regen`
|
||||
(an HP-regen attribute tick) or `ignoreHealFree` (a heal that "无视禁疗");
|
||||
(an HP-regen attribute tick) or `ignoreHealFree` (a heal that "无视禁疗"); a healer whose profile names the target in
|
||||
`healThrough(healer, target)` heals it through the `noHeal` flag and `healFree` (not a profile's `noHeal`), and its heal
|
||||
selection (`injuredAlliesInKeys`) takes it — 凯尔希 on her Mon3tr (PRTS "持有禁疗(可被凯尔希…无视)");
|
||||
× source `healingDealtMul` × target `healingTakenMul`; **`heal`** hook (mutable amount); capped at max HP; a `regen` tick
|
||||
— the unit's own 生命回复速度 (`s.hpRegen`, applied in the buffs phase) — is no 治疗 (PRTS 调香师 / 瑕光 / 铃兰 / 锡人 备注
|
||||
"不受治疗加成和禁疗影响"): no multiplier, and the hook sees it (`opts.regen`) but cannot change its amount. Effects PRTS
|
||||
@@ -1151,7 +1154,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡
|
||||
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait
|
||||
"优先攻击重量最重的敌人", the highest current 重量等级 first: 早露 / 提丰), `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
|
||||
单位"), `healThrough(healer, ally)` (a healer that selects and heals that ally through its 禁疗 — §4; 凯尔希's Mon3tr), `boomerang` (the projectile stays
|
||||
`'boomerang'` whatever the data's generic ranged projectile says), `boomerangOnward(ctx)` (a boomerang's flight after its
|
||||
first hit belongs to content: ctx `hit(target, x, y)` / `comeBack(x, y)` — 娜仁图亚 S1's bounces, S2's dash; the loopshooter
|
||||
row), `rangeAoe` (applied after every override: sets `allInRange` and, on a ranged profile, the instant `'beam'`) (see the
|
||||
|
||||
@@ -54,15 +54,20 @@ export class BattleQueries {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. */
|
||||
/**
|
||||
* Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. A `noHeal` (禁疗) ally
|
||||
* is left out unless the healer's profile heals it through its 禁疗 (`healThrough(healer, ally)`: 凯尔希's Mon3tr —
|
||||
* damage.js heal).
|
||||
*/
|
||||
injuredAlliesInKeys(keys, healer, includeElement = false) {
|
||||
const out = [];
|
||||
if (!keys) return out;
|
||||
const set = keys instanceof Set ? keys : (healer && healer.rangeKeys === keys && healer.rangeKeySet ? healer.rangeKeySet : new Set(keys));
|
||||
const through = healer && healer.profile && typeof healer.profile.healThrough === 'function' ? healer.profile.healThrough : null;
|
||||
for (const a of this.allyUnits) {
|
||||
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') continue;
|
||||
if (!set.has(a.tileR * COLS + a.tileC)) continue;
|
||||
if (a !== healer && (a.s.flags.noHeal || (a.profile && a.profile.noHeal))) continue;
|
||||
if (a !== healer && ((a.s.flags.noHeal && !(through && through(healer, a))) || (a.profile && a.profile.noHeal))) continue;
|
||||
const injured = a.hp < a.s.maxHp - 1e-6;
|
||||
const elem = includeElement && (a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion) > 0;
|
||||
if (injured || elem) out.push(a);
|
||||
|
||||
+3
-2
@@ -155,8 +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 法术护盾 "能吸收…法术伤害"
|
||||
// a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental', or a list
|
||||
// of them (机械师's 屏障: ['phys', 'arts']); null = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") —
|
||||
// 夜莺 S2 法术护盾 "能吸收…法术伤害"
|
||||
shieldType: b.shieldType ?? null,
|
||||
persist: !!b.persist,
|
||||
status: b.status ?? null,
|
||||
|
||||
+11
-5
@@ -184,14 +184,16 @@ export function leaderHitCancelled(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: "优先消耗先生成的屏障").
|
||||
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害") — or, a list of types, only those (机械师's 屏障:
|
||||
* BlockDamage PHYSICAL_AND_MAGICAL, buff_template_data mcnist_t_2 / mcnist_s_2_shield: ['phys', 'arts']); 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;
|
||||
const absorbs = (b) => !b.shieldType || (Array.isArray(b.shieldType) ? b.shieldType.includes(type) : b.shieldType === type);
|
||||
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
|
||||
const b = target.buffs[i];
|
||||
if (b.shieldHits > 0 && absorbs(b)) {
|
||||
@@ -526,13 +528,17 @@ export function reduceElement(target, amount, el = null) {
|
||||
* with an hpRegen buff, PRTS 备注 "不受治疗加成和禁疗影响") is no 治疗: no 治疗加成 scales it — neither the healing
|
||||
* multipliers nor a `heal` handler (handlers still see it, with `opts.regen`; a change they make to its amount is
|
||||
* ignored).
|
||||
* A healer whose profile names the target in `healThrough(healer, target)` heals it through its 禁疗 — the `noHeal` flag
|
||||
* a summon's 禁疗 sets and the flag `healFree` (not a profile's `noHeal`, 无法被友方治疗): 凯尔希 on her Mon3tr (PRTS
|
||||
* Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…无视)"; her heal selection takes it too, Battle.injuredAlliesInKeys).
|
||||
*/
|
||||
export function heal(battle, source, target, amount, opts = {}) {
|
||||
if (!target || !target.alive || target.removed || !target.deployed || target.bossPool) return 0;
|
||||
const self = source === target || !!opts.self;
|
||||
if (!self && (target.s.flags.noHeal || (target.profile && target.profile.noHeal))) return 0;
|
||||
const through = !!(source && source.profile && typeof source.profile.healThrough === 'function' && source.profile.healThrough(source, target));
|
||||
if (!self && ((target.s.flags.noHeal && !through) || (target.profile && target.profile.noHeal))) return 0;
|
||||
const regen = !!opts.regen;
|
||||
if (target.s.flags.healFree && !regen && !opts.ignoreHealFree) return 0;
|
||||
if (target.s.flags.healFree && !regen && !opts.ignoreHealFree && !through) return 0;
|
||||
let amt = regen ? amount : amount * (source && source.s ? source.s.healingDealtMul : 1) * target.s.healingTakenMul;
|
||||
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
|
||||
if (battle._hooks.heal) {
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
// 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; 嵯峨 "不攻击重伤单位")
|
||||
// healThrough(healer, ally) → bool (a healer that selects and heals that ally through its 禁疗 — Battle
|
||||
// injuredAlliesInKeys, damage.js heal; 凯尔希 on her Mon3tr)
|
||||
// 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)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// test/sim/feedback5-o16-engine.test.js — two additive engine rules the 0.2.0 自选 kits of 机械师 and 凯尔希 need:
|
||||
// - a 屏障 limited to several damage types (buff `shieldType` as a list, damage.js absorbShields): 机械师's 屏障 absorb
|
||||
// physical and arts damage only (buff_template_data mcnist_t_2 / mcnist_s_2_shield: BlockDamage PHYSICAL_AND_MAGICAL);
|
||||
// - a healer that heals an ally through its 禁疗 (profile `healThrough(healer, ally)`, damage.js heal and
|
||||
// Battle.injuredAlliesInKeys): 凯尔希 on her Mon3tr (PRTS Mon3tr(凯尔希的召唤物) "持有禁疗(可被凯尔希…无视)") — her heal
|
||||
// selection takes it and her heal reaches it; any other healer still skips it, a profile's `noHeal` (无法被友方治疗)
|
||||
// still stops her.
|
||||
// Run: node --test test/sim/feedback5-o16-engine.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.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 } }),
|
||||
test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProf: 'physician', stats: { maxHp: 2000, atk: 100, def: 0, res: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [-1, 0], [-1, 1]] }),
|
||||
};
|
||||
|
||||
test('a 屏障 of several types (shieldType [\'phys\', \'arts\']) absorbs those, not true damage; older shields first as ever', () => {
|
||||
const h = makeBattle({ defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, units: [{ uid: 1, chessId: 'test_guard_a', row: 10, col: 5 }] });
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
h.b.addBuff(u, { key: 'test:pa', shield: 100, shieldType: ['phys', 'arts'], duration: 30 });
|
||||
let hp = u.hp;
|
||||
for (const type of ['true', 'elemental']) {
|
||||
h.b.dealDamage(null, u, { amount: 10, type, canDodge: false });
|
||||
hp -= 10;
|
||||
assert.equal(u.hp, hp, `${type} passes it`);
|
||||
}
|
||||
assert.equal(u.findBuff('test:pa').shield, 100);
|
||||
h.b.dealDamage(null, u, { amount: 30, type: 'phys', canDodge: false });
|
||||
h.b.dealDamage(null, u, { amount: 30, type: 'arts', canDodge: false });
|
||||
assert.deepEqual([u.hp, u.findBuff('test:pa').shield], [hp, 40], 'physical and arts absorbed');
|
||||
h.b.dealDamage(null, u, { amount: 50, type: 'arts', canDodge: false });
|
||||
assert.deepEqual([u.hp, u.findBuff('test:pa')], [hp - 10, null], 'spent: the rest reaches HP');
|
||||
// a single type still works as before
|
||||
h.b.addBuff(u, { key: 'test:a', shield: 20, shieldType: 'arts', duration: 30 });
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(null, u, { amount: 10, type: 'phys', canDodge: false });
|
||||
assert.equal(u.hp, hp - 10);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('profile healThrough: the healer selects and heals the ally it names through its 禁疗 (noHeal / healFree); others do not, and a profile noHeal still stops it', () => {
|
||||
const marked = new Set();
|
||||
const kits = { test_medic_a: () => ({ trait: { healThrough: (healer, a) => marked.has(a) } }) };
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, kits, hooks: ['heal'], captureNoisy: true,
|
||||
units: [{ uid: 1, chessId: 'test_medic_a', row: 10, col: 3 }, { uid: 2, chessId: 'test_guard_a', row: 10, col: 4 }, { uid: 3, chessId: 'test_guard_a', row: 11, col: 4 }],
|
||||
});
|
||||
h.step();
|
||||
const medic = h.unit(1), a = h.unit(2), b = h.unit(3);
|
||||
h.b.addBuff(a, { key: 'test:healFree', flags: { noHeal: true }, persist: true });
|
||||
h.b.addBuff(b, { key: 'test:healFree', flags: { noHeal: true }, persist: true });
|
||||
a.hp = 1000; b.hp = 1000;
|
||||
assert.deepEqual(h.b.injuredAlliesInKeys(medic.rangeKeys, medic), [], '禁疗: no heal target');
|
||||
assert.equal(h.b.heal(medic, a, 100), 0, 'no heal');
|
||||
marked.add(a);
|
||||
assert.deepEqual(h.b.injuredAlliesInKeys(medic.rangeKeys, medic), [a], 'the named ally is a heal target');
|
||||
assert.equal(h.b.heal(medic, a, 100), 100, 'and is healed');
|
||||
assert.equal(h.b.heal(medic, b, 100), 0, 'the other 禁疗 ally is not');
|
||||
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === medic && c.target === a), 5), 'its attack heals it');
|
||||
assert.ok(!h.hooksOf('heal').some((c) => c.source === medic && c.target === b), 'never the other');
|
||||
// another unit's heal is still stopped; healFree too passes for the named one; a profile noHeal does not
|
||||
assert.equal(h.b.heal(b, a, 100), 0, 'another healer: 禁疗 holds');
|
||||
h.b.addBuff(a, { key: 'test:hf2', flags: { healFree: true }, persist: true });
|
||||
assert.equal(h.b.heal(medic, a, 50), 50, 'healFree passes for it');
|
||||
a.profile.noHeal = true;
|
||||
assert.equal(h.b.heal(medic, a, 50), 0, 'a profile noHeal (无法被友方治疗) stops it');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
Reference in new issue
Block a user