From f671c15d3f3e7c666a5883dce732e01c2da2eec7 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 17:47:19 +0800 Subject: [PATCH] feedback1 integrate: the prep dispatcher takes every equipped item before the first one runs (follow-up to the PR #2 fix) Step 5 read each holder's items when the walk reached that holder, so an item equipped meanwhile onto a holder not yet walked ran in the same dispatch and an item moved to a later holder ran twice (latent: no shipped item does either). Every [holder, item] pair is now taken before the first item runs, as the header and META already said. --- server/match/effectsMeta.js | 28 ++++++---- .../match/feedback1-dispatch-snapshot.test.js | 56 +++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index b9dfadc7..613edc93 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -23,10 +23,12 @@ // ctx.triggerGarrisons(uid, eventType) re-runs another piece's garrisons (铃兰, "触发…的获得时效果", 特质相同). // Items: onEquip / onArt / onDestroy go to the item's own handler only; every other hook runs for items equipped on // owned chess (ev.source.holder). That step walks a snapshot — the owned chess and each holder's items as they stand -// when it begins — and runs an item only if, when its turn comes, it is still equipped on that holder and the holder -// is still owned: handlers move and destroy pieces mid-walk (突变细胞 transforms its holder and returns the equipment -// to the hand; normal 博士投影 destroys itself, which splices holder.items), and a live walk skipped the next item or -// ran it with a holder that was gone. Pieces gained or equipped during the walk wait for the next dispatch. +// when it begins (every [holder, item] pair, taken before the first item runs) — and runs an item only if, when its +// turn comes, it is still equipped on that holder and the holder is still owned: handlers move and destroy pieces +// mid-walk (突变细胞 transforms its holder and returns the equipment to the hand; normal 博士投影 destroys itself, which +// splices holder.items), and a live walk skipped the next item or ran it with a holder that was gone. Pieces gained or +// equipped during the walk wait for the next dispatch; an item moved during it runs at most once (on the holder it +// stood on, if its turn came before the move). // Dispatch order per player: global → band → bonds → garrisons (board reading order, // then hand) → equipped items → effects (insertion order); onPrice runs the priced chess's own 特质 first (购买价格为N // sets the price the discounts and caps of bonds / strategies then act on — user playtest #5). Every call is @@ -212,15 +214,17 @@ export class EffectDispatcher { } // 4. garrisons (onPrice: already run as step 0) if (hook !== 'onPrice') this._garrisons(ps, hook, ev); - // 5. equipped items (not for the item-specific hooks): a snapshot of the owned chess and of each holder's items, - // each run only while still equipped on its still-owned holder — handlers move / destroy pieces (header) + // 5. equipped items (not for the item-specific hooks): every [holder, item] pair of the owned chess, taken before + // the first item runs; each runs only while still equipped on its still-owned holder — handlers move / destroy + // pieces (header). Taking the pairs per holder as the walk reached it ran an item equipped meanwhile onto a later + // holder, and twice an item moved from a holder already walked to a later one. if (hook !== 'onEquip' && hook !== 'onArt' && hook !== 'onDestroy') { - for (const holder of ownedChess(ps)) { - for (const it of (holder.items || []).slice()) { - const key = `item:${itemKey(it.id)}`; - const h = reg.get(key); - if (h && stillEquipped(ps, holder, it)) this._call(ps, key, h, hook, { kind: 'item', key, piece: it, holder, item: this.m.gd.item(it.id) }, ev); - } + const pairs = []; + for (const holder of ownedChess(ps)) for (const it of holder.items || []) pairs.push([holder, it]); + for (const [holder, it] of pairs) { + const key = `item:${itemKey(it.id)}`; + const h = reg.get(key); + if (h && stillEquipped(ps, holder, it)) this._call(ps, key, h, hook, { kind: 'item', key, piece: it, holder, item: this.m.gd.item(it.id) }, ev); } } // 6. persistent effects diff --git a/test/match/feedback1-dispatch-snapshot.test.js b/test/match/feedback1-dispatch-snapshot.test.js index 84d61824..44d94b96 100644 --- a/test/match/feedback1-dispatch-snapshot.test.js +++ b/test/match/feedback1-dispatch-snapshot.test.js @@ -110,3 +110,59 @@ test('dispatcher: an item whose holder an earlier item\'s handler destroyed is n assert.ok(!ps.find(victim.uid), 'the victim chess is gone'); assert.deepEqual(calls, [], 'the spy on the destroyed holder never ran'); }); + +// QA after the integration: the pairs were taken per holder as the walk reached it, not before step 5 began — an item +// equipped meanwhile onto a holder not yet walked ran in the same dispatch, and an item moved from a holder already +// walked to a later one ran twice. Every [holder, item] pair is now taken before the first item runs. +test('dispatcher: an item equipped during the walk waits for the next dispatch, whichever holder it goes to', () => { + for (const where of ['a holder walked later (hand)', 'a holder walked earlier (board)']) { + const calls = []; + const registry = createRegistry({ log: QUIET }); + const GIVER = A('1_02'); + let target = null, given = null; + registry.item(GIVER, { onRoundStart(ctx) { if (given) return; given = ctx.grantItem(SPY); ctx.equipDirect(given.uid, target.uid); } }); + registry.item(SPY, { onRoundStart(ctx) { calls.push(ctx.source.piece.uid); } }); + const h = makeMatch({ mode: 'solo', humans: 1, seed: 3, registry, fake: true }).start(); + h.toPrep(1); + const m = h.m; + const ps = h.ps('p_0'); + for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p); + ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0; ps.bandId = null; ps.recompute(); + const [c1, c2] = plain((c) => c.tier === 1); + let giverHolder; + if (where.includes('later')) { giverHolder = give(m, ps, c1, 'board', [9, 4]); target = give(m, ps, c2, 'hand'); } + else { target = give(m, ps, c2, 'board', [9, 4]); giverHolder = give(m, ps, c1, 'hand'); } + assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: giveItem(m, ps, GIVER).uid, targetUid: giverHolder.uid }), OK); + m.dispatch(ps, 'onRoundStart', { round: 2 }); + assert.ok(given && target.items.some((it) => it.uid === given.uid), `${where}: the spy item was equipped during the walk`); + assert.deepEqual(calls, [], `${where}: not run in the dispatch that equipped it`); + m.dispatch(ps, 'onRoundStart', { round: 3 }); + assert.deepEqual(calls, [given.uid], `${where}: run once in the next dispatch`); + } +}); + +test('dispatcher: an item moved during the walk from a holder already walked to a later one runs once', () => { + const calls = []; + const registry = createRegistry({ log: QUIET }); + const MOVER = A('1_02'); + let first = null, later = null, spy = null, done = false; + registry.item(SPY, { onRoundStart(ctx) { calls.push(ctx.source.holder.uid); } }); + registry.item(MOVER, { onRoundStart(ctx) { if (done) return; done = true; ctx.destroyPiece(first.uid); ctx.equipDirect(spy.uid, later.uid); } }); + const h = makeMatch({ mode: 'solo', humans: 1, seed: 3, registry, fake: true }).start(); + h.toPrep(1); + const m = h.m; + const ps = h.ps('p_0'); + for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p); + ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0; ps.bandId = null; ps.recompute(); + const [c1, c2, c3] = plain((c) => c.tier === 1); + first = give(m, ps, c1, 'board', [12, 3]); // walked first (deployment order) + const moverHolder = give(m, ps, c2, 'board', [9, 3]); + later = give(m, ps, c3, 'hand'); // walked last + spy = giveItem(m, ps, SPY); + assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: spy.uid, targetUid: first.uid }), OK); + assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: giveItem(m, ps, MOVER).uid, targetUid: moverHolder.uid }), OK); + const firstUid = first.uid; + m.dispatch(ps, 'onRoundStart', { round: 2 }); + assert.ok(done && !ps.find(firstUid) && later.items.includes(spy), 'the spy item was moved to the later holder mid-walk'); + assert.deepEqual(calls, [firstUid], 'run once, on the holder it stood on when its turn came (it ran twice)'); +});