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:
1 parent
bdb0765c55
commit
ce33795d1b
6 files changed
+204
-19
No files matched your search
+5
-2
@@ -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 →
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user