Files
ark-stronghold-protocol/server/sim/content/bonds/core.js
T
sganggs 5bc2669407 feedback4: 阿戈尔's devoured base ATK is a 最终加算, and a marker knocked out during the devour still resolves its own marks (GitHub #165 points 2 and 3, PR #176 by @SpiritedAwayCN; the owner's decision of 2026-10-06)
- PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 阿戈尔 ※: 「该付与来源获得所有标记单位的基础攻击力(最终加算)和阻挡数」. New
  additive mod key atkFinal (PRTS 游戏数据基础 A_f = F_t[(A + D_p)(1 + D_t) + F_p]):
  ATK = ((base + ΣatkFlat) × (1 + ΣatkPct) + ΣatkFinal) × ΠatkMul. The devour buff carries atkFinal instead of atkFlat:
  1000 base ATK, +100 % skill, +2000 devoured → 4000 (was 6000). Every other mod key keeps its meaning.
- 「标记按付与顺序触发【吞噬】效果,目标首次被击倒后解除自身被付与但还未触发的【吞噬】效果」 cancels only the knocked-out
  target's marks: a marker knocked out earlier in the pass now resolves its own marks, the kill credited to it
  (「击杀来源始终为对应标记的付与来源」). A → B → C with B devoured first: C takes B's 5000 and the kill is B's. The 3
  revive slots, the 联防 front-tile devour and the down-at-start rules (DESIGN §24.3 / §24.4) are unchanged.
