From a173a470b6b9f120966e02650de9851598e6af07 Mon Sep 17 00:00:00 2001 From: Xinglan233 <130824939+Xinglan233@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:53:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=A9=E5=AE=B6=E6=97=8F=E5=BE=BD?= =?UTF-8?q?=E7=AB=A0=E5=9C=A8=E8=A7=A3=E9=99=A4=E9=9A=90=E5=8C=BF=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E9=A6=96=E7=AC=94=E4=BC=A4=E5=AE=B3=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 8 ++ docs/design/engine.md | 8 ++ docs/history/0.2.3.md | 16 +++ server/sim/content/items/battle.js | 16 +-- test/content/family-crest-timing.test.js | 169 +++++++++++++++++++++++ test/content/items.test.js | 2 +- 6 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 test/content/family-crest-timing.test.js diff --git a/docs/SIM.md b/docs/SIM.md index d297a48e..a1261a8e 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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`. diff --git a/docs/design/engine.md b/docs/design/engine.md index 104a829f..33690878 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -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. diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index 5fd8b3f9..420cf803 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -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. diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index 38dd7867..27c65630 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -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; }); }, }; diff --git a/test/content/family-crest-timing.test.js b/test/content/family-crest-timing.test.js new file mode 100644 index 00000000..6f5cafd9 --- /dev/null +++ b/test/content/family-crest-timing.test.js @@ -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, []); +}); diff --git a/test/content/items.test.js b/test/content/items.test.js index 021b0e99..03792c70 100644 --- a/test/content/items.test.js +++ b/test/content/items.test.js @@ -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);