fix: 让家族徽章在解除隐匿后的首笔伤害触发
This commit is contained in:
1 parent
1db8e51023
commit
a173a470b6
6 files changed
+210
-9
No files matched your search
@@ -1561,3 +1561,11 @@ yet — enemy content must pick one); 抵抗 covers the control statuses of `RES
|
|||||||
“余音” owns a same-call pulse queue (DESIGN §28.24): reflected hits enqueue their earned pulses instead of recursively
|
“余音” owns a same-call pulse queue (DESIGN §28.24): reflected hits enqueue their earned pulses instead of recursively
|
||||||
entering another pulse. This retains the lethal-hit pulse and prevents valid high hit-count chains from tripping the
|
entering another pulse. This retains the lethal-hit pulse and prevents valid high hit-count chains from tripping the
|
||||||
general hook guard. The queue is emptied in `finally`; it introduces no timer or simulation RNG.
|
general hook guard. The queue is emptied in `finally`; it introduces no timer or simulation RNG.
|
||||||
|
|
||||||
|
|
||||||
|
### Family Crest damage-event timing
|
||||||
|
|
||||||
|
`content/items/battle.js` consumes 家族徽章's accumulated ATK at the first positive damage event without the `item` tag after the
|
||||||
|
Siracusa carrier's effective 隐匿 ends, without waiting for the growth timer. Its 叙拉古正装 combo is claimed before
|
||||||
|
synchronous nested damage and uses the boosted ATK before the buff is removed. A later hit cannot consume it again.
|
||||||
|
Leaving the field clears the accumulation; 迷彩 does not count as 隐匿 (see §3). Source: `data/items.json`.
|
||||||
@@ -224,3 +224,11 @@ the hook-depth guard on scaled hit-count HP; the guard remains unchanged for oth
|
|||||||
as well as by its aura tick, including the first hit and immediately after the last companion leaves.
|
as well as by its aura tick, including the first hit and immediately after the last companion leaves.
|
||||||
|
|
||||||
Sources and the feedback items requiring no simulation change are recorded in [§28.25](../history/0.2.3.md#2825-community-feedback-of-2026-10-10).
|
Sources and the feedback items requiring no simulation change are recorded in [§28.25](../history/0.2.3.md#2825-community-feedback-of-2026-10-10).
|
||||||
|
|
||||||
|
|
||||||
|
**Family Crest (家族徽章).** On a Siracusa carrier, the accumulated ATK lasts through the first positive damage
|
||||||
|
instance after effective 隐匿 ends. At that damage event, the crest + 叙拉古正装 combo uses the accumulated ATK for its
|
||||||
|
true damage, then removes the bonus. The first hit does not wait for the next 0.25 s growth poll. Consumption is
|
||||||
|
claimed before nested combo damage, so a multi-hit/AOE attack consumes it once; tagged item procs do not consume it.
|
||||||
|
Source: `data/items.json` 家族徽章 `desc` / `descRaw` and the
|
||||||
|
`act2autochess_equip_acarm122_global_buff` blackboard in `buffs`; the existing proc exclusion is preserved.
|
||||||
@@ -479,3 +479,19 @@ imports and typecheck pass. This round changes no production UI and does not rer
|
|||||||
the `roster`, `bonds`, `fields`, `matches`, `standins` families are unchanged.
|
the `roster`, `bonds`, `fields`, `matches`, `standins` families are unchanged.
|
||||||
- **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md
|
- **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md
|
||||||
(local identity recovery and handshake).
|
(local identity recovery and handshake).
|
||||||
|
|
||||||
|
|
||||||
|
### 28.28 Family Crest consumes on the first damage after stealth — `content/items/battle.js`
|
||||||
|
|
||||||
|
- Cause: the growth poll armed consumption only after observing 隐匿 end. Damage between that transition and the
|
||||||
|
next 0.25 s poll therefore missed the combo and kept the accumulated ATK through a later hit.
|
||||||
|
- Source: `data/items.json` 家族徽章 normal/advanced descriptions and combo blackboard: accumulated ATK lasts until
|
||||||
|
the first damage after 隐匿 ends (or leaving), and 叙拉古正装 adds `atk_scale` true damage on that first instance.
|
||||||
|
- Read effective stealth at the damage event; claim once before nested proc damage, use the boosted ATK for the
|
||||||
|
combo and then remove it. Growth rate/cap, bond requirement, deployment/death clearing and proc exclusions stay.
|
||||||
|
No new [ASSUMED] rule; the existing tagged-proc exclusion is preserved.
|
||||||
|
- Regression: `test/content/family-crest-timing.test.js` covers stealth removal and natural expiry, ordinary and
|
||||||
|
advanced item combinations, the immediate first hit and a later hit, stealth re-entry and recharging, zero/proc
|
||||||
|
damage and other sources, synchronous damage re-entry, leaving/redeployment, and a real Provence kit against a
|
||||||
|
synthetic enemy. `test/content/items.test.js` no longer waits after stealth ends. These are model tests; they do
|
||||||
|
not cover reveal, blocked damage or AOE.
|
||||||
@@ -810,30 +810,30 @@ const BY_ITEM = {
|
|||||||
const partner = partnerOf(p);
|
const partner = partnerOf(p);
|
||||||
const key = S.key('stealth');
|
const key = S.key('stealth');
|
||||||
const per = num(p.atk_per_sec), max = num(p.max_atk, 1);
|
const per = num(p.atk_per_sec), max = num(p.max_atk, 1);
|
||||||
const st = { bonus: 0, wasStealth: false, armed: false, seq: -1 };
|
const st = { bonus: 0, seq: -1 };
|
||||||
const TICKS = 0.25;
|
const TICKS = 0.25;
|
||||||
S.every(TICKS, () => {
|
S.every(TICKS, () => {
|
||||||
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; st.armed = false; st.wasStealth = false; }
|
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; }
|
||||||
if (!onField(u) || !memberOf(battle, u, 'siracusaShip')) return;
|
if (!onField(u) || !memberOf(battle, u, 'siracusaShip')) return;
|
||||||
const stealth = !!u.s.flags.stealth;
|
const stealth = !!u.s.flags.stealth;
|
||||||
if (stealth && st.bonus < max) {
|
if (stealth && st.bonus < max) {
|
||||||
st.bonus = Math.min(max, st.bonus + per * TICKS);
|
st.bonus = Math.min(max, st.bonus + per * TICKS);
|
||||||
S.buff(u, { key, mods: directMods({ atk: st.bonus }), refresh: 'replace' });
|
S.buff(u, { key, mods: directMods({ atk: st.bonus }), refresh: 'replace' });
|
||||||
}
|
}
|
||||||
if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true;
|
|
||||||
st.wasStealth = stealth;
|
|
||||||
});
|
});
|
||||||
S.on('damaged', (c) => {
|
S.on('damaged', (c) => {
|
||||||
if (c.source !== u || !st.armed || isProc(c.dmg) || !(c.amount > 0)) return;
|
if (c.source !== u || isProc(c.dmg) || !(c.amount > 0) || st.bonus <= 0
|
||||||
st.armed = false;
|
|| u.deploySeq !== st.seq || !onField(u) || u.s.flags.stealth) return;
|
||||||
|
// Read stealth at the damage event: the first hit can land before the next 0.25 s growth poll.
|
||||||
|
// Claim the bonus before the combo's synchronous damage can re-enter this handler; keep its ATK for the combo.
|
||||||
|
st.bonus = 0;
|
||||||
if (partner && carries(battle, u, partner) && c.target && c.target.alive && c.target.side === 'enemy') {
|
if (partner && carries(battle, u, partner) && c.target && c.target.alive && c.target.side === 'enemy') {
|
||||||
battle.dealDamage(u, c.target, { amount: u.s.atk * num(p.atk_scale), type: 'true', canDodge: false, tags: procTags('crest') });
|
battle.dealDamage(u, c.target, { amount: u.s.atk * num(p.atk_scale), type: 'true', canDodge: false, tags: procTags('crest') });
|
||||||
battle.fx('strike', { x: c.target.x, y: c.target.y, id: c.target.id, src: 'item:chess_item_6_11_e', key: rec.id, from: u.id });
|
battle.fx('strike', { x: c.target.x, y: c.target.y, id: c.target.id, src: 'item:chess_item_6_11_e', key: rec.id, from: u.id });
|
||||||
}
|
}
|
||||||
st.bonus = 0;
|
|
||||||
battle.removeBuff(u, key);
|
battle.removeBuff(u, key);
|
||||||
});
|
});
|
||||||
S.on('death', (c) => { if (c.unit === u) { st.bonus = 0; st.armed = false; } });
|
S.on('death', (c) => { if (c.unit === u) st.bonus = 0; });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Family Crest timing through the real Battle damage/status pipeline. Synthetic fixtures below isolate boundaries;
|
||||||
|
// the Provence case uses real operator kits and natural attacks on a stationary synthetic enemy, not a UI match.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||||
|
|
||||||
|
const CREST = 'chess_item_6_11_e_a';
|
||||||
|
const SUIT = 'chess_item_3_01_e_a';
|
||||||
|
const KEY = `item:${CREST}:stealth`;
|
||||||
|
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-6, `${actual} ≠ ${expected}`);
|
||||||
|
const crestHits = (h) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes('item:crest'));
|
||||||
|
const hit = (h, amount = 100, extra = {}) => h.b.dealDamage(h.unit(1), h.enemy('enemy_dummy'), { amount, type: 'true', canDodge: false, ...extra });
|
||||||
|
|
||||||
|
function fixture(items = [CREST, SUIT], bonds = ['siracusaShip']) {
|
||||||
|
const h = makeBattle({
|
||||||
|
units: [{ uid: 1, chessId: 'test_crest', row: 10, col: 4, items }],
|
||||||
|
defs: {
|
||||||
|
chess: { test_crest: chessRec({ id: 'test_crest', bonds, rangeGrid: [[0, 0]], skill: null }) },
|
||||||
|
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) },
|
||||||
|
},
|
||||||
|
enemies: [{ key: 'dummy', pos: [10, 9] }], autoFinish: false, captureNoisy: true,
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
h.b.applyStatus(h.unit(1), 'stealth', { duration: 20 });
|
||||||
|
h.run(1);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Family Crest consumes the first damage immediately after stealth is removed, with either item quality', () => {
|
||||||
|
for (const crest of [CREST, CREST.replace('_a', '_b')]) for (const suit of [null, SUIT, SUIT.replace('_a', '_b')]) {
|
||||||
|
const h = fixture(suit ? [crest, suit] : [crest]);
|
||||||
|
const u = h.unit(1), atk = u.s.atk;
|
||||||
|
assert.ok(atk > u.base.atk);
|
||||||
|
h.b.removeBuff(u, 'stealth');
|
||||||
|
const hp = h.enemy('enemy_dummy').hp;
|
||||||
|
hit(h);
|
||||||
|
close(hp - h.enemy('enemy_dummy').hp, 100 + (suit ? atk * 8 : 0));
|
||||||
|
close(u.s.atk, u.base.atk);
|
||||||
|
assert.equal(crestHits(h).length, suit ? 1 : 0);
|
||||||
|
hit(h);
|
||||||
|
assert.equal(crestHits(h).length, suit ? 1 : 0, 'the second damage cannot reuse the charge');
|
||||||
|
checkInvariants(h.b);
|
||||||
|
assert.deepEqual(h.b.errors, []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Family Crest ignores damage while stealth is active, including after a polled exit and re-entry', () => {
|
||||||
|
const h = fixture(), u = h.unit(1);
|
||||||
|
const atk = u.s.atk;
|
||||||
|
hit(h);
|
||||||
|
close(u.s.atk, atk);
|
||||||
|
h.b.removeBuff(u, 'stealth');
|
||||||
|
h.run(0.3); // The old poll has armed the charge, but a new stealth period still protects it.
|
||||||
|
h.b.applyStatus(u, 'stealth', { duration: 20 });
|
||||||
|
hit(h);
|
||||||
|
assert.equal(crestHits(h).length, 0);
|
||||||
|
close(u.s.atk, atk);
|
||||||
|
h.b.removeBuff(u, 'stealth');
|
||||||
|
hit(h);
|
||||||
|
assert.equal(crestHits(h).length, 1);
|
||||||
|
close(u.s.atk, u.base.atk);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Family Crest does not re-arm the spent charge at the next poll, and can charge again under new stealth', () => {
|
||||||
|
const h = fixture(), u = h.unit(1);
|
||||||
|
h.b.removeBuff(u, 'stealth');
|
||||||
|
hit(h);
|
||||||
|
h.run(0.3);
|
||||||
|
hit(h);
|
||||||
|
assert.equal(crestHits(h).length, 1);
|
||||||
|
h.b.applyStatus(u, 'stealth', { duration: 20 });
|
||||||
|
h.run(0.3);
|
||||||
|
const atk = u.s.atk;
|
||||||
|
assert.ok(atk > u.base.atk);
|
||||||
|
h.b.removeBuff(u, 'stealth');
|
||||||
|
hit(h);
|
||||||
|
assert.equal(crestHits(h).length, 2);
|
||||||
|
close(crestHits(h)[1].amount, atk * 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Family Crest preserves the charge for zero damage, item proc damage and damage from another source', () => {
|
||||||
|
const h = fixture(), u = h.unit(1), e = h.enemy('enemy_dummy'), atk = u.s.atk;
|
||||||
|
h.b.removeBuff(u, 'stealth');
|
||||||
|
hit(h, 0);
|
||||||
|
hit(h, 100, { tags: ['item', 'item:probe'] });
|
||||||
|
h.b.dealDamage(null, e, { amount: 100, type: 'true' });
|
||||||
|
close(u.s.atk, atk);
|
||||||
|
assert.equal(crestHits(h).length, 0);
|
||||||
|
hit(h);
|
||||||
|
assert.equal(crestHits(h).length, 1);
|
||||||
|
close(crestHits(h)[0].amount, atk * 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Family Crest claims the charge before synchronous ordinary damage re-enters from its combo', () => {
|
||||||
|
const h = fixture(), u = h.unit(1), atk = u.s.atk;
|
||||||
|
h.b.removeBuff(u, 'stealth');
|
||||||
|
let nested = 0;
|
||||||
|
h.b.on('damaged', (c) => {
|
||||||
|
if (c.dmg?.tags?.includes('item:crest')) { nested++; hit(h, 7); }
|
||||||
|
}, { priority: 100 });
|
||||||
|
const hp = h.enemy('enemy_dummy').hp;
|
||||||
|
hit(h);
|
||||||
|
assert.equal(nested, 1);
|
||||||
|
close(hp - h.enemy('enemy_dummy').hp, 100 + atk * 8 + 7);
|
||||||
|
close(u.s.atk, u.base.atk);
|
||||||
|
assert.deepEqual(h.b.errors, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Family Crest discards the charge on leaving the field and does not grant it to a non-member', () => {
|
||||||
|
const h = fixture(), u = h.unit(1);
|
||||||
|
h.b.removeBuff(u, 'stealth');
|
||||||
|
h.b.retreat(u);
|
||||||
|
hit(h); // An already flying hit from the departed source must not spend an old charge.
|
||||||
|
assert.equal(crestHits(h).length, 0);
|
||||||
|
assert.equal(u.findBuff(KEY), null);
|
||||||
|
assert.ok(h.b.redeploy(u));
|
||||||
|
hit(h);
|
||||||
|
assert.equal(crestHits(h).length, 0);
|
||||||
|
close(u.s.atk, u.base.atk);
|
||||||
|
const other = fixture([CREST, SUIT], []);
|
||||||
|
close(other.unit(1).s.atk, other.unit(1).base.atk);
|
||||||
|
other.b.removeBuff(other.unit(1), 'stealth');
|
||||||
|
hit(other);
|
||||||
|
assert.equal(crestHits(other).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Family Crest + suit triggers on Provence\'s first natural attack after six-Siracusa stealth (2 layers)', () => {
|
||||||
|
const ordinary = [], combo = [], transitions = [];
|
||||||
|
let previous = false;
|
||||||
|
const h = makeBattle({
|
||||||
|
units: [
|
||||||
|
{ uid: 1, chessId: 'chess_char_1_07_a', row: 10, col: 4, items: [CREST, SUIT] },
|
||||||
|
{ uid: 2, chessId: 'chess_char_1_08_a', row: 9, col: 2 },
|
||||||
|
{ uid: 3, chessId: 'chess_char_2_16_a', row: 9, col: 3 },
|
||||||
|
{ uid: 4, chessId: 'chess_char_3_15_a', row: 10, col: 2 },
|
||||||
|
{ uid: 5, chessId: 'chess_char_3_18_a', row: 9, col: 4 },
|
||||||
|
{ uid: 6, chessId: 'chess_char_3_19_a', row: 9, col: 5 },
|
||||||
|
],
|
||||||
|
bonds: { siracusaShip: { count: 6, active: true, tier: 2, layers: 2 } },
|
||||||
|
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e8, atk: 0, speed: 0 }) } },
|
||||||
|
enemies: [{ key: 'dummy', pos: [10, 5] }], timeLimit: 45, autoFinish: false,
|
||||||
|
flags: { startOpCooldown: 3 },
|
||||||
|
setup(b) {
|
||||||
|
b.on('damaged', (c) => {
|
||||||
|
if (c.source?.uid !== 1 || !(c.amount > 0)) return;
|
||||||
|
const tags = c.dmg?.tags ?? [];
|
||||||
|
if (tags.includes('item:crest')) combo.push({ t: b.time, amount: c.amount });
|
||||||
|
else if (!tags.some((s) => s === 'item' || s === 'addition' || s.startsWith('bond:')) && !c.source.s.flags.stealth) {
|
||||||
|
ordinary.push({ t: b.time, atk: c.source.s.atk });
|
||||||
|
}
|
||||||
|
}, { priority: 1000 });
|
||||||
|
b.on('tick', () => {
|
||||||
|
const active = !!b.allies().find((u) => u.uid === 1)?.s.flags.stealth;
|
||||||
|
if (active !== previous) { transitions.push({ t: b.time, active }); previous = active; }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.run(44);
|
||||||
|
const exit = transitions.find((c) => !c.active);
|
||||||
|
assert.ok(exit, 'six-Siracusa stealth expires');
|
||||||
|
const hits = ordinary.filter((c) => c.t >= exit.t);
|
||||||
|
assert.ok(hits.length >= 2);
|
||||||
|
assert.equal(combo.length, 1);
|
||||||
|
close(combo[0].t, hits[0].t);
|
||||||
|
close(combo[0].amount, hits[0].atk * 8);
|
||||||
|
assert.equal(h.unit(1).findBuff(KEY), null);
|
||||||
|
checkInvariants(h.b);
|
||||||
|
assert.deepEqual(h.b.errors, []);
|
||||||
|
});
|
||||||
@@ -661,7 +661,7 @@ test('家族徽章 (叙拉古): ATK +2 %/s while 隐匿 (max +100 %) until the f
|
|||||||
h.b.applyStatus(u, 'stealth', { duration: 10 });
|
h.b.applyStatus(u, 'stealth', { duration: 10 });
|
||||||
h.run(10);
|
h.run(10);
|
||||||
const ramp = u.s.atk / atk0;
|
const ramp = u.s.atk / atk0;
|
||||||
h.run(0.5);
|
assert.ok(!u.s.flags.stealth, 'damage immediately after stealth expires');
|
||||||
const e = foe(h);
|
const e = foe(h);
|
||||||
const hp0 = e.hp;
|
const hp0 = e.hp;
|
||||||
hitTrue(h, u, e, 100);
|
hitTrue(h, u, e, 100);
|
||||||
|
|||||||
Reference in new issue
Block a user