Files
sganggs 38d4c3e4b5 feedback7 R22-GHSIM: 余 【文火慢炖】 adds no layers to a bond without layers (noStack: 绝技, 独行, 调和, 协防干员) — with one such bond the only one active, nothing is added (the owner's decision of 2026-10-08); golden: unchanged
The owner's decision (relayed 2026-10-08) overrides PRTS 下半/PRTS盟约记录 「部分盟约不会显示叠加层数,但是叠加层数的特质/策略/
装备等效果仍然对其生效」 for this strategy. bands/meta.js round_start_bond_check_gain_layer skips every bonds.json noStack bond;
the 「仅激活了1个盟约」 count stays the sentence's (every active bond), so: 独行 / 绝技 alone → nothing; 独行 + one layered
bond → the layered one +12 (count2), 独行 none. Repro: 独行 alone got 36, 绝技 alone 36, 独行 + 炎 got 12 each.

Only 余: 协防 (bonds/addon/meta.js deputShip, 「使所有已激活的盟约层数+N」) and 华法琳 【重点监护】
(prep_finish_char_bond_add_layer, a random operator's bonds, 无需激活) each pick their own bonds and call addLayers; they
share no helper with 余 beyond the generic Player.addLayers sink, which every layer gain uses — left unchanged, so both
still add to noStack bonds as PRTS says. Docs: PLAYING.md 层数. Golden: unchanged (no corpus 余 at round 8 with such a bond).
2026-10-08 20:34:51 +08:00

516 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/bands/meta.js — prep side of the 40 strategies (bands, data/bands.json).
//
// Every band handler is assembled from the band's buffs by their official buff key (numbers from bb / bbStr, never
// hard-coded); keys that only act in battle (env_gbuff_new_with_verify, auto_chess_change_map) live in battle.js.
// Starting LP (totalHp) and 坎诺特's leftover funds are the match's own rules.
//
// prep_finish_char_bond_add_layer {layer} 华法琳 prep end: per distinct board tier, 1 random op → its bonds +layer (无需激活)
// band_first_self_refresh_present_char {count,bond} 杜遥夜 first 2 manual refreshes/round: ≥count <bond> chess (tier ≤ shop level)
// band_shop_refresh_copy_max_lv_char {count} 梓兰 every manual refresh: highest-tier shop chess copied into another slot, copy frozen
// preparation_start_gain_chess_from_round {round} 梓兰/小贾斯汀/昆图斯/马克维茨/变形者集群/Pith/夕/杰西卡/缪尔赛思/芬 round start of `round`: bbStr.chess
// preparation_start_gain_chess_every_n_round {round} 杜宾 every `round` rounds (round % n = 0): bbStr.chess
// give_coin_in_round {round,coin} 老鲤 income of round `round` = coin (R1/R2 0, R3 15)
// prep_start_gain_chess_from_pool_in_round {round} 老鲤 round start of `round`: 1 chess of pool
// band_coin_cost_gain_random_char_by_shop_level 绮良 every coin_cnt funds spent → count random chess ≤ shop level
// up_shop_next_refresh_must_present_bond_char 佩佩 level-up to a level of lvlist → the next manual refresh (its usual
// price) prefers <bond>
// gain_bond_char_per_round {round,preround,bond} 哈洛德 rounds round, round+preround, …: a <bond> chess (≤ shop level, else any tier)
// first_buy_in_round_char_price_change {price,bond} 休露丝 first <bond> chess of the round costs `price` (bought in the
// shop: onBuy — a free pick / grant never uses it up)
// band_cost_coin_reach_cnt_gain_chess_from_pool 潘格尼尼 once coin_cnt funds spent in total: 1 chess of pool (elite 拉特兰 ≥ T4)
// round_start_bond_check_gain_layer 余 round start of `round`: exactly factioncount active bonds → +count1, else each active +count2
// — never to a bond without layers (noStack; the owner's decision of 2026-10-08)
// up_shop_add_special_goods {count,choice,pool} 凯瑟琳 every level-up: pick 1 of `count` items of pool (free)
// coin_carry_over {capital,interest,max} 坎诺特 leftover ≥ capital at round start → +min(max, ⌊left/capital⌋×interest) income
// round_start_all_player_change_enemy_2 鸭爵 global: every alive player's battle from `round` (own + teammates;
// normal, Final Assault, Hidden Core — on the player's half of a
// pair field): 0–2 ground enemies → enemylist
// round_start_activate_char_chess_effect_in_board 铃兰 round start: 获得时 traits of the right-most (then bottom-most) board op with one
// first_sell_char_chess_exchange_char_chess_in_shop 巫恋 first sale of a normal op per round: swapped with a random shop chess (no funds)
// round_start_gain_char_chess_in_shop_every_n_round 松桐 round % n = 0: a random shop chess for free (its slot empties)
// round_start_gain_coin_by_bond_char_chess_buy 玛恩纳 each <bond> chess bought → +count funds next round (≤ max_count / round)
// refresh_shop_count_gain_coin_bond_char_chess 贾维 every refresh_count manual refreshes (match total) → <bond> chess ≤ shop level (≤ max_count / round)
// preparation_start_add_special_goods_every_n_round 娜仁图亚 round % n = 0: pick choice_cnt of refresh_cnt items of pool (free)
// "进入休整期时" / "回合开始时" = onRoundStart (research 01 §5 step 6).
import { GEO } from '../../../../shared/constants.js';
import { buffsOf, num, bandRecord, gameData } from '../support/index.js';
import { metaBonds } from '../support/meta.js';
import * as garrisons from '../garrisons.js';
const int = (v, d = 0) => Math.trunc(num(v, d));
const list = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const hasBond = (ctx, id, bond) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bond)); };
const isNormalChess = (ctx, id) => { const c = ctx.gd.chess(id); return !!(c && !c.isGolden); };
/** Per-round counter: reset lazily when the round changes. */
function roundCounter(ctx, key) {
if (ctx.counter(`${key}:r`) !== ctx.round) { ctx.setCounter(`${key}:r`, ctx.round); ctx.setCounter(key, 0); }
return ctx.counter(key);
}
const bumpRound = (ctx, key, n = 1) => { roundCounter(ctx, key); return ctx.incCounter(key, n); };
/** Grant a chess or an item by id. */
function grant(ctx, id, opts = {}) {
if (ctx.gd.item(id)) return ctx.grantItem(id, opts);
if (ctx.gd.chess(id)) return ctx.grantChess(id, opts);
return null;
}
/** Indices of unsold chess slots of the shop. */
const chessSlots = (ctx) => ctx.shopSlots().map((s, i) => (s && s.kind === 'chess' && !s.sold ? i : -1)).filter((i) => i >= 0);
/** Up to `n` distinct item ids of an equip pool. */
function rollItems(ctx, poolId, n) {
const out = [];
for (let k = 0; k < n * 6 && out.length < n; k++) {
const r = ctx.rollPool(poolId);
if (!r || r.kind !== 'item') break;
if (!out.includes(r.id)) out.push(r.id);
}
return out;
}
/** Random chess of `bond`, tier ≤ shop level (fallback: any tier when `anyTier`). */
function rollBond(ctx, bond, { anyTier = false } = {}) {
return ctx.rollChess({ bond, maxTier: Math.max(1, ctx.shopLevel()) }) || (anyTier ? ctx.rollChess({ bond, maxTier: 6 }) : null);
}
// ---------------------------------------------------------------------------------------------------------------
// buff key → (params[], bandId) → hooks
const K = {};
K.prep_finish_char_bond_add_layer = (ps) => ({
onPrepEnd(ctx) {
const n = int(ps[0].layer, 0);
if (n <= 0) return;
const byTier = new Map();
for (const p of ctx.board()) {
if (!p || p.kind !== 'chess') continue;
const t = p.tier ?? ctx.gd.tierOf(p.id);
if (!byTier.has(t)) byTier.set(t, []);
byTier.get(t).push(p);
}
for (const t of [...byTier.keys()].sort((a, b) => a - b)) {
const p = ctx.rng.pick(byTier.get(t));
for (const b of metaBonds(ctx, p)) ctx.addLayers(b, n, { requireActive: false, reason: 'band' });
}
},
});
K.band_first_self_refresh_present_char = (ps) => ({
onRefresh(ctx) {
const p = ps[0];
if ((ctx.roundStats().refreshes || 0) > 2) return;
const need = Math.max(1, int(p.count, 1));
const idx = chessSlots(ctx);
const slots = ctx.shopSlots();
let have = idx.filter((i) => hasBond(ctx, slots[i].id, p.bond)).length;
const others = ctx.rng.shuffle(idx.filter((i) => !hasBond(ctx, slots[i].id, p.bond)));
for (const i of others) {
if (have >= need) break;
const id = rollBond(ctx, p.bond);
if (!id) break;
ctx.setShopSlot(i, { kind: 'chess', id });
have++;
}
},
});
K.band_shop_refresh_copy_max_lv_char = () => ({
onRefresh(ctx) {
const slots = ctx.shopSlots();
const idx = chessSlots(ctx);
if (idx.length < 2) return;
let best = -1;
for (const i of idx) best = Math.max(best, ctx.gd.tierOf(slots[i].id));
const src = ctx.rng.pick(idx.filter((i) => ctx.gd.tierOf(slots[i].id) === best));
const dst = ctx.rng.pick(idx.filter((i) => i !== src));
ctx.setShopSlot(dst, { kind: 'chess', id: slots[src].id, frozen: true });
},
});
K.preparation_start_gain_chess_from_round = (ps) => ({
onRoundStart(ctx) {
for (const p of ps) {
const id = p.chess ?? p.chess_id;
if (ctx.round !== int(p.round, -1) || !id) continue;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) grant(ctx, id, { requirePool: false, source: 'band' });
}
},
});
K.preparation_start_gain_chess_every_n_round = (ps) => ({
onRoundStart(ctx) {
for (const p of ps) {
const n = int(p.round, 0);
const id = p.chess ?? p.chess_id;
if (n <= 0 || ctx.round % n !== 0 || !id) continue;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) grant(ctx, id, { source: 'band' });
}
},
});
K.give_coin_in_round = (ps) => ({
onIncome(ctx, ev) {
for (const p of ps) if (ctx.round === int(p.round, -1)) ev.income = Math.max(0, int(p.coin, 0));
},
});
K.prep_start_gain_chess_from_pool_in_round = (ps) => ({
onRoundStart(ctx) {
for (const p of ps) {
if (ctx.round !== int(p.round, -1)) continue;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) {
const r = ctx.rollPool(p.pool);
if (r) grant(ctx, r.id, { source: 'band' });
}
}
},
});
K.band_coin_cost_gain_random_char_by_shop_level = (ps) => ({
onSpend(ctx, ev) {
const p = ps[0];
const step = int(p.coin_cnt, 0);
if (step <= 0 || !(ev.amount > 0)) return;
let acc = ctx.incCounter('band:kirara:acc', ev.amount);
while (acc >= step) {
acc = ctx.incCounter('band:kirara:acc', -step);
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) {
const id = ctx.rollChess({ maxTier: Math.max(1, ctx.shopLevel()) });
if (id) ctx.grantChess(id, { source: 'band' });
}
}
},
});
// 佩佩 博学多通 "升级调度中心至2、4和6级后,获得1次特殊刷新:此次刷新出现的干员优先为<萨尔贡>干员": the special refresh is the
// player's next manual refresh, paid as usual — the text gives no 免费 (德克萨斯's trait says 「获得1次免费刷新」 for a free
// one), and the community report of 2026-10-06 「佩佩策略的特殊刷新也是要花钱的,不是免费」. Until 0.2.0 a free refresh came
// with it (blackboard `price` 0 read as its price) [ASSUMED: `cnt` / `price` 0 change nothing].
K.up_shop_next_refresh_must_present_bond_char = (ps) => ({
onLevelUp(ctx, ev) {
const p = ps[0];
if (!list(p.lvlist).map(Number).includes(ev.level)) return;
ctx.incCounter('band:pepe:special', 1);
},
onRefresh(ctx) {
if (ctx.counter('band:pepe:special') <= 0) return;
ctx.incCounter('band:pepe:special', -1);
const bond = ps[0].bond;
const slots = ctx.shopSlots();
for (const i of chessSlots(ctx)) {
if (hasBond(ctx, slots[i].id, bond)) continue;
const id = rollBond(ctx, bond);
if (!id) break;
ctx.setShopSlot(i, { kind: 'chess', id });
}
},
});
K.gain_bond_char_per_round = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
const r0 = int(p.round, 1), every = Math.max(1, int(p.preround, 1));
if (ctx.round < r0 || (ctx.round - r0) % every !== 0) return;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) {
const id = rollBond(ctx, p.bond, { anyTier: true });
if (id) ctx.grantChess(id, { source: 'band' });
}
},
});
K.first_buy_in_round_char_price_change = (ps) => ({
onPrice(ctx, ev) {
const p = ps[0];
if (ev.kind !== 'chess' || !hasBond(ctx, ev.id, p.bond)) return;
if (ctx.counter('band:sciurus:r') === ctx.round) return;
ctx.setPrice(Math.min(num(ev.price, 0), int(p.price, 1)));
},
onBuy(ctx, ev) {
if (ev.kind === 'chess' && hasBond(ctx, ev.slot.id, ps[0].bond)) ctx.setCounter('band:sciurus:r', ctx.round);
},
});
K.band_cost_coin_reach_cnt_gain_chess_from_pool = (ps) => ({
onSpend(ctx, ev) {
const p = ps[0];
if (ctx.counter('band:paganini:done') > 0) return;
const spent = ctx.incCounter('band:paganini:spent', Math.max(0, int(ev.amount, 0)));
if (spent < int(p.coin_cnt, 0)) return;
const r = ctx.rollPool(p.pool);
if (r && grant(ctx, r.id, { source: 'band' })) ctx.setCounter('band:paganini:done', 1);
},
});
/**
* 余 【文火慢炖】 「第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层」. The owner's decision of 2026-10-08
* overrides PRTS here (下半/PRTS盟约记录: layers of a bond that shows none 「仍然对其生效」): a bond without layers — bonds.json
* `noStack`: 绝技, 独行, 调和, 协防干员 — gets none. The count stays the sentence's (every active bond), so one active bond
* that is such a bond means nothing is added, and with two active bonds a layered one still gets count2. Only this
* strategy: 协防's 「使所有已激活的盟约层数」 (bonds/addon/meta.js) and 华法琳's 【重点监护】 keep adding to every bond they name.
*/
K.round_start_bond_check_gain_layer = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
if (ctx.round !== int(p.round, -1)) return;
const active = Object.entries(ctx.bonds()).filter(([, b]) => b && b.active).map(([id]) => id);
const n = active.length === int(p.factioncount, 1) ? int(p.count1, 0) : int(p.count2, 0);
for (const b of active) {
if (ctx.gd.bond(b)?.noStack) continue;
ctx.addLayers(b, n, { requireActive: true, reason: 'band' });
}
},
});
K.up_shop_add_special_goods = (ps) => ({
onLevelUp(ctx) {
const p = ps[0];
const ids = rollItems(ctx, p.pool, Math.max(1, int(p.count, 3)));
if (ids.length) ctx.offerItems(ids, { source: 'band' });
},
});
K.coin_carry_over = (ps) => ({
onIncome(ctx, ev) {
const p = ps[0];
const cap = int(p.capital, 0), left = ctx.funds();
if (cap <= 0 || left < cap) return;
const bonus = Math.min(int(p.max, Infinity), Math.floor(left / cap) * int(p.interest, 0));
if (bonus > 0) ev.income = int(ev.income, 0) + bonus;
},
});
K.round_start_activate_char_chess_effect_in_board = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
const event = p.event_type || 'SERVER_GAIN';
const cands = ctx.board().filter((v) => v && v.kind === 'chess' && ctx.garrisonsOf(v.uid).some((g) => g.eventType === event));
if (!cands.length) return;
cands.sort((a, b) => (b.col - a.col) || (a.row - b.row));
for (const v of cands.slice(0, Math.max(1, int(p.count, 1)))) {
if (event === 'SERVER_GAIN' && typeof garrisons.triggerGainEffects === 'function') garrisons.triggerGainEffects(ctx, v);
else ctx.triggerGarrisons(v.uid, event);
}
},
});
K.first_sell_char_chess_exchange_char_chess_in_shop = (ps) => ({
onSold(ctx, ev) {
const piece = ev.piece;
if (!piece || piece.kind !== 'chess' || !isNormalChess(ctx, piece.id)) return;
if (roundCounter(ctx, 'band:vodfox') >= Math.max(1, int(ps[0].count, 1))) return;
const slots = ctx.shopSlots();
const idx = chessSlots(ctx);
if (!idx.length) return;
const i = ctx.rng.pick(idx);
const got = ctx.grantChess(slots[i].id, { source: 'band' });
if (!got) return;
ctx.setShopSlot(i, { kind: 'chess', id: piece.id });
ev.gain = 0;
bumpRound(ctx, 'band:vodfox');
},
});
K.round_start_gain_char_chess_in_shop_every_n_round = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
const n = int(p.round, 0);
if (n <= 0 || ctx.round % n !== 0) return;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) {
const idx = chessSlots(ctx);
if (!idx.length) return;
const i = ctx.rng.pick(idx);
if (ctx.grantChess(ctx.shopSlots()[i].id, { source: 'band' })) ctx.setShopSlot(i, null);
}
},
});
K.round_start_gain_coin_by_bond_char_chess_buy = (ps) => ({
onBuy(ctx, ev) {
const p = ps[0];
if (ev.kind !== 'chess' || !hasBond(ctx, ev.slot.id, p.bond)) return;
const n = int(p.count, 1), max = int(p.max_count, Infinity);
const used = roundCounter(ctx, 'band:mlynar');
const add = Math.min(n, max - used);
if (add <= 0) return;
ctx.addPendingFunds(add);
bumpRound(ctx, 'band:mlynar', add);
},
});
K.refresh_shop_count_gain_coin_bond_char_chess = (ps) => ({
onRefresh(ctx) {
const p = ps[0];
const every = int(p.refresh_count, 0);
if (every <= 0) return;
if (ctx.incCounter('band:chiave:refreshes', 1) % every !== 0) return;
if (roundCounter(ctx, 'band:chiave') >= int(p.max_count, Infinity)) return;
const id = rollBond(ctx, p.bond);
if (id && ctx.grantChess(id, { source: 'band' })) bumpRound(ctx, 'band:chiave');
},
});
K.preparation_start_add_special_goods_every_n_round = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
const n = int(p.round, 0);
if (n <= 0 || ctx.round % n !== 0) return;
const ids = rollItems(ctx, p.pool, Math.max(1, int(p.refresh_cnt, 2)));
for (let k = 0; k < Math.max(1, int(p.choice_cnt, 1)) && ids.length; k++) ctx.offerItems(ids, { source: 'band' });
},
});
// ---------------------------------------------------------------------------------------------------------------
// 鸭爵 “神秘顾客”: "从第5回合起,你和队友遭遇的部分敌人可能会替换为<鸭爵><高普尼克><流泪小子><圆仔>,击倒这些敌人者获得1资金奖励
// (包括联防阶段)". A global handler rewrites the battle spawn list of every alive player while the band is held by that
// player or a teammate: from round `round`, in the normal battle, the Final Assault and the Hidden Core (PRTS 卫戍协议:盟约
// 下半/PRTS盟约记录 鸭爵 备注 "每回合将有0~2名敌人被替换为上述敌人之一…且在最终回合和隐秘核心回合中仍然生效"; the 联防 phase
// replaces nothing: its enemies are leaks, which keep their bounty). min..max (0–2, uniform [ASSUMED]) of the player's
// ground enemies from the `minweight`–`maxweight` share of the player's enemies (time order) [ASSUMED reading of the two
// weights] — any wave enemy but the leader, its parts, bounties, earlier swaps and uncounted units. Elites are eligible
// too [ASSUMED: the text names no rank; one rule for every battle; about two thirds of the leader rounds' escorts in that
// share are elites, so a NORMAL-only rule would leave most Final Assault halves without a candidate — ≈ 80 % instead of
// ≈ 36 % of the R14 pair-field halves over 40 seeds]. Flyers are never swapped [ASSUMED: the four are ground units and a
// FLY action's route is a FLY route]. A swapped unit becomes a random enemylist enemy (the act2 `_2` versions) at the
// replaced unit's time and route, worth DUCK_COINS to its killer (bounty, also in 联防). A pair boss field has one spawn
// list that its two players' handlers edit in turn; each player's 0–2 come from the enemies whose route ends on that
// player's half (`ev.side` + `ev.routes`, the half rule of the field's bounties, server/match/waves.js routeByMotion), the
// share taken over that half [ASSUMED: "你和队友遭遇的敌人" — the enemies heading for the player's own protection point;
// each official client simulates its own battle, docs/research/08 appendix A]. The originals never exist, so none of
// their death / kill / leak effects happen. Reaching the protection point costs 1 LP ("但进入保护目标点将减少1点目标生命值":
// the enemies' data lpr, tools/build-data.mjs). The swaps carry the spawn tag 'duck': their 隐匿 (流泪小子) comes back no
// sooner than 1 s after a block ends, not the official 0 s (content/enemies/helpers.js DUCK_STEALTH_RESTORE — the owner's
// decision of 2026-10-07, a deliberate deviation).
export const DUCK_BAND = 'band_ducklord';
/** "击倒这些敌人者获得1资金奖励" (the blackboard's `count` is 1 too). */
export const DUCK_COINS = 1;
/** Battle kinds the swap applies to (PRTS: the Final Assault and the Hidden Core too). */
const DUCK_KINDS = new Set(['normal', 'boss', 'hidden']);
/** Boss-field column between the left ('L') and the right ('R') player's half (the goals are at cols 2–3 / 17). */
const BOSS_MID_COL = (GEO.BOSS_RECT.c0 + GEO.BOSS_RECT.c1) / 2;
function duckParams() {
for (const b of buffsOf(bandRecord(DUCK_BAND))) if (b.key === 'round_start_all_player_change_enemy_2') return b.p;
return null;
}
/**
* Rewrite `spawns` in place (exported for tests). Returns the replaced spawn specs. `side` ('L' | 'R', a pair boss
* field) limits the swap to the spawns whose route (`routes[routeIndex]`) ends on that half of the field.
*/
export function duckReplace(ctx, spawns, p, ownerPlayerId, { routes = null, side = null } = {}) {
const gd = ctx.gd;
const ducks = list(p.enemylist).filter((k) => gd.enemy(k));
if (!ducks.length || !Array.isArray(spawns)) return [];
const lo = Math.max(0, int(p.min, 0)), hi = Math.max(lo, int(p.max, lo));
const want = lo + ctx.rng.int(hi - lo + 1);
if (want <= 0) return [];
const half = side === 'L' || side === 'R';
const onHalf = (s) => {
if (!half) return true;
const r = Array.isArray(routes) ? routes[s.routeIndex] : null;
const c = r && Array.isArray(r.end) ? Number(r.end[1]) : NaN;
return side === 'R' ? c > BOSS_MID_COL : c < BOSS_MID_COL;
};
// one entry per enemy of the player (split multi-count specs), in spawn-time order
const flat = [];
for (let i = 0; i < spawns.length; i++) {
const s = spawns[i];
if (!s || !onHalf(s)) continue;
const cnt = Math.max(1, int(s.count, 1));
for (let k = 0; k < cnt; k++) flat.push({ i, k, t: num(s.time, 0) + k * num(s.interval, 0) });
}
flat.sort((a, b) => a.t - b.t || a.i - b.i || a.k - b.k);
const w0 = num(p.minweight, 0), w1 = num(p.maxweight, 1);
const n = flat.length;
const ok = flat.filter((f, j) => {
const s = spawns[f.i];
const e = gd.enemy(s.enemyKey);
const frac = n > 1 ? j / (n - 1) : 0;
return !s.tag && s.countInTotal !== false && e && e.rank !== 'BOSS' && !e.notCountInTotal && e.stats?.motion !== 'FLY'
&& frac >= w0 - 1e-9 && frac <= w1 + 1e-9;
});
const picked = ctx.rng.shuffle(ok).slice(0, want);
if (!picked.length) return [];
const byIdx = new Map();
for (const f of picked) { if (!byIdx.has(f.i)) byIdx.set(f.i, new Set()); byIdx.get(f.i).add(f.k); }
const out = [];
const add = [];
for (const [i, ks] of byIdx) {
const s = spawns[i];
const cnt = Math.max(1, int(s.count, 1));
const keep = cnt - ks.size;
for (const k of ks) {
const d = {
...s, mods: s.mods ? { ...s.mods } : undefined, count: 1, interval: 0,
time: num(s.time, 0) + k * num(s.interval, 0), enemyKey: ctx.rng.pick(ducks), tag: 'duck',
bounty: { coins: DUCK_COINS, ownerPlayerId },
};
if (d.mods) delete d.mods.slot;
add.push(d);
out.push(d);
}
if (keep <= 0) spawns[i] = null;
else if (ks.size) {
// keep the unreplaced copies of a multi-count spec as single specs at their original times
const rest = [];
for (let k = 0; k < cnt; k++) if (!ks.has(k)) rest.push({ ...s, mods: s.mods ? { ...s.mods } : undefined, count: 1, interval: 0, time: num(s.time, 0) + k * num(s.interval, 0) });
spawns[i] = null;
add.push(...rest);
}
}
for (let i = spawns.length - 1; i >= 0; i--) if (spawns[i] == null) spawns.splice(i, 1);
spawns.push(...add);
return out;
}
const duckGlobal = {
onBattleStart(ctx, ev) {
if (!ev || !DUCK_KINDS.has(ev.kind) || !Array.isArray(ev.spawns)) return;
const p = duckParams();
if (!p || ctx.round < int(p.round, 1)) return;
const held = ctx.bandId() === DUCK_BAND || ctx.teammates().some((t) => t.bandId() === DUCK_BAND);
if (!held) return;
duckReplace(ctx, ev.spawns, p, ctx.playerId, { routes: ev.routes, side: ev.side });
},
};
// ---------------------------------------------------------------------------------------------------------------
/** Merge hook objects (same hook → called in order). */
function merge(parts) {
const out = {};
for (const h of parts) {
for (const [name, fn] of Object.entries(h)) {
if (typeof fn !== 'function') continue;
const prev = out[name];
out[name] = prev ? function merged(ctx, ev) { prev(ctx, ev); fn(ctx, ev); } : fn;
}
}
return out;
}
/** Hooks of one band (null when it has no prep effect). */
export function bandMetaHandler(bandId) {
const groups = new Map();
for (const b of buffsOf(bandRecord(bandId))) {
if (!b.key || !K[b.key]) continue;
if (!groups.has(b.key)) groups.set(b.key, []);
groups.get(b.key).push(b.p);
}
if (!groups.size) return null;
return merge([...groups].map(([k, ps]) => K[k](ps, bandId)));
}
export function registerMeta(registry) {
for (const id of Object.keys(gameData().bands || {})) {
const h = bandMetaHandler(id);
if (h) registry.band(id, h);
}
registry.global('bands_ducklord', duckGlobal);
}