卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,269 @@
|
||||
// server/sim/content/bands/battle.js — battle side of the strategies (bands, data/bands.json; research 01 §11).
|
||||
//
|
||||
// Each player of a battle installs the IN_BATTLE parts of its own band, dispatched on the band buff's bbStr.key
|
||||
// (numbers from the blackboard, never hard-coded). Band buffs apply in every battle kind (normal / 联防 / boss /
|
||||
// hidden); IN_BATTLE layer gains are no-ops where the match disables them (support.gainLayers).
|
||||
//
|
||||
// act1autochess_band2_buff 阿米娅 众志合一 ≥ value_i active bonds (highest i) ⇒ every operator ATK × (1 + atk_i),
|
||||
// HP × (1 + max_hp_i) (直接乘算, bonds snapshot at combat start)
|
||||
// act1autochess_band28_buff 埃芒加德 命结之秘 the first max_respawn_cnt operator knock-downs of the battle revive at
|
||||
// once at full HP (runs after every other death saver, items included)
|
||||
// act1autochess_band13_buff 克莱门莎 崇高牺牲 a <bond_id> operator knocked down ⇒ +its tier (等阶) <bond_id> layers
|
||||
// (bond_add_type by_charlevel; no "已激活" in the text ⇒ requireActive false)
|
||||
// act1autochess_band16_buff 大帝 加急调派 "每次部署后再部署时间减少50%": every deployment of an operator stacks one
|
||||
// redeploy ×(1 + respawn_time) for the rest of the battle [ASSUMED cumulative:
|
||||
// "每次" — the first redeploy is −50 % under either reading]
|
||||
// act1autochess_band17_buff 桑葚 药枚实验 at combat start the player's units on the front-most (最右边) column: each
|
||||
// attack has `prob` to gain 1 shield layer (max 1)
|
||||
// act1autochess_band18_buff 休谟斯 回收利用 a ground (地面) operator's skill ends ⇒ a random operator on its 4
|
||||
// neighbouring tiles gains `sp` SP
|
||||
// act1autochess_band15_buff 卡莱莎 食腐之蝶 an operator knocked down ⇒ every other unit of the player on the field
|
||||
// ATK × (1 + atk × n), n ≤ max_stack_cnt (+20 % … +200 %, additive stacks),
|
||||
// until that unit is knocked down itself or the battle ends
|
||||
// act1autochess_band19_buff 陈 以己之长 the player's operators' physical / arts damage becomes 弱点伤害
|
||||
// act2autochess_band12_buff 夕 墨色真颜 at combat start operators of which ≥ 2 of the same name (same base chess,
|
||||
// elite or not) stand on the field get ATK × (1 + atk)
|
||||
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
|
||||
// ATK × (1 + atk_per_cnt × n), HP × (1 + max_hp_per_cnt × n)
|
||||
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match
|
||||
// holds band_amedic, every player's field gets the map character of the
|
||||
// variant whose common_condition holds for that player's elites on the field
|
||||
// (condition_golden_chess_le cnt → 预备干员-医疗, _ge cnt → Touch), spawned at
|
||||
// combat start on its stage slot (tokens.spawnMapChar)
|
||||
// Prep-only bands (economy / shop / grants / layers at prep end or round start) live in bands/meta.js.
|
||||
|
||||
import {
|
||||
num, buffsOf, bandRecord, isOp, onField, isElite, tierOf, unitBonds, activeBondIds, playerOps, passiveBuff, fxOn,
|
||||
matchBands, gainLayers, alliesAround, N4, baseChessId, isGroundOp,
|
||||
} from '../support/index.js';
|
||||
import { weaknessRetype, addShieldLayer, PRIO_REVIVE } from '../items/battle.js';
|
||||
import { spawnMapChar } from '../tokens.js';
|
||||
|
||||
export const AMEDIC_BAND = 'band_amedic';
|
||||
/** 'fatal' priority of 埃芒加德: after the operators' own items (PRIO_REVIVE) and every talent / skill saver. */
|
||||
export const PRIO_BAND_REVIVE = PRIO_REVIVE - 10;
|
||||
|
||||
const keyOf = (bandId, part = '') => `band:${bandId}${part ? `:${part}` : ''}`;
|
||||
const deployedOps = (battle, pid) => playerOps(battle, pid, { fieldOnly: true });
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// band buff behaviours: (battle, ps, params, bandId)
|
||||
|
||||
const BY_KEY = {
|
||||
// 阿米娅 众志合一
|
||||
act1autochess_band2_buff(battle, ps, p, bandId) {
|
||||
const n = activeBondIds(battle, ps.playerId).length;
|
||||
let atk = 0, hp = 0;
|
||||
for (let i = 1; i <= 9 && p[`value_${i}`] != null; i++) {
|
||||
if (n >= num(p[`value_${i}`], Infinity)) { atk = num(p[`atk_${i}`]); hp = num(p[`max_hp_${i}`]); }
|
||||
}
|
||||
if (!(atk > 0 || hp > 0)) return;
|
||||
const mods = {};
|
||||
if (atk) mods.atkMul = 1 + atk;
|
||||
if (hp) mods.hpMul = 1 + hp;
|
||||
for (const u of playerOps(battle, ps.playerId)) passiveBuff(battle, u, keyOf(bandId), mods);
|
||||
},
|
||||
|
||||
// 埃芒加德 命结之秘
|
||||
act1autochess_band28_buff(battle, ps, p, bandId) {
|
||||
const max = Math.floor(num(p.max_respawn_cnt, 3));
|
||||
if (!(max > 0)) return;
|
||||
let used = 0;
|
||||
battle.on('fatal', (c) => {
|
||||
const u = c.unit;
|
||||
if (c.prevented || used >= max || !isOp(u) || u.ownerId !== ps.playerId || !u.alive) return;
|
||||
used++;
|
||||
c.prevented = true;
|
||||
u.hp = u.s.maxHp;
|
||||
fxOn(battle, 'revive', u, keyOf(bandId), bandId, { left: max - used });
|
||||
}, { priority: PRIO_BAND_REVIVE });
|
||||
},
|
||||
|
||||
// 克莱门莎 崇高牺牲
|
||||
act1autochess_band13_buff(battle, ps, p, bandId) {
|
||||
const bond = typeof p.bond_id === 'string' && p.bond_id ? p.bond_id : null;
|
||||
if (!bond) return;
|
||||
const byLevel = p.bond_add_type == null || p.bond_add_type === 'by_charlevel';
|
||||
battle.on('death', (c) => {
|
||||
const u = c.unit;
|
||||
if (c.reason !== 'killed' || !isOp(u) || u.ownerId !== ps.playerId || !unitBonds(u).includes(bond)) return;
|
||||
const n = byLevel ? tierOf(u) : Math.max(1, Math.floor(num(p.value, 1)));
|
||||
if (gainLayers(battle, { playerId: ps.playerId, bonds: bond, n, requireActive: false, source: u, reason: 'band' }) > 0) {
|
||||
fxOn(battle, 'layer', u, keyOf(bandId), bandId, { bond, n });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 大帝 加急调派
|
||||
act1autochess_band16_buff(battle, ps, p, bandId) {
|
||||
const mul = Math.max(0, 1 + num(p.respawn_time, 0));
|
||||
if (mul === 1) return;
|
||||
const key = keyOf(bandId);
|
||||
battle.on('deploy', (c) => {
|
||||
const u = c.unit;
|
||||
if (!isOp(u) || u.ownerId !== ps.playerId) return;
|
||||
battle.addBuff(u, { key, mods: { redeployMul: mul }, refresh: 'stack', stacks: 1, maxStacks: 20, persist: true, allowDead: true });
|
||||
});
|
||||
},
|
||||
|
||||
// 桑葚 药枚实验
|
||||
act1autochess_band17_buff(battle, ps, p, bandId) {
|
||||
const pr = num(p.prob);
|
||||
if (!(pr > 0)) return;
|
||||
const key = keyOf(bandId, 'shield');
|
||||
const chosen = new Set();
|
||||
battle.on('battleStart', () => {
|
||||
const units = battle.allies(ps.playerId);
|
||||
// "最右边的所有我方单位": the rightmost column of the player's own board (a board position, whatever the units'
|
||||
// directions; the mirrored Final Assault right side counts from the field's left)
|
||||
const boardCol = (u) => (ps.mirror ? -u.tileC : u.tileC);
|
||||
let front = -Infinity;
|
||||
for (const u of units) front = Math.max(front, boardCol(u));
|
||||
for (const u of units) if (boardCol(u) === front) chosen.add(u);
|
||||
});
|
||||
battle.on('attack', (c) => {
|
||||
const u = c.attacker;
|
||||
if (!chosen.has(u) || !u.alive) return;
|
||||
if (!(pr >= 1 || battle.rng() < pr)) return;
|
||||
if (addShieldLayer(battle, u, key, 1)) fxOn(battle, 'shield', u, keyOf(bandId), bandId);
|
||||
});
|
||||
},
|
||||
|
||||
// 休谟斯 回收利用
|
||||
act1autochess_band18_buff(battle, ps, p, bandId) {
|
||||
const sp = num(p.sp);
|
||||
if (!(sp > 0)) return;
|
||||
battle.on('skillEnd', (c) => {
|
||||
const u = c.unit;
|
||||
if (!isOp(u) || u.ownerId !== ps.playerId || c.reason === 'death' || !onField(u) || !isGroundOp(u)) return;
|
||||
const cands = alliesAround(battle, u, N4).filter((a) => a.kind === 'op' && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive');
|
||||
const t = cands.length ? battle.rng.pick(cands) : null;
|
||||
if (!t) return;
|
||||
t.skill.gainSp(sp, 'band');
|
||||
fxOn(battle, 'spGain', t, keyOf(bandId), bandId, { n: sp, from: u.id });
|
||||
});
|
||||
},
|
||||
|
||||
// 卡莱莎 食腐之蝶
|
||||
act1autochess_band15_buff(battle, ps, p, bandId) {
|
||||
const step = num(p.atk);
|
||||
const cap = Math.max(1, Math.floor(num(p.max_stack_cnt, 10)));
|
||||
if (!(step > 0)) return;
|
||||
const key = keyOf(bandId);
|
||||
battle.on('death', (c) => {
|
||||
const d = c.unit;
|
||||
if (c.reason !== 'killed' || !isOp(d) || d.ownerId !== ps.playerId) return;
|
||||
for (const a of battle.allies(ps.playerId)) {
|
||||
if (a === d) continue;
|
||||
const cur = a.findBuff(key);
|
||||
const n = Math.min(cap, (cur && cur.data ? cur.data.n : 0) + 1);
|
||||
battle.addBuff(a, { key, mods: { atkMul: 1 + step * n }, refresh: 'replace', data: { n }, visible: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 陈 以己之长
|
||||
act1autochess_band19_buff(battle, ps) {
|
||||
battle.on('hit', (c) => {
|
||||
const s = c.source;
|
||||
if (!isOp(s) || s.ownerId !== ps.playerId || !c.target || c.target.side !== 'enemy') return;
|
||||
weaknessRetype(c.dmg, s, c.target);
|
||||
}, { priority: 5 });
|
||||
},
|
||||
|
||||
// 夕 墨色真颜
|
||||
act2autochess_band12_buff(battle, ps, p, bandId) {
|
||||
const atk = num(p.atk);
|
||||
if (!atk) return;
|
||||
battle.on('battleStart', () => {
|
||||
const groups = new Map();
|
||||
for (const u of deployedOps(battle, ps.playerId)) {
|
||||
const b = baseChessId(u);
|
||||
if (!groups.has(b)) groups.set(b, []);
|
||||
groups.get(b).push(u);
|
||||
}
|
||||
for (const list of groups.values()) {
|
||||
if (list.length < 2) continue;
|
||||
for (const u of list) passiveBuff(battle, u, keyOf(bandId), { atkMul: 1 + atk });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 伊奥莱塔 统御号令
|
||||
act2autochess_band3_buff(battle, ps, p, bandId) {
|
||||
const a = num(p.atk_per_cnt), h = num(p.max_hp_per_cnt);
|
||||
if (!(a > 0 || h > 0)) return;
|
||||
battle.on('battleStart', () => {
|
||||
const elites = deployedOps(battle, ps.playerId).filter(isElite);
|
||||
const n = elites.length;
|
||||
if (!n) return;
|
||||
const mods = {};
|
||||
if (a) mods.atkMul = 1 + a * n;
|
||||
if (h) mods.hpMul = 1 + h * n;
|
||||
for (const u of elites) passiveBuff(battle, u, keyOf(bandId), mods);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Touch 外勤医疗 (auto_chess_change_map, every player's field)
|
||||
|
||||
/** Map-character variants of band_amedic: [{ cond, cnt, chars: [tokenId…] }]. */
|
||||
export function amedicVariants() {
|
||||
const out = [];
|
||||
for (const b of buffsOf(bandRecord(AMEDIC_BAND))) {
|
||||
if (b.key !== 'auto_chess_change_map') continue;
|
||||
const chars = new Set();
|
||||
for (const [k, v] of Object.entries(b.p)) if (k.includes('#') && num(v) > 0) chars.add(k.split('#')[0]);
|
||||
out.push({ cond: String(b.p.common_condition || ''), cnt: num(b.p.cnt, 0), chars: [...chars] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map characters for a player with `elites` elite operators on the field. */
|
||||
export function amedicCharsFor(elites, variants = amedicVariants()) {
|
||||
for (const v of variants) {
|
||||
const ok = /_ge$/.test(v.cond) ? elites >= v.cnt : /_le$/.test(v.cond) ? elites <= v.cnt : false;
|
||||
if (ok) return v.chars;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function installMapChars(battle) {
|
||||
const variants = amedicVariants();
|
||||
if (!variants.length) return;
|
||||
const holders = battle.players.filter((ps) => matchBands(battle, ps.playerId).includes(AMEDIC_BAND));
|
||||
if (!holders.length) return;
|
||||
battle.on('battleStart', () => {
|
||||
for (const ps of holders) {
|
||||
const elites = deployedOps(battle, ps.playerId).filter(isElite).length;
|
||||
for (const id of amedicCharsFor(elites, variants)) {
|
||||
const u = spawnMapChar(battle, ps.playerId, id);
|
||||
if (u) fxOn(battle, 'summon', u, keyOf(AMEDIC_BAND), AMEDIC_BAND);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Band buff keys with a battle part (tests / tooling). */
|
||||
export const BATTLE_BUFF_KEYS = Object.freeze([...Object.keys(BY_KEY), 'auto_chess_change_map']);
|
||||
|
||||
/** Does this band have a battle part? */
|
||||
export function hasBattlePart(bandId) {
|
||||
return buffsOf(bandRecord(bandId)).some((b) => (b.bbKey && BY_KEY[b.bbKey]) || b.key === 'auto_chess_change_map');
|
||||
}
|
||||
|
||||
export function install(battle) {
|
||||
for (const ps of battle.players) {
|
||||
const band = ps.bandId ? bandRecord(ps.bandId) : null;
|
||||
if (!band) continue;
|
||||
for (const b of buffsOf(band)) {
|
||||
const f = b.bbKey ? BY_KEY[b.bbKey] : null;
|
||||
if (!f) continue;
|
||||
try { f(battle, ps, b.p, ps.bandId); } catch (e) { battle._handlerError?.(`band:${ps.bandId}`, null, e); }
|
||||
}
|
||||
}
|
||||
installMapChars(battle);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// 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 → +1 free refresh (price 0) that 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`
|
||||
// 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
|
||||
// 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 normal battle from `round` (own + teammates)
|
||||
// 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 { 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' });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
if (int(p.price, 0) === 0) ctx.grantFreeRefresh(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);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
if (active.length === int(p.factioncount, 1)) {
|
||||
for (const b of active) ctx.addLayers(b, int(p.count1, 0), { requireActive: true, reason: 'band' });
|
||||
} else {
|
||||
for (const b of active) ctx.addLayers(b, int(p.count2, 0), { 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' });
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 鸭爵: "你和队友遭遇的部分敌人" — a global handler rewrites the normal-battle spawn list of every alive player while
|
||||
// the band is held by that player or a teammate. From the `minweight`–`maxweight` share of the wave (time order),
|
||||
// min..max NORMAL ground enemies are replaced by a random enemylist enemy worth 1 fund to its killer (bounty, also in
|
||||
// 联防). The originals never exist, so none of their death / kill / leak effects happen.
|
||||
|
||||
export const DUCK_BAND = 'band_ducklord';
|
||||
export const DUCK_COINS = 1;
|
||||
|
||||
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. */
|
||||
export function duckReplace(ctx, spawns, p, ownerPlayerId) {
|
||||
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 [];
|
||||
// one entry per enemy (split multi-count specs), in spawn-time order
|
||||
const flat = [];
|
||||
for (let i = 0; i < spawns.length; i++) {
|
||||
const s = spawns[i];
|
||||
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 && e && e.rank === 'NORMAL' && 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 || ev.kind !== 'normal' || !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);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** 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);
|
||||
}
|
||||
Reference in new issue
Block a user