fix: 让家族徽章在解除隐匿后的首笔伤害触发

This commit is contained in:
Xinglan233 committed 2026-10-10 09:53:26 +08:00
1 parent 1db8e51023
commit a173a470b6
6 files changed
+210 -9

No files matched your search

+8
View File
@@ -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
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.
### 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`.
+8
View File
@@ -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.
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.
+16
View File
@@ -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.
- **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md
(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.
+8 -8
View File
@@ -810,30 +810,30 @@ const BY_ITEM = {
const partner = partnerOf(p);
const key = S.key('stealth');
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;
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;
const stealth = !!u.s.flags.stealth;
if (stealth && st.bonus < max) {
st.bonus = Math.min(max, st.bonus + per * TICKS);
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) => {
if (c.source !== u || !st.armed || isProc(c.dmg) || !(c.amount > 0)) return;
st.armed = false;
if (c.source !== u || isProc(c.dmg) || !(c.amount > 0) || st.bonus <= 0
|| 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') {
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 });
}
st.bonus = 0;
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; });
},
};
+169
View File
@@ -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, []);
});
+1 -1
View File
@@ -661,7 +661,7 @@ test('家族徽章 (叙拉古): ATK +2 %/s while 隐匿 (max +100 %) until the f
h.b.applyStatus(u, 'stealth', { duration: 10 });
h.run(10);
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 hp0 = e.hp;
hitTrue(h, u, e, 100);