- Point 5 (the 流失's own source is the target itself) stays open: the hooks still see the marker (DESIGN §24.7).
- Tests: bonds_core — new 最终加算 test (4000; a 直接加算 still scaled; ×0.5 scales the whole), new A → B → C test,
  the dead-marker test rewritten (its mark now lands); feedback3-egir-down, feedback4-egir-unite, feedback3-ulpia-move
  read atkFinal; match/bosshp recomputes ATK with the 最终加算.
- Docs: DESIGN §24.7 (and the §24 intro, §5.2 / §5.3, the §24.4 atkFlat lines marked), SIM.md §3, research 02 §3.6
  and its JSON spec; data/bonds.json regenerated with build-data --offline — only egirShip.spec.algorithm[2] / [3]
  changed.
- Golden: bond-egirShip-high (the devourers 深巡 / 幽灵鲨 / 斯卡蒂 no longer scale the devoured ATK by their ATK +%; the
  fight ends at 89.03 s instead of 74.03 s), coop2-ABYSS-10-boosted (from round 8 ai_0 has 3 more 阿戈尔 and 奥术
  layers — 海霓's first-kill trait — and both bots' boss damage moves). The marker rule moves no scenario.
2026-10-06 14:39:32 +08:00

673 lines
37 KiB
JavaScript
Raw 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/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side.
//
// 炎 yanShip members ATK +(base_atk + atk_per_stack·L); 6: one “炎佑” (its template ATK / max HP + 30 %
// of the 炎 ATK / max HP sums at battle start — PRTS "增加…(最终加算)"); 9: two 炎佑, ATK ×atk,
// damage taken ×(1 − damage_resistance)
// (every bond "+X%" ATK / max HP here is 直接乘算 — S.directMods, additive with the other percentages, PRTS 盟约记录)
// 萨尔贡 sargonShip member skill start → every member on the field gets an independent stack (ASPD +base_attack_speed
// for base_time + time_per_stack·L s, ≤ max_buff_stack_cnt); 6: each stack also ATK +base_atk
// (additive, one 直接乘算 buff); 6 + band_narant: instead lends the caster's equipment (tier ≤
// filter_item_level) to the 8 surrounding operators (items.js lendItemEffects)
// 维多利亚 victoriaShip members carrying equipment deal ×(base_damage_scale + damage_scale_per_stack·L);
// 6: ATK +atk_normal_equip per item (+atk_golden_equip more per golden item; 直接乘算);
// prep: every `layer` layers → `count` item(s) from `pool` (bond_layer_added_reward_equip)
// 谢拉格 kjeragShip members deal ×base_damage_scale, vs cold / frozen enemies ×(base_ex_damage_scale +
// ex_damage_scale_per_stack·L) instead; 6: cold wind (devices.js kjeragColdWind)
// 拉特兰 lateranoShip member ammo skills start with floor(ammo × (1 + base_ammo_percent + ammo_percent_per_stack·L));
// 6: every ammo used by a member → all members ATK +atk_per_consume (≤ max_atk_for_consume)
// 阿戈尔 egirShip members max HP +(base_max_hp + max_hp_per_stack·L) (直接乘算); battle start devour (see devour():
// the marker's gained base ATK is a 最终加算, `atkFinal`, not scaled by its ATK +%);
// 5: the first max_free_respawn_cnt members knocked out (in knock-out order, the devour's food
// included) each redeploy at once (free) on that first knock-out
// 叙拉古 siracusaShip every member deployment: ASPD +(base + per·L) for (base_duration + per·L) s; 6: 隐匿 for the same
// time, and while hidden / end_duration s after, every 普通伤害 hit (siracusaRolls) procs (PRD,
// nominal `prob`) base_damage + damage_per_stack·L true damage + fear `fear` s
// 卡西米尔 kazimierzShip every deployment of one of the player's operators (initial ones included): members ATK
// +atk_when_born, total ≤ base_max_atk_when_born + max_atk_when_born_per_stack·L; 6: blocking
// members pulse damage_atk_scale×ATK true damage + stun around them every damage_interval s,
// non-blocking members' attacks add pure_atk_scale×ATK true damage
//
// Numbers: data/bonds.json buffs (support.buffParams by bbStr.key); 炎佑's 30 % is parsed from the effect text.
// Tiers: a bond's upper tier is reached when count ≥ the blackboard threshold (power_bond_char_cnt / ex_bond_char_cnt)
// or the bond's tier index says so. Members = support.isMember (own bonds, 变形同构体, 调和 enjoying core bonds).
// Live layers: effects that read L at a moment (stack duration, ammo, deploy buffs, proc damage, cold multiplier) read
// the live value; standing buffs (炎 ATK, 维多利亚, 阿戈尔 HP, 卡西米尔 cap) are refreshed one tick after a `layerGain`.
// Two players in one field: everything is per player (own members, own deployments); the 谢拉格 wind and 炎佑 act on
// every enemy of the field (debuffs on shared enemies help both, research 06 §8.6).
// Hook priorities: 拉特兰 skillStart −10 (after kits' own ammo changes), 阿戈尔 death 11 (5-tier revive, before 不屈's
// death 10; every `fatal` saver runs before any death hook), layerGain −100 (after 魔王-style modifiers).
import * as S from '../support/index.js';
import { mitigate } from '../../damage.js';
import { spawnYanyou } from '../tokens.js';
import { kjeragColdWind } from '../devices.js';
import * as items from '../items.js';
import { FORCED_EXIT } from '../../constants.js';
const num = S.num;
/** Injection point for tests (lendItemEffects stub); null ⇒ items.js export. */
export const deps = { lendItemEffects: null };
const BUFF_KEYS = Object.freeze({
yanShip: 'act1autochess_bond_eff_yan',
sargonShip: 'act1autochess_bond_eff_sargon',
victoriaShip: 'act1autochess_bond_eff_victoria',
kjeragShip: 'act1autochess_bond_eff_kjerag',
lateranoShip: 'act1autochess_bond_eff_laterano',
egirShip: 'act1autochess_bond_eff_egir',
siracusaShip: 'act2autochess_bond_eff_siracusa',
kazimierzShip: 'act2autochess_bond_eff_kazimierz',
});
const SARGON_SHARE_KEY = 'act2autochess_bond_eff_sargon[share]';
const VICTORIA_REWARD_KEY = 'bond_layer_added_reward_equip';
const VICTORIA_COUNTER = 'bond:victoria:hammers';
/** Bond effect blackboard `{ ...bb, ...bbStr }` of a core bond (null when the data record is missing). */
export function bondBb(bondId, key = BUFF_KEYS[bondId]) {
return S.buffParams(S.bondRecord(bondId), key);
}
/** Has player `pid` reached the tier of `bondId` that needs `cnt` members? (active + count / tier index) */
export function reached(battle, pid, bondId, cnt) {
const st = S.bondState(battle, pid, bondId);
if (!st || !st.active) return false;
const need = num(cnt, 0);
if (!(need > 0)) return true;
if (num(st.count, 0) >= need) return true;
const th = S.bondRecord(bondId)?.thresholds;
const idx = Array.isArray(th) ? th.indexOf(need) : -1;
return idx >= 0 && S.bondTier(battle, pid, bondId) >= idx + 1;
}
/** "总和的30%" → 0.3 (炎佑 stat share, text only). */
export function yanyouShare() {
const text = String(S.bondRecord('yanShip')?.effectDesc ?? S.bondRecord('yanShip')?.desc ?? '');
const m = /总和的\s*(\d+(?:\.\d+)?)\s*%/.exec(text);
const v = m ? +m[1] / 100 : NaN;
return v > 0 && v <= 10 ? v : 0.3;
}
const PRD = new Map();
/**
* Pseudo-random-distribution constant C for a nominal probability p: attempt n since the last proc succeeds with
* min(1, C·n), and the long-run proc rate equals p (3 % → C ≈ 0.00139, research 02 §3.7).
*/
export function prdConstant(p) {
if (!(p > 0)) return 0;
if (p >= 1) return 1;
if (PRD.has(p)) return PRD.get(p);
const rate = (c) => {
let exp = 0, notYet = 1;
const nMax = Math.ceil(1 / c) + 1;
for (let n = 1; n <= nMax && notYet > 0; n++) {
const pn = Math.min(1, n * c);
exp += n * notYet * pn;
notYet *= 1 - pn;
}
return exp > 0 ? 1 / exp : 1;
};
let lo = 0, hi = p;
for (let i = 0; i < 50; i++) {
const mid = (lo + hi) / 2;
if (rate(mid) < p) lo = mid; else hi = mid;
}
const c = (lo + hi) / 2;
PRD.set(p, c);
return c;
}
// =====================================================================================================================
// per-battle plumbing
/** Shared per-battle state: layer refreshers, one hit / damaged / skillStart dispatch per battle. */
function core(battle) {
return S.battleStore(battle, 'bonds:core', () => ({
refreshers: new Map(), // `${pid}|${bondId}` → [fn]
pending: new Set(),
hitFns: new Map(), // source unit → [fn(ctx)]
dmgFns: new Map(), // source unit → [fn(ctx)]
hooked: new Set(),
}));
}
function onLayers(battle, pid, bondId, fn) {
const st = core(battle);
const k = `${pid}|${bondId}`;
if (!st.refreshers.has(k)) st.refreshers.set(k, []);
st.refreshers.get(k).push(fn);
if (st.hooked.has('layerGain')) return;
st.hooked.add('layerGain');
battle.on('layerGain', (c) => {
const key = `${c.playerId}|${c.bondId}`;
const list = st.refreshers.get(key);
if (!list || st.pending.has(key)) return;
st.pending.add(key);
// the gain is recorded after the hook: re-read the live layers next tick
battle.after(0, () => { st.pending.delete(key); for (const f of list) f(); });
}, { priority: -100 });
}
function onUnitHit(battle, unit, fn) {
const st = core(battle);
if (!st.hitFns.has(unit)) st.hitFns.set(unit, []);
st.hitFns.get(unit).push(fn);
if (st.hooked.has('hit')) return;
st.hooked.add('hit');
battle.on('hit', (c) => {
const list = c.source ? st.hitFns.get(c.source) : undefined;
if (list) for (let i = 0; i < list.length; i++) list[i](c);
});
}
function onUnitDamaged(battle, unit, fn) {
const st = core(battle);
if (!st.dmgFns.has(unit)) st.dmgFns.set(unit, []);
st.dmgFns.get(unit).push(fn);
if (st.hooked.has('damaged')) return;
st.hooked.add('damaged');
battle.on('damaged', (c) => {
const list = c.source ? st.dmgFns.get(c.source) : undefined;
if (list) for (let i = 0; i < list.length; i++) list[i](c);
});
}
const isEnemyTarget = (t) => !!t && t.side === 'enemy' && t.alive;
const ownTag = (dmg, tag) => !!dmg && Array.isArray(dmg.tags) && dmg.tags.includes(tag);
// =====================================================================================================================
// 炎
function installYan(battle, pid, bb, members) {
const L = () => S.bondLayers(battle, pid, 'yanShip');
const apply = () => {
const mods = S.directMods({ atk: num(bb.base_atk) + num(bb.atk_per_stack) * L() });
for (const u of members) S.passiveBuff(battle, u, 'bond:yan', mods);
};
apply();
onLayers(battle, pid, 'yanShip', apply);
if (!reached(battle, pid, 'yanShip', bb.power_bond_char_cnt)) return;
const ex = reached(battle, pid, 'yanShip', bb.ex_bond_char_cnt);
battle.on('battleStart', () => {
// PRTS: the sums count real 炎 operators only (调和 operators' stats are not collected unless they are 炎 too)
let atk = 0, hp = 0;
for (const u of S.playerOps(battle, pid, { fieldOnly: true })) {
if (!S.unitBonds(u).includes('yanShip')) continue;
atk += num(u.s.atk, 0);
hp += num(u.s.maxHp, 0);
}
if (!(hp > 0)) return;
const share = yanyouShare();
const out = spawnYanyou(battle, pid, {
atk: atk * share, hp: hp * share, count: ex ? 2 : 1,
atkMul: ex ? num(bb.atk, 1) : 1,
dmgTakenMul: ex ? Math.max(0, 1 - num(bb.damage_resistance, 0)) : 1,
});
for (const y of out) y.mem.bondYan = true;
}, { once: true });
}
// =====================================================================================================================
// 萨尔贡
function installSargon(battle, pid, bb, members) {
const memberSet = new Set(members);
const ps = S.player(battle, pid);
const share = S.buffParams(S.bondRecord('sargonShip'), SARGON_SHARE_KEY);
const band = ps?.bandId ?? null;
const narant = !!(share && band && share.valid_in_band === band);
const main = narant ? share : bb;
const six = reached(battle, pid, 'sargonShip', num(main.power_bond_char_cnt, 6));
const atkStacks = six && !narant && !(bb.invalid_in_band && bb.invalid_in_band === band);
const maxStacks = Math.max(1, Math.floor(num(main.max_buff_stack_cnt, 25)));
const perAtk = num(bb.base_atk, 0);
const syncAtk = (u) => {
if (!atkStacks) return;
let n = 0;
for (const b of u.buffs) if (b.key === 'bond:sargon') n++;
if (n > 0 && u.alive) battle.addBuff(u, { key: 'bond:sargon:atk', mods: S.directMods({ atk: perAtk * n }) });
else battle.removeBuff(u, 'bond:sargon:atk');
};
battle.on('skillStart', ({ unit }) => {
if (!memberSet.has(unit)) return;
const L = S.bondLayers(battle, pid, 'sargonShip');
const dur = num(main.base_time, 0) + num(main.time_per_stack, 0) * L;
if (dur > 0) {
for (const m of members) {
if (!S.onField(m)) continue;
battle.addBuff(m, {
key: 'bond:sargon', duration: dur, refresh: 'independent', maxStacks,
mods: { aspd: num(main.base_attack_speed, 0) },
onExpire: ({ unit: u }) => syncAtk(u), onRemove: ({ unit: u }) => syncAtk(u),
});
syncAtk(m);
}
}
if (narant && six) lendAround(battle, unit, share, S.bondLayers(battle, pid, 'sargonShip'));
});
}
function lendAround(battle, from, share, L) {
if (!S.itemsOf(from).length) return;
const lend = typeof deps.lendItemEffects === 'function' ? deps.lendItemEffects
: typeof items.lendItemEffects === 'function' ? items.lendItemEffects : null;
if (!lend) return;
const duration = num(share.base_power_time, 0) + num(share.power_time_per_stack, 0) * L;
if (!(duration > 0)) return;
const maxTier = num(share.filter_item_level, 5);
let n = 0;
for (const a of S.alliesAround(battle, from, S.N8)) {
if (!S.isOp(a)) continue;
lend(battle, from, a, { maxTier, duration });
n++;
}
if (n) S.fxOn(battle, 'bondShare', from, 'bond:sargonShip', 'narant', { n, duration });
}
// =====================================================================================================================
// 维多利亚
function installVictoria(battle, pid, bb, members) {
const apply = () => {
const mul = num(bb.base_damage_scale, 1) + num(bb.damage_scale_per_stack, 0) * S.bondLayers(battle, pid, 'victoriaShip');
for (const u of members) {
if (S.itemsOf(u).length >= 1) S.passiveBuff(battle, u, 'bond:victoria', { dmgDealtMul: mul });
}
};
apply();
onLayers(battle, pid, 'victoriaShip', apply);
if (!reached(battle, pid, 'victoriaShip', bb.power_bond_char_cnt)) return;
const normal = num(bb.atk_normal_equip, 0), golden = normal + num(bb.atk_golden_equip, 0);
for (const u of members) {
const ids = S.itemsOf(u);
if (!ids.length) continue;
let add = 0;
for (const id of ids) add += S.isGoldenId(id) ? golden : normal;
if (add > 0) S.passiveBuff(battle, u, 'bond:victoria:atk', S.directMods({ atk: add }));
}
}
// =====================================================================================================================
// 谢拉格
function installKjerag(battle, pid, bb, members) {
const base = num(bb.base_damage_scale, 1);
for (const u of members) S.passiveBuff(battle, u, 'bond:kjerag', { dmgDealtMul: base });
const ex = num(bb.base_ex_damage_scale, base), per = num(bb.ex_damage_scale_per_stack, 0);
const onHit = (c) => {
const t = c.target;
if (!isEnemyTarget(t) || !c.dmg) return;
const f = t.s.flags;
if (!f.cold && !f.freeze) return;
const target = ex + per * S.bondLayers(battle, pid, 'kjeragShip');
if (base > 0 && target > 0) c.dmg.mul *= target / base;
};
for (const u of members) onUnitHit(battle, u, onHit);
if (reached(battle, pid, 'kjeragShip', bb.power_bond_char_cnt)) kjeragColdWind(battle, pid, bb);
}
// =====================================================================================================================
// 拉特兰
function installLaterano(battle, pid, bb, members) {
const memberSet = new Set(members);
battle.on('skillStart', ({ unit, skill }) => {
if (!memberSet.has(unit) || !skill || skill.kind !== 'ammo' || !(skill.ammoLeft > 0)) return;
const mul = 1 + num(bb.base_ammo_percent, 0) + num(bb.ammo_percent_per_stack, 0) * S.bondLayers(battle, pid, 'lateranoShip');
const n = Math.floor(skill.ammoLeft * mul + 1e-9);
if (Number.isFinite(n) && n > skill.ammoLeft) skill.ammoLeft = n;
}, { priority: -10 });
if (!reached(battle, pid, 'lateranoShip', bb.power_bond_char_cnt)) return;
const per = num(bb.atk_per_consume, 0), cap = num(bb.max_atk_for_consume, Infinity);
if (!(per > 0)) return;
const st = { used: 0, bonus: 0 };
battle.on('ammoUsed', ({ unit }) => {
if (!memberSet.has(unit) || st.bonus >= cap) return;
st.used++;
st.bonus = Math.min(cap, st.used * per);
for (const m of members) S.passiveBuff(battle, m, 'bond:laterano:ammo', S.directMods({ atk: st.bonus }));
});
}
// =====================================================================================================================
// 阿戈尔
// "更靠左和靠上": left first (on the player's own board: mirrored players count from the field's right), then top first —
// row 0 is the BOTTOM row (DESIGN §3), so the top of the board is the highest row index. The order is a board position,
// independent of the members' directions (only "身前" follows each member's `dir`). The devour marks in this order (PRTS
// "从最先部署(更靠左和靠上的)的【阿戈尔】干员开始"; the battle's initial deployment order, Battle.start).
const boardCol = (u) => (u.player && u.player.mirror ? -u.tileC : u.tileC);
const egirOrder = (a, b) => boardCol(a) - boardCol(b) || b.tileR - a.tileR || a.id - b.id;
/**
* 联防: an operator forced out at the deployment (carry.down, Battle.start, before battleStart) still stands on its
* deploy position for the devour (PRTS 盟约记录 最先部署). The forced exit is not a knock-out, so it takes no 5-tier slot.
*/
function egirDownAtStart(battle, u) {
return S.isOp(u) && !u.alive && u.removeReason === FORCED_EXIT && !!u.carry && u.carry.down === true
&& battle.isDown(u);
}
/**
* Battle-start devour (research 02 §3.6 algorithm): members in order (further left on the player's own board — the
* Final Assault right side is mirrored, so its board-left is the field's right — then higher on the board first) mark
* the operator on the tile in front of them (one step along each member's own direction `dir`), and through marked
* members the tiles in front of
* those (chain); never themselves, a unit already marked by them or a unit that marked them. The marker gains the base
* ATK and block count of everything it marked — the ATK as a 最终加算 (`atkFinal`, PRTS 盟约记录 "该付与来源获得所有标记单位
* 的基础攻击力(最终加算)和阻挡数"): added after its percentages, so its skill's ATK +% does not scale it (it was `atkFlat`
* until 0.1.3: 1000 base ATK, +100 %, +2000 devoured gave 6000 instead of 4000; GitHub #165 point 2, PR #176, the owner's
* decision of 2026-10-06, DESIGN §24.7). Then each mark makes its target lose damage_value HP as a
* 物理流失 (PRTS 盟约记录: "造成5000点物理流失", 修正 "【吞噬】的物理流失来源为被付与目标自身;单位被【吞噬】击杀时,击杀来源始终为
* 对应标记的付与来源"; PRTS 作战机制: a 物理流失 "会受到目标当前防御力…影响而相应衰减") — less the target's DEF as a physical hit
* (its own source: no DEF ignore), then battle.loseHp: no shields, dodge or damage multipliers (DEF-free until 0.1.1); the
* kill is credited to the marker — in marking order. A target knocked out during the pass has its remaining marks
* cancelled, also when it is back at once (the 5-tier 立刻复活, 不屈's 立刻重新部署, 埃芒加德 / M3茧甲): PRTS 盟约记录 "目标首次被
* 击倒后解除自身被付与但还未触发的【吞噬】效果". Only the target's state cancels a mark: a marker knocked out by an earlier
* mark still resolves its own, credited to it (PRTS names only the target; "击杀来源始终为对应标记的付与来源") — GitHub #165
* point 3, PR #176, the owner's decision of 2026-10-06; until 0.1.3 a marker off the field gave no further mark.
* Not modelled: the 流失's own source (PRTS: the target itself) — the hooks see the marker as the source, as for the
* credit (an open question, DESIGN §24.7).
* 联防: the operators down since the end of their own combat (forced out by Battle.start) mark, are marked and resolve
* their marks like standing ones, but nothing resolves on them (below; per players' reports, owner's decision 2026-10-04).
* Whose operator stands in front does not matter (PRTS "依次吞噬身前一格干员", no own-side limit; the owner's decision of
* 2026-10-05 after GitHub #140 comment 4): on a shared field (联防, boss) a teammate's operator — standing, or entering
* 联防 down — is marked like an own one, gives the same base ATK / block count, and the chain goes on through it when it
* is an 阿戈尔 (S.isMember: its own bonds). Its knock-out is its owner's (their bonds' revives, 不屈 …), credited to the
* marker as usual. Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields).
* Tokens / devices / empty tiles are never devoured.
*/
function devour(battle, pid, bb, members) {
// 联防: an operator down at the end of its own combat (carryState.down — Battle.start forced it out right before
// battleStart, FORCED_EXIT) takes part in the devour as if it stood on its tile, then stays out: it marks in its turn,
// it is "the unit in front" of another (the chain goes on through it when it is a member), its base ATK / block count
// for its marker and its own marks resolve (their 物理流失 lands, credited to it as usual); only the marks ON it resolve
// nothing — it is forced out, so it is never knocked out again, revived or devoured. Per players' reports (community
// report #3, GitHub #33 item 3), owner's decision 2026-10-04; until 0.1.2 the forced exit came first and the chain broke.
const downAtStart = (u) => egirDownAtStart(battle, u);
const order = members.filter((u) => S.onField(u) || downAtStart(u)).sort(egirOrder);
// the operator in front, whoever owns it (until 0.1.3 only the player's own): a living one, else one lying there since
// the 联防 start (carry.down, forced out) — a teammate's included
const opAt = (u) => {
const [r, c] = S.frontTile(u);
const a = S.allyAt(battle, r, c);
if (a) return S.isOp(a) && a.alive ? a : null;
const d = battle.downOn(r, c);
return d && downAtStart(d) ? d : null;
};
const markedBy = new Map(); // marker → [targets]
const marks = [];
for (const m of order) {
const mine = [];
const seen = new Set([m]);
const queue = [m];
for (let guard = 0; queue.length && guard < 64; guard++) {
const x = queue.shift();
const t = opAt(x);
if (!t || seen.has(t)) continue;
if ((markedBy.get(t) ?? []).includes(m)) continue;
seen.add(t);
mine.push(t);
// through a marked 阿戈尔 (for the player's own operators: exactly its members; a teammate's by its own bonds)
if (S.isMember(battle, t, 'egirShip')) queue.push(t);
}
markedBy.set(m, mine);
if (!mine.length) continue;
let atk = 0, block = 0;
for (const t of mine) { atk += num(t.base.atk, 0); block += num(t.base.blockCnt, 0); marks.push([m, t]); }
const mods = {};
if (atk > 0) mods.atkFinal = atk; // 最终加算 (units.js _recalc)
if (block > 0) mods.blockCnt = block;
if (Object.keys(mods).length) S.passiveBuff(battle, m, 'bond:egir:devour', mods);
}
const amount = num(bb.damage_value, 0);
const layered = new Set();
// a target knocked out during the pass = off the field, or in another deployment than when the marks were placed (items
// deploymentOf: the 5-tier revive and 不屈 redeploy it, 埃芒加德 / M3茧甲 revive it in place) — a revived member was
// standing again when its pending marks used to knock it out a second time and spend every revive at t = 0 (GitHub #33)
const dep = new Map();
for (const [, t] of marks) if (!dep.has(t)) dep.set(t, items.deploymentOf(t));
const knocked = (t) => !t.alive || items.deploymentOf(t) !== dep.get(t);
for (const [m, t] of marks) {
// every mark resolves whatever became of its marker — knocked out by an earlier mark (and revived or not), or down
// since its own combat (downAtStart); a mark on a unit knocked out during the pass, or lying down since the 联防
// start, resolves nothing (`knocked`: it is off the field)
if (knocked(t)) continue;
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
if (!layered.has(t)) {
layered.add(t);
S.gainLayers(battle, { playerId: pid, bonds: 'egirShip', n: S.tierOf(t), source: m, reason: 'bond' });
}
}
}
function installEgir(battle, pid, bb, members) {
const apply = () => {
const mods = S.directMods({ hp: num(bb.base_max_hp, 0) + num(bb.max_hp_per_stack, 0) * S.bondLayers(battle, pid, 'egirShip') });
for (const u of members) S.passiveBuff(battle, u, 'bond:egir', mods);
};
apply();
onLayers(battle, pid, 'egirShip', apply);
// 5: "前3名【阿戈尔】干员首次被击倒时立刻复活" — the 3 (bonds.json max_free_respawn_cnt) slots go to the first 3 members
// knocked out, in knock-out order: the owner's decision of 2026-10-05, following players' reports (GitHub #105, #140:
// "没被吃的阿戈尔干员也会占用复活名额" — with 0.1.3's fixed holders by position the uneaten front members held the
// slots, so whatever the food chain ate only 3 阿戈尔 stood). A knock-out by the battle-start devour counts like any
// other, so the eaten food usually takes them at t = 0 and survivors = uneaten members + 3. A member takes at most one
// slot, on its first knock-out ('killed') in the battle: its later knock-outs never take one, and a first knock-out
// after the 3 are gone stays down. However many members mark it, its pending marks are cancelled once it is knocked
// out (devour), so a multi-devoured member spends one slot at most. Its own saver acts first: every kit, item or band
// revive that prevents or replaces the knock-out is a `fatal` hook (斯卡蒂's DRE-Y −50, kit savers 10 … −60, 坚固维式
// 重锤 PRIO_REVIVE −100, M3茧甲 PRIO_RESPAWN −101, 埃芒加德 −110), so the unit is never knocked out and no slot is used —
// the slot waits for its first real knock-out [ASSUMED for 埃芒加德, a band: as M3茧甲, the item revive the owner named].
// An operator entering 联防 down (FORCED_EXIT) is not knocked out either; a 调和 member counts like any other. Each
// battle (normal, 联防, boss) counts its own. PRTS: the knocked-out unit's next deployment has 0 redeploy time and 0
// cost, i.e. it IS knocked out (被击倒 triggers, 克莱门莎, 幽灵鲨 … fire) and redeploys at once where it lies (the
// engine's rest tile, Battle._layBody: the tile it was knocked out on — a raid-relocated member comes back where it
// fell —, or its own home when it fell on another board piece's home; PRTS 卫戍协议/帮助 §作战阶段 单位部署) with full
// HP, SP reset and `deploy` effects (卡西米尔 / 叙拉古). Death priority 11: before 不屈 (10), whose redeploy "also
// consumes a 复活 charge" — with this order the slot is always the one used, same outcome. Until 0.1.3 the slots were
// fixed at battle start for the first 3 members by position (§23.20); until 0.1.2 the first 3 knocked out took them
// but a revived member's pending marks could knock it out again (GitHub #33, fixed in devour since).
const max = reached(battle, pid, 'egirShip', bb.power_bond_char_cnt) ? Math.max(0, Math.floor(num(bb.max_free_respawn_cnt, 0))) : 0;
battle.on('battleStart', () => devour(battle, pid, bb, members), { once: true });
if (!(max > 0)) return;
const memberOps = new Set(members.filter(S.isOp));
const knockedOut = new Set(); // members whose first knock-out of this battle has happened
let revives = 0;
battle.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || !memberOps.has(u) || knockedOut.has(u)) return;
knockedOut.add(u);
if (revives >= max || u.alive || u.removed) return;
if (!battle.redeploy(u, { free: true })) return;
revives++;
S.fxOn(battle, 'revive', u, 'bond:egirShip', 'respawn', { n: revives });
}, { priority: 11 });
}
// =====================================================================================================================
// 叙拉古
/**
* Does a member's damage instance try the tier-6 proc? PRTS 盟约记录 叙拉古: "※仅在造成普通伤害时尝试造成来源为干员自身的
* 真实附加伤害和恐惧", "每次造成普通伤害时尝试触发" — 普通伤害 is the attack type NORMAL (PRTS 伤害分类: the default type,
* group damage included unless it is 溅射), not "a normal attack" (community report 「叙拉古盟约真伤概率数值没有正确递增
* 成长」, GitHub #79: it rolled on normal attacks only). So skill hits roll — 德克萨斯 剑雨, 缄默德克萨斯's bursts and
* sword rain, 阿罗玛 S1 / S2, 忍冬 S1 / S2, 伺夜 S3 — and so do 荒芜拉普兰德's S3 drone attacks (PRTS 备注 "不属于普通攻击/
* 技能直接伤害": neither an attack nor skill damage, still 普通伤害). Not: 溅射 (`isSplash`); 持续伤害 (tags dot / periodic:
* 荒芜拉普兰德's S3 pulse, PRTS 备注 "持续法术伤害"; 缄默德克萨斯 S1, "伤害分类为法术持续伤害"); 附加伤害 (tag `addition`:
* 拉普兰德's module, 忍冬's 追凶 — PRTS "法术附加伤害"; item procs and the bonds' riders, own proc included [ASSUMED: 附加,
* PRTS gives no class]); element damage; 无来源 damage (坚守's thorns); 流失.
*/
function siracusaRolls(dmg) {
if (!dmg || dmg.isSplash || dmg.sourceless || dmg.type === 'element' || dmg.type === 'elemental') return false;
const tags = dmg.tags;
if (!Array.isArray(tags)) return true;
for (let i = 0; i < tags.length; i++) {
const t = tags[i];
if (t === 'dot' || t === 'periodic' || t === 'addition' || t === 'item' || t === 'hpLoss') return false;
if (typeof t === 'string' && t.startsWith('bond:')) return false;
}
return true;
}
function installSiracusa(battle, pid, bb, members) {
const memberSet = new Set(members);
const six = reached(battle, pid, 'siracusaShip', bb.power_bond_char_cnt);
const end = num(bb.end_duration, 0);
battle.on('deploy', ({ unit }) => {
if (!memberSet.has(unit)) return;
const L = S.bondLayers(battle, pid, 'siracusaShip');
const dur = num(bb.base_duration, 0) + num(bb.duration_per_stack, 0) * L;
if (!(dur > 0)) return;
battle.addBuff(unit, { key: 'bond:siracusa', duration: dur, mods: { aspd: num(bb.base_attack_speed, 0) + num(bb.attack_speed_per_stack, 0) * L } });
if (!six) return;
unit.mem.siraStealthEnd = Infinity;
const off = ({ unit: u }) => { u.mem.siraStealthEnd = battle.time; };
battle.addBuff(unit, { key: 'bond:siracusa:stealth', duration: dur, flags: { stealth: true }, status: 'stealth', onExpire: off, onRemove: off });
});
if (!six) return;
const c = prdConstant(num(bb.prob, 0));
if (!(c > 0)) return;
const st = { n: 0 };
const onDmg = (ctx) => {
const u = ctx.source, t = ctx.target, dmg = ctx.dmg;
if (!isEnemyTarget(t) || !siracusaRolls(dmg)) return;
const hidden = u.s.flags.stealth || battle.time <= num(u.mem.siraStealthEnd, -Infinity) + end + 1e-9;
if (!hidden) return;
st.n++;
if (!(battle.rng() < Math.min(1, c * st.n))) return;
st.n = 0;
const amount = num(bb.base_damage, 0) + num(bb.damage_per_stack, 0) * S.bondLayers(battle, pid, 'siracusaShip');
S.fxOn(battle, 'bondProc', t, 'bond:siracusaShip', 'assassin', { from: u.id, amount });
if (amount > 0) battle.dealDamage(u, t, { amount, type: 'true', canDodge: false, tags: ['bond:siracusa'] });
if (t.alive && num(bb.fear, 0) > 0) battle.applyStatus(t, 'fear', { duration: num(bb.fear, 0), source: u });
};
for (const u of members) onUnitDamaged(battle, u, onDmg);
}
// =====================================================================================================================
// 卡西米尔
function installKazimierz(battle, pid, bb, members) {
const st = { deploys: 0 };
const apply = () => {
const cap = num(bb.base_max_atk_when_born, 0) + num(bb.max_atk_when_born_per_stack, 0) * S.bondLayers(battle, pid, 'kazimierzShip');
const bonus = Math.max(0, Math.min(cap, st.deploys * num(bb.atk_when_born, 0)));
if (!(bonus > 0)) return;
for (const u of members) S.passiveBuff(battle, u, 'bond:kazimierz', S.directMods({ atk: bonus }));
};
battle.on('deploy', ({ unit }) => {
if (!S.isOp(unit) || unit.ownerId !== pid) return;
st.deploys++;
apply();
});
onLayers(battle, pid, 'kazimierzShip', apply);
if (!reached(battle, pid, 'kazimierzShip', bb.power_bond_char_cnt)) return;
const iv = num(bb.damage_interval, 0), radius = num(bb.range_radius, 0), scale = num(bb.damage_atk_scale, 0), stun = num(bb.stun, 0);
if (iv > 0 && scale > 0) {
battle.every(iv, () => {
for (const m of members) {
if (!S.onField(m) || !m.blocking || !m.blocking.length) continue;
const hits = battle.foesInRadius(m.x, m.y, radius);
if (!hits.length) continue;
const amount = scale * m.s.atk;
for (const e of hits) {
if (!e.alive) continue;
battle.dealDamage(m, e, { amount, type: 'true', canDodge: false, isSplash: true, tags: ['bond:kazimierz'] });
if (e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: m });
}
S.fxOn(battle, 'aoe', m, 'bond:kazimierzShip', 'pulse', { radius, dmgType: 'true', n: hits.length });
}
});
}
const pure = num(bb.pure_atk_scale, 0);
if (!(pure > 0)) return;
const onDmg = (ctx) => {
const u = ctx.source, t = ctx.target, dmg = ctx.dmg;
if (!dmg || !dmg.isAttack || !isEnemyTarget(t) || ownTag(dmg, 'bond:kazimierz')) return;
if (u.blocking && u.blocking.length) return;
battle.dealDamage(u, t, { amount: pure * u.s.atk, type: 'true', canDodge: false, tags: ['bond:kazimierz'] });
};
for (const u of members) onUnitDamaged(battle, u, onDmg);
}
// =====================================================================================================================
// entry points
const INSTALLERS = Object.freeze({
yanShip: installYan,
sargonShip: installSargon,
victoriaShip: installVictoria,
kjeragShip: installKjerag,
lateranoShip: installLaterano,
egirShip: installEgir,
siracusaShip: installSiracusa,
kazimierzShip: installKazimierz,
});
export function install(battle) {
if (!battle || !Array.isArray(battle.players)) return;
for (const p of battle.players) {
const pid = p.playerId;
if (!p.bonds || typeof p.bonds !== 'object') continue;
for (const [bondId, fn] of Object.entries(INSTALLERS)) {
if (!S.bondActive(battle, pid, bondId)) continue;
const bb = bondBb(bondId);
if (!bb) continue;
const members = S.bondMembers(battle, pid, bondId);
if (!members.length) continue;
fn(battle, pid, bb, members);
}
}
}
/**
* 维多利亚 hammer milestones: every `layer` layers pay `count` item(s) of `pool` once (counter = milestones paid).
* A bond effect, so it pays only while 维多利亚 is active (official tip "叠加的盟约…只有在盟约被激活时才会发挥作用");
* milestones crossed while it was inactive ("无需激活" sources such as 烛煌) are paid the next time a hook sees it active
* (research 02 §3.3 / §8 Q7: the payout counts layers added while inactive) — same rule as 远见 / 奇迹 (addon/meta.js).
*/
export function payHammers(ctx) {
if (!ctx.bondActive('victoriaShip')) return 0;
const p = S.buffParams(ctx.data?.bonds?.victoriaShip ?? S.bondRecord('victoriaShip'), VICTORIA_REWARD_KEY);
const step = Math.floor(num(p?.layer, 0));
if (!p || !(step > 0)) return 0;
const due = Math.floor(ctx.layers('victoriaShip') / step);
const count = Math.max(0, Math.floor(num(p.count, 1)));
let granted = 0;
// re-read the counter every milestone: grantItem dispatches onGain, which re-enters this function (nested payments
// must not be paid twice by the outer loop)
for (let guard = 0; guard < 100; guard++) {
const paid = ctx.counter(VICTORIA_COUNTER);
if (paid >= due) break;
ctx.setCounter(VICTORIA_COUNTER, paid + 1);
for (let i = 0; i < count; i++) {
const id = ctx.rollItem({ pool: p.pool });
// onPrepEnd: grantItem stows this and does not merge until the next prep start, so a second copy does not
// take an equipped hammer off for the fight (effectsMeta _deferItemMerge). [ASSUMED] every item that hook grants.
if (id && ctx.grantItem(id, { source: 'bond:victoriaShip' })) granted++;
}
}
if (granted && typeof ctx.toast === 'function') ctx.toast(`【维多利亚】获得${granted}件维式重锤`, 'info');
return granted;
}
/** Hooks on which owed hammers are (caught up and) paid; onLayers only for 维多利亚's own gains. */
const HAMMER_HOOKS = Object.freeze(['onLayers', 'onRoundStart', 'onPrepStart', 'onPrepEnd', 'onBuy', 'onGain', 'onSold', 'onMerge']);
export function registerMeta(registry) {
const prev = typeof registry.get === 'function' ? registry.get('bond:victoriaShip') : null;
const handler = { ...(prev || {}) };
for (const hook of HAMMER_HOOKS) {
const before = prev && typeof prev[hook] === 'function' ? prev[hook] : null;
handler[hook] = function hammers(ctx, ev) {
if (before) before.call(this, ctx, ev);
if (hook === 'onLayers' && !(ev && ev.bondId === 'victoriaShip')) return;
payHammers(ctx);
};
}
registry.bond('victoriaShip', handler);
}