From ce33795d1be4f644650f958bb56a084ae5968b88 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:06:43 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WC:=20the=20=E4=BF=A1=E6=A0=87=20gi?= =?UTF-8?q?ft=20sends=20the=20original=20operator=20(an=20elite=20stays=20?= =?UTF-8?q?an=20elite)=20and=20still=20arrives=20when=20the=20sender=20is?= =?UTF-8?q?=20eliminated;=20a=20failed=20grant=20waits=20for=20the=20next?= =?UTF-8?q?=20round=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/META.md | 7 +- server/match/Match.js | 5 ++ server/match/PlayerState.js | 2 + server/match/builtinMeta.js | 56 +++++++++---- server/match/effectsMeta.js | 22 +++++ test/match/feedback3-gift.test.js | 131 ++++++++++++++++++++++++++++++ 6 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 test/match/feedback3-gift.test.js diff --git a/docs/META.md b/docs/META.md index 105bbbb7..079beef5 100644 --- a/docs/META.md +++ b/docs/META.md @@ -430,7 +430,8 @@ a promotion or `ctx.destroyPiece` returns) ends with the auto-merge (`acquireIte Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds), 紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶 (+funds next round), 人事部文档 (cap 9), 博士投影 (elite now / at the next round start), 寻呼模块 / 信标 (pick-one -offers; 信标 gifts the original chess to the teammate with the most members of its bonds next round), 商业包装方案 (every +offers; 信标 gifts the original chess — an elite stays an elite — to the teammate with the most members of its bonds next +round, also when the sender was eliminated meanwhile; a failed grant waits for the next round start), 商业包装方案 (every N sells → same-bond chess), 突变细胞 (after battle the carrier — deployed or on the bench — is destroyed, its tile freed; its equipment, the cell included, returns to the hand first; then a random NORMAL chess one tier higher, max 6, is gained like any gained operator: into the 整备区, overflow temp, never onto the carrier's tile — official footage, @@ -453,7 +454,9 @@ risk / reward the item is about. The bounty then behaves like any other (next battles, 联防 payouts, Final Assault spawns). 神秘顾客's destroy clause (+1 fund, the Art passes to the next alive player) is content too (`onDestroy`). EffectRefs: `effect:builtin_round_coin`, `effect:builtin_gift`, `effect:builtin_next_buy_golden_item` (整备), -`effect:builtin_next_buy_elite` (升华). +`effect:builtin_next_buy_elite` (升华). An eliminated player gets no dispatch, except an EffectRef whose handler sets +`afterElimination: true` (its `onRoundStart` still runs: `EffectDispatcher.dispatchEliminated`, called by +`Match.startRound`) — only `effect:builtin_gift` does, so a 信标 gift reaches the teammate (GitHub #86). ### 2.6 机变 card application `choice:` handler (content) → else the family default (choices.js): bounty → `ctx.addBounty`; supply/shop → diff --git a/server/match/Match.js b/server/match/Match.js index f0d0af65..a9d3ca20 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -1373,6 +1373,11 @@ export class Match { } for (const ps of alive) ps.startRound(r); for (const ps of alive) this.dispatch(ps, 'onRoundStart', { round: r }); + // an eliminated player's pending 信标 gift still goes to its teammate (effects flagged afterElimination; GitHub #86) + for (const ps of this.order) { + if (ps.alive) continue; + try { this.dispatcher.dispatchEliminated(ps, 'onRoundStart', { round: r }); } catch (e) { this.reportError('dispatch onRoundStart (eliminated)', e); } + } for (const ps of alive) ps.recompute(); this.setDeadline(DELAYS.ROUND_START / 1000, () => this.afterRoundStart(), { silent: this.soloUntimed }); this.markPublic(); diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index e3f972d7..9b2d0425 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -1489,6 +1489,8 @@ export class PlayerState { this.dirty(); } + // `effects` are kept: a 信标 gift still pending is delivered to the teammate at the next round start (builtin_gift is + // flagged afterElimination — GitHub #86); nothing else of an eliminated player is dispatched. eliminate(round) { this.alive = false; this.ready = false; diff --git a/server/match/builtinMeta.js b/server/match/builtinMeta.js index 570706e4..95de6566 100644 --- a/server/match/builtinMeta.js +++ b/server/match/builtinMeta.js @@ -15,7 +15,8 @@ // use_equip_reward_char_chess 拟态物质 3rd copy or a same-bond chess // use_equip_reward_special_goods_char_chess {refresh_cnt} 寻呼模块 offer N same-bond chess (≤ shop level) // use_equip_recruit_new_char_and_give_char_to_player_most_bond {refresh_cnt} 信标 destroy target, offer N same-tier -// chess, gift the original next prep +// chess, gift the original (an elite stays +// elite) next prep, sender eliminated or not // sell_char_count_gain_equip_owner_bond {count} 商业包装方案 every `count` sells → same-bond chess // char_chess_transformation_equip 突变细胞 after battle: holder destroyed, its equipment // (the cell included — not consumed) back to @@ -43,6 +44,19 @@ function rollSameBond(ctx, bonds, maxTier, exclude = null) { return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))) && !(exclude && exclude.includes(id)); } }); } +/** 信标: the living teammate (not the caller) with the most members of `bonds` ("相应盟约人数最多"), ties at random; null without one. */ +function mostBondMate(ctx, bonds) { + const mates = ctx.teammates(); + if (!mates.length) return null; + let best = -1; + let pick = []; + for (const t of mates) { + const score = bonds.reduce((s, b) => s + t.bondCount(b), 0); + if (score > best) { best = score; pick = [t]; } else if (score === best) pick.push(t); + } + return ctx.rng.pick(pick) || null; +} + const ITEM_HANDLERS = { equip_destory_gain_random_coin: { onEquip(ctx, ev) { @@ -135,6 +149,12 @@ const ITEM_HANDLERS = { if (ids.length) ctx.offerChess(ids, { source: 'item' }); }, }, + // 信标 (act2autochess eff_acarm109 / eff_acgarm109 "装备时,目标干员和本装备销毁并进行一次特殊刷新,出现两名与携带者同等阶的 + // 干员,免费获取其中一名 / 若在同盟模拟中且存在其他队友,下个休整期向相应盟约人数最多的队友发送1个原干员 / 相应盟约人数相同则 + // 随机发送"; 芬 band_fang "原干员在下回合传递给对应盟约人数最多的队友"). The gift is the ORIGINAL operator: an elite carrier + // is sent as that elite (community report #6 after 0.1.2: it arrived as the normal card — the gift carried baseIdOf). + // Destroying the carrier returns its pool copies (an elite's 3) at once; the gift takes them again when it is granted + // (effect:builtin_gift: grantChess → acquireChess, an elite takes up to goldenCopies, as every effect grant). use_equip_recruit_new_char_and_give_char_to_player_most_bond: { onEquip(ctx, ev) { const target = ctx.piece(ev.target.uid); @@ -142,7 +162,7 @@ const ITEM_HANDLERS = { const tier = ctx.gd.tierOf(target.id); const n = Math.max(1, int(paramsOf(ctx, ev.item).refresh_cnt, 2)); const bonds = ctx.pieceBonds(target.uid); - const original = ctx.gd.baseIdOf(target.id); + const original = target.id; // the elite id for an elite (report #6) ctx.destroyPiece(target.uid); const ids = []; // different operators of the target's tier, topped up from the tier below like the promotion reward (item 19) @@ -153,16 +173,8 @@ const ITEM_HANDLERS = { } if (ids.length) ctx.offerChess(ids, { source: 'item', tier }); // co-op: next prep, send the original chess to the teammate with the most members of its bonds - const mates = ctx.teammates(); - if (!mates.length) return; - let best = -1; - let pick = []; - for (const t of mates) { - const score = bonds.reduce((s, b) => s + t.bondCount(b), 0); - if (score > best) { best = score; pick = [t]; } else if (score === best) pick.push(t); - } - const to = ctx.rng.pick(pick); - if (to) ctx.addEffect({ id: `gift:${ev.item.uid}`, key: 'effect:builtin_gift', hidden: true, battle: false, params: { toPlayerId: to.playerId, chessId: original } }); + const to = mostBondMate(ctx, bonds); + if (to) ctx.addEffect({ id: `gift:${ev.item.uid}`, key: 'effect:builtin_gift', hidden: true, battle: false, params: { toPlayerId: to.playerId, chessId: original, bonds } }); }, }, sell_char_count_gain_equip_owner_bond: { @@ -230,15 +242,25 @@ const EFFECT_HANDLERS = { if (n > 0) ctx.addFunds(n, 'doll'); }, }, + // 信标's gift: at the next round start (下个休整期) the original operator goes to the teammate picked at equip time. It + // hangs on the SENDER and runs even when the sender was eliminated in between (`afterElimination`: Match.startRound → + // EffectDispatcher.dispatchEliminated) — an eliminated player gets no onRoundStart, so the gift was lost (GitHub #86). + // A receiver eliminated meanwhile is replaced by the living teammate with the most members of the operator's bonds + // (ties at random) [ASSUMED]; with no teammate left alive the gift is dropped. The effect is removed only after the + // operator was granted: a grant that fails (no copy of it left in the shared pool, the receiver's 整备区 and temp + // full) keeps it for the next round start — the official text names no refund [ASSUMED]. builtin_gift: { + afterElimination: true, onRoundStart(ctx) { const ref = ctx.source.ref; - ctx.removeEffect(ref.id); const p = ref.params || {}; - const to = ctx.player(p.toPlayerId); - if (!to || !p.chessId) return; - const got = to.grantChess(p.chessId); - if (got) to.giftTicker(ctx.name, p.chessId); + const rec = p.chessId ? ctx.gd.chess(p.chessId) : null; + if (!rec) { ctx.removeEffect(ref.id); return; } + const to = ctx.player(p.toPlayerId) || mostBondMate(ctx, Array.isArray(p.bonds) ? p.bonds : rec.bonds || []); + if (!to) { ctx.removeEffect(ref.id); return; } + if (!to.grantChess(p.chessId)) return; + ctx.removeEffect(ref.id); + to.giftTicker(ctx.name, p.chessId); }, }, // 整备: the next purchased item becomes advanced (golden) diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index 0759fac2..98fcf5a4 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -33,6 +33,8 @@ // 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. +// An eliminated player gets no dispatch, except the persistent effects whose handler sets `afterElimination: true` +// (onRoundStart, EffectDispatcher.dispatchEliminated: 信标's gift — GitHub #86). import { itemKey } from './gamedata.js'; import { boardOrder, parseKey, tileKey } from './board.js'; @@ -239,6 +241,26 @@ export class EffectDispatcher { return ev; } + /** + * `hook` for an ELIMINATED player (Match.startRound: onRoundStart): only its persistent effects whose handler sets + * `afterElimination: true` run — 信标's gift still reaches the teammate when its sender is out (builtinMeta.js + * builtin_gift, GitHub #86). Nothing else of an eliminated player is dispatched. + */ + dispatchEliminated(ps, hook, ev = {}) { + if (!ps || ps.alive || this.depth >= MAX_DEPTH) return ev; + this.depth++; + try { + for (const ref of ps.effects.slice()) { + if (!ref || typeof ref.key !== 'string') continue; + const h = this.registry.get(ref.key); + if (h && h.afterElimination === true) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev); + } + } finally { + this.depth--; + } + return ev; + } + /** Hooks a garrison handler runs on: its eventType's hook, or what `handler.garrisonHooks(garrison)` returns. */ _garrisonHooks(h, g) { if (typeof h.garrisonHooks === 'function') { diff --git a/test/match/feedback3-gift.test.js b/test/match/feedback3-gift.test.js new file mode 100644 index 00000000..c0eb3100 --- /dev/null +++ b/test/match/feedback3-gift.test.js @@ -0,0 +1,131 @@ +// 芬's 信标 gift (server/match/builtinMeta.js use_equip_recruit_new_char_and_give_char_to_player_most_bond → +// effect:builtin_gift): act2autochess eff_acarm109 "下个休整期向相应盟约人数最多的队友发送1个原干员"; band_fang +// "原干员在下回合传递给对应盟约人数最多的队友". +// * community report #6 after 0.1.2: an ELITE carrier arrived at the teammate as the normal card — the gift is the +// original operator, an elite with its 3 pool copies; +// * GitHub #86: a sender eliminated before the next round start got no onRoundStart, so the gift was lost — it is +// delivered anyway (afterElimination); a failed grant keeps the gift for the next round start; a receiver +// eliminated meanwhile is replaced by the living teammate with the most members of the bonds [ASSUMED]. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch, give, giveItem, DATA, legalTileFor } from './harness.js'; +import { FakeBattle } from './fakeBattle.js'; +import { createRegistry } from '../../server/match/effectsMeta.js'; + +const QUIET = { warn() {}, error() {}, info() {} }; +const REG = createRegistry({ log: QUIET }); +const OK = { ok: true }; +const BEACON = 'chess_item_5_04_e_a'; + +/** Co-op match of `humans` humans at PREP R1, every board / hand emptied (pool copies returned), no strategies. */ +function setup({ humans = 2, seed = 31 } = {}) { + const h = makeMatch({ mode: 'coop', humans, seed, registry: REG, 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 equip = (item, target, pid = 'p_0') => m.handle(pid, { t: 'g.equip', itemUid: item.uid, targetUid: target.uid }); + return { h, m, equip }; +} +/** Visible normal chess (with an elite) whose 特质 are all IN_BATTLE, sorted. */ +const plain = (pred = () => true) => Object.values(DATA.chess) + .filter((c) => c.visible && !c.isGolden && c.goldenId && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && pred(c)) + .map((c) => c.chessId).sort(); +const owned = (ps) => [...ps.board.values(), ...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'chess'); +const ofBase = (m, ps, base) => owned(ps).filter((p) => m.gd.baseIdOf(p.id) === base); +const gifts = (ps) => ps.effects.filter((e) => e.key === 'effect:builtin_gift'); +/** `ps` drops to 1 LP and every field leaks one enemy (nobody perfect: no 联防), so `ps` is eliminated at the settle. */ +const knockOut = (ps) => { ps.lp = 1; FakeBattle.script = (b) => ({ leaks: Object.fromEntries(b.players.map((id) => [id, 1])) }); }; +const deploy = (m, ps, id) => give(m, ps, id, 'board', legalTileFor(m, ps, id)); + +test('信标 on an elite: the teammate receives that elite with its 3 pool copies, not the normal card (community report #6)', () => { + const { h, m, equip } = setup({ seed: 31 }); + const p0 = h.ps('p_0'), p1 = h.ps('p_1'); + const cid = plain((c) => c.tier === 3 && c.bonds.length)[0]; + const gid = DATA.chess[cid].goldenId; + const t = give(m, p0, gid, 'hand'); + assert.equal(t.poolCopies, m.gd.goldenCopies); + const left = m.pool.left(cid); + assert.deepEqual(equip(giveItem(m, p0, BEACON), t), OK); + assert.equal(p0.find(t.uid), null, 'carrier destroyed'); + assert.equal(m.pool.left(cid), left + 3, 'its 3 copies are back in the pool'); + for (const s of p0.offers[0].slots) assert.equal(DATA.chess[s.id].tier, 3, 'the pick of two stays at the carrier\'s tier'); + assert.equal(gifts(p0)[0].params.chessId, gid, 'the gift carries the elite id'); + h.toPrep(2); + const got = ofBase(m, p1, cid); + assert.equal(got.length, 1, 'gift arrived'); + assert.equal(got[0].id, gid, 'as the elite'); + assert.equal(got[0].poolCopies, 3, 'holding the 3 copies'); + assert.equal(m.pool.left(cid), left, 'taken from the pool again'); + assert.equal(gifts(p0).length, 0, 'removed once granted'); + h.invariants(); +}); + +test('信标 on a normal operator still sends the normal card', () => { + const { h, m, equip } = setup({ seed: 35 }); + const p0 = h.ps('p_0'), p1 = h.ps('p_1'); + const cid = plain((c) => c.tier === 2 && c.bonds.length)[0]; + assert.deepEqual(equip(giveItem(m, p0, BEACON), give(m, p0, cid, 'hand')), OK); + h.toPrep(2); + assert.deepEqual(ofBase(m, p1, cid).map((p) => [p.id, p.poolCopies]), [[cid, 1]]); + h.invariants(); +}); + +test('信标: the gift still arrives when its sender is eliminated before the next round start (GitHub #86)', () => { + const { h, m, equip } = setup({ seed: 33 }); + const p0 = h.ps('p_0'), p1 = h.ps('p_1'); + const cid = plain((c) => c.tier === 3 && c.bonds.length)[1]; + const gid = DATA.chess[cid].goldenId; + assert.deepEqual(equip(giveItem(m, p0, BEACON), give(m, p0, gid, 'hand')), OK); + knockOut(p0); + h.toPrep(2); + assert.equal(p0.alive, false, 'the sender was eliminated at the R1 settle'); + assert.equal(p0.eliminatedRound, 1); + assert.deepEqual(ofBase(m, p1, cid).map((p) => p.id), [gid], 'the teammate got the elite anyway'); + assert.equal(gifts(p0).length, 0, 'delivered, then removed'); + const line = h.allTo('p_1', 'm.ticker').find((t) => t.type === 'CHAR_GIFT'); + assert.ok(line && line.text.includes('P0') && line.text.includes(DATA.chess[gid].name), `CHAR_GIFT names the sender: ${line && line.text}`); + h.invariants(); +}); + +test('信标: a grant that fails (no copy left in the pool) keeps the gift for the next round start', () => { + const { h, m, equip } = setup({ seed: 37 }); + const p0 = h.ps('p_0'), p1 = h.ps('p_1'); + const cid = plain((c) => c.tier === 2 && c.bonds.length)[1]; + assert.deepEqual(equip(giveItem(m, p0, BEACON), give(m, p0, cid, 'hand')), OK); + // every copy taken by "other players" before the round start + const held = m.pool.take(cid, 99); + assert.ok(held > 0); + h.drive(() => m.phase === 'PREP' && m.round === 2); + assert.equal(ofBase(m, p1, cid).length, 0, 'nothing granted'); + assert.equal(gifts(p0).length, 1, 'the gift is kept'); + m.pool.give(cid, held); // the copies come back (sold) + h.toPrep(3); + assert.deepEqual(ofBase(m, p1, cid).map((p) => p.id), [cid], 'delivered at the next round start'); + assert.equal(gifts(p0).length, 0); + h.invariants(); +}); + +test('信标: a receiver eliminated before the round start is replaced by the living teammate with the most bond members [ASSUMED]', () => { + const { h, m, equip } = setup({ humans: 3, seed: 39 }); + const p0 = h.ps('p_0'), p1 = h.ps('p_1'), p2 = h.ps('p_2'); + const cid = plain((c) => c.tier === 3 && c.bonds.length === 1)[0]; + const bond = DATA.chess[cid].bonds[0]; + const mates = plain((c) => c.bonds.includes(bond) && c.chessId !== cid); + deploy(m, p1, mates[0]); + deploy(m, p1, mates[1]); // p1 fields the most members → picked at equip time + deploy(m, p2, mates[2]); + assert.deepEqual(equip(giveItem(m, p0, BEACON), give(m, p0, cid, 'hand')), OK); + assert.equal(gifts(p0)[0].params.toPlayerId, 'p_1'); + knockOut(p1); + h.toPrep(2); + assert.equal(p1.alive, false); + assert.deepEqual(ofBase(m, p2, cid).map((p) => p.id), [cid], 'the next teammate got it'); + assert.equal(gifts(p0).length, 0); + h.invariants(); +});