diff --git a/docs/META.md b/docs/META.md index b5cd6288..3963ffa3 100644 --- a/docs/META.md +++ b/docs/META.md @@ -371,7 +371,7 @@ that per garrison with `garrisonHooks(garrison) → hook[]`, e.g. "<进入休整 | `SERVER_GAIN` 获得时 | `onGain` | the gained piece — run **×2 while 投资人 is active, ×3 at ≥ 100 投资人 layers** | | `SERVER_PREP_START` 进入休整期时 | `onRoundStart` | owned chess: board, and hand unless `bbStr.conditionkey === 'character_target_inboard'` | | `SERVER_PREP_FIN` 休整期结束时 | `onPrepEnd` | same | -| `SERVER_REFRESH_SHOP` 刷新时 | `onRefresh` | same — manual refreshes only (`ev.trigger` marks a re-run); 拉普兰德's "本回合首次主动刷新" counts per copy (`incPieceCounter`) | +| `SERVER_REFRESH_SHOP` 刷新时 | `onRefresh` | same, as they stood when the refresh happened (taken before its first handler: a chess gained during the dispatch — 贾维's gift, the elite it completes — waits for the next refresh; one that left is skipped) — manual refreshes only (`ev.trigger` marks a re-run); 拉普兰德's "本回合首次主动刷新" counts per copy (`incPieceCounter`) | | `SERVER_CHESS_SOLD` 售出时 | `onSold` | the sold piece | | `SERVER_PRICE` 购买价格 | `onPrice` (first) | the chess in the priced slot (`ctx.source.where === 'shop'`); SERVER_CHESS_PRICE `bb.price` is the discount off the tier price (至简 3 − 2 = 1, 红豆 2 − 1 = 1: both texts say 购买价格为1) | | `IN_BATTLE` | — | battle side (server/sim/content/garrisons.js `install`) | diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index df1bdc84..5ef54039 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -18,7 +18,9 @@ // onRoundStart, SERVER_PREP_FIN→onPrepEnd, SERVER_CHESS_SOLD→onSold of the sold piece, SERVER_PRICE→onPrice of the // slot's chess, SERVER_REFRESH_SHOP→onRefresh); the dispatcher calls `handler[hook] ?? handler.run`. A handler may // widen that per garrison with `garrisonHooks(garrison) → hook[]` (e.g. "<进入休整期时><休整期结束时>"). Owned-piece -// garrisons fire for board pieces, and for hand pieces unless bbStr.conditionkey is 'character_target_inboard'. +// garrisons fire for board pieces, and for hand pieces unless bbStr.conditionkey is 'character_target_inboard'. A +// refresh (onRefresh) runs those that stood on the board / in the hand when it happened (taken before its first +// handler): a chess gained during its dispatch — 贾维's gift, the elite that gift completes — waits for the next refresh. // 投资人 (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 @@ -204,6 +206,10 @@ export class EffectDispatcher { if (deferItems) ps._deferItemMerge = (ps._deferItemMerge || 0) + 1; try { const reg = this.registry; + // onRefresh: the board / hand chess as they stood when the refresh happened, taken before any handler runs — a + // chess gained during this dispatch (贾维's gift on the 6th refresh, the elite its copy completes) was not there + // when it happened: its 刷新时 特质 waits for the next manual refresh (拉普兰德's "本回合首次主动刷新", PR #196) + const refreshed = hook === 'onRefresh' ? refreshPieces(ps) : null; // 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on if (hook === 'onPrice') this._garrisons(ps, hook, ev); // 1. globals @@ -221,7 +227,7 @@ export class EffectDispatcher { if (h) this._call(ps, key, h, hook, { kind: 'bond', key, bondId, bond: ps.bonds[bondId] ?? null }, ev); } // 4. garrisons (onPrice: already run as step 0) - if (hook !== 'onPrice') this._garrisons(ps, hook, ev); + if (hook !== 'onPrice') this._garrisons(ps, hook, ev, refreshed); // 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 @@ -279,7 +285,12 @@ export class EffectDispatcher { return [GARRISON_HOOK[g.eventType]]; } - _garrisons(ps, hook, ev) { + /** + * Run the owned chess's garrisons of `hook`. `refreshed` (onRefresh): the board / hand chess taken when the dispatch + * began (refreshPieces) — each runs if it is still on the board or in the hand (where it stands now); a chess gained + * meanwhile does not run, nor one that left (sold, consumed by a merge, moved to the 临时整备区). + */ + _garrisons(ps, hook, ev, refreshed = null) { const gd = ps.gd || this.m.gd; const run = (piece, where) => { const rec = gd.chess(piece.id); @@ -304,6 +315,13 @@ export class EffectDispatcher { return; } if (hook !== 'onRoundStart' && hook !== 'onPrepEnd' && hook !== 'onRefresh') return; + if (refreshed) { + for (const piece of refreshed) { + const loc = ps.find(piece.uid); + if (loc && loc.piece === piece && (loc.area === 'board' || loc.area === 'hand')) run(piece, loc.area); + } + return; + } for (const { piece } of boardOrder(ps.board)) if (piece.kind === 'chess') run(piece, 'board'); for (const p of ps.hand) if (p && p.kind === 'chess') run(p, 'hand'); } @@ -385,6 +403,14 @@ function ownedChess(ps) { return out; } +/** The board chess (reading order) then the hand chess — the pieces whose 刷新时 特质 a refresh runs (onRefresh). */ +function refreshPieces(ps) { + const out = []; + for (const { piece } of boardOrder(ps.board)) if (piece.kind === 'chess') out.push(piece); + for (const p of ps.hand) if (p && p.kind === 'chess') out.push(p); + 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; diff --git a/server/sim/content/garrisons/meta.js b/server/sim/content/garrisons/meta.js index 6931434e..90c670e4 100644 --- a/server/sim/content/garrisons/meta.js +++ b/server/sim/content/garrisons/meta.js @@ -202,7 +202,9 @@ H.SERVER_ADD_REFRESH_CNT_MULTIPLIER_BOND_LAYER = { // (ev.trigger) is no manual refresh: it neither fires nor counts. A new copy (bought, granted) starts at 0, and so does // an elite merged this round: a new 拉普兰德, its own first manual refresh this round adds +8 even when its copies // already fired (GitHub #169; the owner's decision of 2026-10-06 — PlayerState._mergeChess carries no counter over). -// [ASSUMED]: a copy bought after selling one this round is a new copy — "获得该干员后" — and fires on its own first +// A copy gained by a refresh's own effects (贾维's gift on every 6th refresh, or the elite that gift completes) did not +// witness that refresh: the dispatcher runs the chess that stood there when it happened (effectsMeta.js onRefresh), so +// her first is the next manual refresh (PR #196). [ASSUMED]: a copy bought after selling one this round is a new copy — "获得该干员后" — and fires on its own first // refresh (the server cannot tell it from any other copy; each such +4 costs her price + a refresh − the 1-fund refund, // and needs her in the shop again). const REFRESH_CNT_KEY = 'garrison:SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT:refreshes'; // per-piece counter (module-prefixed) diff --git a/test/match/feedback1-meta.test.js b/test/match/feedback1-meta.test.js index c1bd2af2..38456a6c 100644 --- a/test/match/feedback1-meta.test.js +++ b/test/match/feedback1-meta.test.js @@ -184,6 +184,41 @@ test('#1 only manual refreshes count: a re-triggered "刷新时" trait (ctx.trig m.dispose(); }); +// PR #196 (the part 0.2.0 lacks): 贾维 【团伙行动】 grants a 叙拉古 operator on every 6th manual refresh from that refresh's +// own dispatch (band step, before the garrisons). The 拉普兰德 it grants — or the elite its copy completes — is gained +// after the refresh happened: her "本回合首次主动刷新" is the next manual one. The dispatcher walks the board and hand as +// they stood when the refresh happened (EffectDispatcher.dispatch onRefresh snapshot); it walked them live, so she fired +// (and spent her first refresh) in the refresh that granted her. Round counters are not copied on a merge (§25.13.3). +for (const variant of ['a new copy', 'a copy that completes the elite']) { + test(`#1 贾维 refresh gift (${variant}, PR #196): the 拉普兰德 waits for the next manual refresh`, () => { + const s = setup(); + const { m, ps } = s; + ps.bandId = 'band_chiave'; + ps.shop.level = 2; + // 贾维's draw (ctx.rollChess on the meta RNG) gives 拉普兰德; the shop rolls stay as they are + const roll = m.pool.roll.bind(m.pool); + m.pool.roll = (rng, opts = {}) => (rng === m.rngMeta && typeof opts.filter === 'function' && opts.filter(LAP) ? LAP : roll(rng, opts)); + s.activate(); + const elite = variant !== 'a new copy'; + if (elite) { give(m, ps, LAP, 'hand'); give(m, ps, LAP, 'hand'); } + for (let i = 0; i < 5; i++) s.refresh(); + const before = s.L(); + assert.equal(before, elite ? 8 : 0, 'the copies already owned fire on the round\'s first refresh'); + assert.ok(!ps.allChess().some((p) => p.id === LAP_B) && ps.allChess().filter((p) => p.id === LAP).length === (elite ? 2 : 0)); + s.refresh(); // the 6th: 贾维's gift + const lap = ps.allChess().find((p) => p.id === (elite ? LAP_B : LAP)); + assert.ok(lap, elite ? 'the gift completed the elite' : 'the gift is owned'); + assert.equal(s.L(), before, 'not in the refresh that granted her'); + s.refresh(); + assert.equal(s.L(), before + (elite ? 8 : 4), 'her first manual refresh is the next one'); + s.refresh(); + assert.equal(s.L(), before + (elite ? 8 : 4), 'once a round'); + assert.equal(m.dispatcher.errors, 0); + checkInvariants(m); + m.dispose(); + }); +} + // --------------------------------------------------------------------------------------------------------------------- // #4 突变细胞