- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
273 lines
14 KiB
JavaScript
273 lines
14 KiB
JavaScript
// 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 +atk_i,
|
||
// HP +max_hp_i (直接乘算 — support directMods —, bonds snapshot at combat start)
|
||
// act1autochess_band28_buff 埃芒加德 命结之秘 the first max_respawn_cnt operator knock-downs of the battle revive at
|
||
// once — PRTS 备注 "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署
|
||
// 的再部署时间和费用归零": the knock-out stands (被击倒时 effects run) and the
|
||
// operator redeploys at once, free, where it lies (部署时 effects run, SP
|
||
// reset): items reviveNow, a `death` hook after M3茧甲's and before 阿戈尔
|
||
// 5 / 不屈 (in place until 0.2.0, community report 「…砾和瑕光这种死亡和部
|
||
// 署的叠层效果,如果有艾芒加德的3次复活似乎是无法触发」)
|
||
// 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, with no cap (PRTS
|
||
// 卫戍协议:盟约 下半 / PRTS盟约记录 备注 "※该策略效果可无限叠加"; a 20-stack cap
|
||
// until 0.2.2 — GitHub #328, PR #329)
|
||
// 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 +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 +atk
|
||
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
|
||
// ATK +atk_per_cnt × n, HP +max_hp_per_cnt × n (all 直接乘算)
|
||
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": once selected in this match, its
|
||
// retained team grant gives every player's field 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, directMods,
|
||
} from '../support/index.js';
|
||
import { weaknessRetype, addShieldLayer, PRIO_RESPAWN, reviveNow } from '../items/battle.js';
|
||
import { spawnMapChar } from '../tokens.js';
|
||
|
||
export const AMEDIC_BAND = 'band_amedic';
|
||
/**
|
||
* 'death' priority of 埃芒加德's revive: a knock-out (so after every `fatal` saver — kits', 坚固维式重锤's 不死), after the
|
||
* operator's own M3茧甲 (PRIO_RESPAWN 13), before 阿戈尔 5's first-knock-out revive (11) and 不屈 (10).
|
||
*/
|
||
export const PRIO_BAND_REVIVE = PRIO_RESPAWN - 1;
|
||
|
||
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 = directMods({ atk, 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('death', (c) => {
|
||
const u = c.unit;
|
||
if (used >= max || !isOp(u) || u.ownerId !== ps.playerId || !reviveNow(battle, c, 'band')) return;
|
||
used++;
|
||
fxOn(battle, 'revive', u, keyOf(bandId), bandId, { left: max - used });
|
||
}, { priority: PRIO_BAND_REVIVE });
|
||
},
|
||
|
||
// 克莱门莎 崇高牺牲: PRTS requires the 阿戈尔 bond to be active.
|
||
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: true, 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: Infinity, 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);
|
||
});
|
||
},
|
||
|
||
// 休谟斯 回收利用 ("地面干员技能结束时…": a melee-position operator on any tile — support isGroundOp)
|
||
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: directMods({ atk: 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), directMods({ 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 = directMods({ atk: a * n, hp: 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);
|
||
}
|