feedback1 integrate: the prep dispatcher walks a snapshot of equipped items (pointed out in PR #2)

Step 5 iterated holder.items live while handlers move and destroy pieces: normal 博士投影
splices itself out (the next item on that holder was skipped) and 突变细胞 transforms its
holder (the next item ran with a holder that was gone). It now walks a copy and runs an item
only while it is still equipped on its still-owned holder; the rule is in the header and
META.md. Tests use a synthetic spy handler after the 突变细胞 and after 博士投影.
This commit is contained in:
sganggs committed 2026-10-03 13:43:56 +08:00
1 parent 07c47b3a19
commit cb01bb3fcf
3 files changed
+133 -4

No files matched your search

+4
View File
@@ -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
+17 -4
View File
@@ -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)
@@ -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');
});