Ported by hand into the split layout: -83fc408board.js placeClass: the player's loadout no longer counts, as in83fc408. feedback5's 补位 rule stays: a chess the player fields as its stand-in is placed by the stand-in's record (ps.fieldRecord). positionClass(rec) takes no module. -83fc408client piecePosition → public/js/ui/gameLogic/placement.js: meleeOnHighGround(rec) on the stand-in or chess record, no module. The gameLogic.js shim header and deployedModuleId's comment follow; deployedModuleId is kept, still exported and tested. -83fc408PlayerState._placementOf comment → server/match/player/placement.js. -83fc408tools/golden.mjs layout: positionClass without the module, on feedback5's stand-in body (recOf) and in diyLayout. - bonds/core.js (6ab9a06,8a2e0c7) applied cleanly; it equals 13808e3's plus feedback5's i18n toast. shared/highGround.js equals 13808e3's. - data/: only bonds.json (the 阿戈尔 spec text). node tools/build-data.mjs --offline on the merged tree rewrites all 15 files byte-identical. Golden regenerated: 16 scenarios moved, all by83fc408. Each fields 歌蕾蒂娅 (normal, or elite with HOK-X / no module), whom tools/golden.mjs now lays out beside the enemy path like a ranged operator instead of on the busiest melee tile: roster-010, roster-012, roster-029, roster-040, roster-041; bond-deputShip-high, bond-indomShip-low, bond-preciShip-low, bond-preciShip-high, bond-suntShip-low, bond-swiftShip-high; boss-boss_2-pair, boss-boss_4-pair, boss-boss_6-pair, hidden-boss_8-pair, hidden-boss_10-pair. This is the list in 83fc408's own message. With the 阿戈尔 commits reverted the digests are the same, so those two move nothing in this corpus. The standins, diy and matches families are unchanged: no stand-in or 自选 piece there is 歌蕾蒂娅, 崖心 or 见行者.
666 lines
36 KiB
JavaScript
666 lines
36 KiB
JavaScript
// 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());
|
||
// 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';
|
||
import { msg } from '../../../../shared/i18n.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 (atkFlat) and block count of everything it marked; 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 盟约记录 "目标首次被
|
||
* 击倒后解除自身被付与但还未触发的【吞噬】效果". A marker off the field gives no further mark (the rule since 0.1.0; one knocked
|
||
* out and back in the same pass still gives its marks).
|
||
* 联防: 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.atkFlat = atk;
|
||
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) {
|
||
// (a member down since its own combat — downAtStart — resolves its marks as if it stood; a mark on it resolves nothing:
|
||
// `knocked` — it is off the field)
|
||
if (knocked(t) || !(m.alive || downAtStart(m))) 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(msg('【维多利亚】获得{n}件维式重锤', { n: 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);
|
||
}
|