feedback3 WC: the 信标 gift sends the original operator (an elite stays an elite) and still arrives when the sender is eliminated; a failed grant waits for the next round start

This commit is contained in:
sganggs committed 2026-10-04 17:06:43 +08:00
1 parent bdb0765c55
commit ce33795d1b
6 files changed
+204 -19

No files matched your search

+5 -2
View File
@@ -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:<effectId>` handler (content) → else the family default (choices.js): bounty → `ctx.addBounty`; supply/shop →
+5
View File
@@ -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();
+2
View File
@@ -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;
+39 -17
View File
@@ -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)
+22
View File
@@ -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') {
+131
View File
@@ -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();
});