diff --git a/docs/META.md b/docs/META.md index 7dfc840c..2beb2a4c 100644 --- a/docs/META.md +++ b/docs/META.md @@ -332,6 +332,10 @@ Dispatch order per player: `global` → `band` → `bond` (data order) → garri → equipped items → EffectRefs (insertion order). `onPrice` runs the priced chess's own 特质 first (购买价格为N sets the price that 远见's discount and the strategies' caps then act on). Every call is isolated with try/catch (the error is logged once and counted in `match.dispatcher.errors`); nested dispatches are capped at depth 6. +The equipped-items 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, normal 博士投影 destroys itself), so an item a handler +took off is skipped and the item after it still runs; a piece gained or equipped meanwhile waits for the next dispatch. ### 2.3 Garrisons (特质) The dispatcher calls `handler[hook] ?? handler.run` only on the hook of the garrison's `eventType` (a handler may widen diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index 720d1702..b9dfadc7 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -22,7 +22,12 @@ // 投资人 (investShip) active ⇒ SERVER_GAIN garrisons run ×2 (×3 at ≥ 100 layers) — owned by the dispatcher. // 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). Dispatch order per player: global → band → bonds → garrisons (board reading order, +// 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. +// 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 // try/catch-guarded; nested dispatch depth is capped (MAX_DEPTH) so content can never loop the server. @@ -207,13 +212,14 @@ 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) + // 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) if (hook !== 'onEquip' && hook !== 'onArt' && hook !== 'onDestroy') { for (const holder of ownedChess(ps)) { - for (const it of holder.items || []) { + for (const it of (holder.items || []).slice()) { const key = `item:${itemKey(it.id)}`; const h = reg.get(key); - if (h) this._call(ps, key, h, hook, { kind: 'item', key, piece: it, holder, item: this.m.gd.item(it.id) }, ev); + 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); } } } @@ -346,6 +352,13 @@ function ownedChess(ps) { return out; } +/** Dispatch step 5: is `it` still equipped on `holder`, and `holder` still an owned chess of `ps`? */ +function stillEquipped(ps, holder, it) { + if (!Array.isArray(holder.items) || !holder.items.includes(it)) return false; + const loc = ps.find(holder.uid); + return !!loc && loc.piece === holder; +} + // ===================================================================================================== // handler context — the ONLY way content mutates player state (see docs/META.md) diff --git a/test/match/feedback1-dispatch-snapshot.test.js b/test/match/feedback1-dispatch-snapshot.test.js new file mode 100644 index 00000000..84d61824 --- /dev/null +++ b/test/match/feedback1-dispatch-snapshot.test.js @@ -0,0 +1,112 @@ +// Prep-effect dispatcher, step 5 (equipped items; server/match/effectsMeta.js): it walks a snapshot of the owned chess +// and of each holder's items, and an item's hook runs only while that item is still equipped on that holder and the +// holder is still owned. Handlers move and destroy pieces mid-walk — 突变细胞 (onBattleResult) transforms its holder and +// sends the equipment back to the hand, normal 博士投影 (onRoundStart) destroys itself and promotes its holder — so the +// live array either skipped the next item on that holder (a splice shifts it under the loop) or ran it with a holder +// that was no longer owned. No shipped item has a second onBattleResult / onRoundStart hook, so a synthetic spy handler +// on an ordinary item (registered in a private registry) stands in for one. DESIGN §21. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch, give, giveItem, DATA } from './harness.js'; +import { createRegistry } from '../../server/match/effectsMeta.js'; + +const QUIET = { warn() {}, error() {}, info() {} }; +const A = (k) => `chess_item_${k}_e_a`; +const CELL = A('5_08'); +const PROJECTION = A('5_06'); // 博士投影 (normal): promotes its holder at the next round start, then is gone +const SPY = A('1_01'); // any item: carries the synthetic spy handler +const OK = { ok: true }; + +/** Solo match in PREP R1, everything cleared, with a private registry holding `spy` on SPY. */ +function setup(spy, seed = 3) { + const registry = createRegistry({ log: QUIET }); + registry.item(SPY, spy); + const h = makeMatch({ mode: 'solo', humans: 1, seed, registry, fake: true }).start(); + h.toPrep(1); + const m = h.m; + for (const ps of m.players.values()) { + 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.funds = 50; ps.layers = {}; ps.pendingFunds = 0; ps.shop.freeRefreshes = 0; ps.bandId = null; + ps.recompute(); + } + const ps = h.ps('p_0'); + const equip = (item, target) => m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid }); + return { m, ps, equip }; +} +/** Visible normal chess whose 特质 are all IN_BATTLE (no prep-side side effects), sorted. */ +const plain = (pred) => Object.values(DATA.chess) + .filter((c) => c.visible && !c.isGolden && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && pred(c)) + .map((c) => c.chessId).sort(); + +/** What the spy saw: its holder still owned, and itself still equipped on it. */ +function spyCall(ctx) { + const { piece, holder } = ctx.source; + return { holderOwned: !!ctx.piece(holder.uid), equipped: (holder.items || []).includes(piece) }; +} + +test('dispatcher: an item after the 突变细胞 on the same holder is not run with the transformed (gone) holder', () => { + for (const order of ['cell first', 'spy first']) { + const calls = []; + const { m, ps, equip } = setup({ onBattleResult(ctx) { calls.push(spyCall(ctx)); } }); + const cid = plain((c) => c.tier === 2)[0]; + const holder = give(m, ps, cid, 'hand'); + const cell = giveItem(m, ps, CELL); + const spy = giveItem(m, ps, SPY); + for (const it of order === 'cell first' ? [cell, spy] : [spy, cell]) assert.deepEqual(equip(it, holder), OK); + m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true }); + const chess = ps.hand.filter((p) => p && p.kind === 'chess'); + assert.equal(chess.length, 1, `${order}: the holder was transformed`); + assert.equal(DATA.chess[chess[0].id].tier, 3); + assert.ok(ps.hand.some((p) => p && p.uid === spy.uid), `${order}: the spy item is back in the hand`); + if (order === 'cell first') { + assert.deepEqual(calls, [], 'its holder was gone when its turn came: not run (it was run with a stale holder)'); + } else { + assert.deepEqual(calls, [{ holderOwned: true, equipped: true }], 'run once, before the cell, on its owned holder'); + } + } +}); + +test('dispatcher: 博士投影 destroying itself at the round start does not skip the next item on its holder', () => { + for (const order of ['projection first', 'spy first']) { + const calls = []; + const { m, ps, equip } = setup({ onRoundStart(ctx) { calls.push({ ...spyCall(ctx), golden: DATA.chess[ctx.source.holder.id].isGolden }); } }); + const cid = plain((c) => c.tier === 2)[0]; + const holder = give(m, ps, cid, 'board', [10, 5]); + const proj = giveItem(m, ps, PROJECTION); + const spy = giveItem(m, ps, SPY); + for (const it of order === 'projection first' ? [proj, spy] : [spy, proj]) assert.deepEqual(equip(it, holder), OK); + m.dispatch(ps, 'onRoundStart', { round: 2 }); + assert.ok(DATA.chess[holder.id].isGolden, `${order}: 博士投影 promoted its holder`); + assert.ok(!ps.find(proj.uid), `${order}: 博士投影 is gone`); + assert.deepEqual(holder.items.map((it) => it.uid), [spy.uid], `${order}: the spy item stays equipped`); + // the projection's splice shifted the spy item under the live loop: it was skipped + assert.deepEqual(calls, [{ holderOwned: true, equipped: true, golden: order === 'projection first' }], `${order}: run exactly once`); + } +}); + +test('dispatcher: an item whose holder an earlier item\'s handler destroyed is not run', () => { + const calls = []; + const registry = createRegistry({ log: QUIET }); + const KILLER = A('1_02'); + let victimUid = null; + registry.item(KILLER, { onRoundStart(ctx) { if (victimUid != null) ctx.destroyPiece(victimUid); } }); + registry.item(SPY, { onRoundStart(ctx) { calls.push(spyCall(ctx)); } }); + const h = makeMatch({ mode: 'solo', humans: 1, seed: 5, 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); + // board reading order: the killer's holder comes first + const first = give(m, ps, c1, 'board', [9, 4]); + const victim = give(m, ps, c2, 'hand'); + victimUid = victim.uid; + for (const [it, holder] of [[giveItem(m, ps, KILLER), first], [giveItem(m, ps, SPY), victim]]) { + assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: holder.uid }), OK); + } + m.dispatch(ps, 'onRoundStart', { round: 2 }); + assert.ok(!ps.find(victim.uid), 'the victim chess is gone'); + assert.deepEqual(calls, [], 'the spy on the destroyed holder never ran'); +});