卫戍协议:盟约 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:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
// server/sim/content/bonds/addon.js — the 15 add-on bonds (research 02 §3.9–§3.23), loaded by content/bonds.js.
//
// addon/battle.js 精准 迅捷 灵巧 奥术 坚守 助力 突袭 不屈 协防干员 独行 绝技 (battle effects)
// addon/meta.js 助力 (prep-end layers) 远见 (layer funds, price discounts) 奇迹 (free refresh, layer funds)
// 投资人's ×2/×3 "获得时" repeat lives in the match dispatcher, 调和's +1 core count in server/match/bondsMeta.js and its
// "enjoys active core bonds" in support/index.js isMember().
import { install as installBattle } from './addon/battle.js';
import { registerMeta as registerAddonMeta } from './addon/meta.js';
export function install(battle) { installBattle(battle); }
export function registerMeta(registry) { registerAddonMeta(registry); }
+469
View File
@@ -0,0 +1,469 @@
// server/sim/content/bonds/addon/battle.js — battle side of the 15 add-on bonds (research 02 §3.9–§3.23).
//
// Every number comes from data/bonds.json (`env_gbuff_new` buff blackboards). Effects only apply to the owning
// player's units (`unit.ownerId === playerId`): two players sharing a boss / 联防 field never buff each other.
// Layer-dependent values read the LIVE layers (`battle.getPlayer(pid).bonds[id].layers`): an IN_BATTLE layer gain
// (engine `layerGain` hook, which fires before the layers are written) schedules one deferred recompute per player.
//
// 精准 preciShip members ATK ×(1 + base_atk + atk_per_stack·L); tier 2 (3 distinct): members + RANGED operators,
// which also ignore power_def_penetrate DEF / power_magic_resist_penetrate RES (defIgnorePct / resIgnorePct)
// 迅捷 swiftShip member skill end → p = min(1, base_prob + prob_per_stack·L): +normal_sp SP; L ≥ power_bond_stack_cnt:
// every operator's skill end rolls p again for +power_sp (members roll both, research [ASSUMED])
// 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s (one refreshing instance
// per player); tier 2: ×power_weak_scale when the target is below hp_ratio at application
// 坚守 steadShip all operators max HP ×(1 + base + per·L); tier 2: 40 % of a non-member operator's damage is borne by
// the members on the field (split evenly, sourceless true damage — already mitigated), members’ thorns
// (base + per·L arts, sourceless, ≤ 1 per cd_duration per member) + 脆弱 ×damage_scale for weak[limit] s
// 助力 deputShip all operators DEF ×(1 + base + per·L), redeploy time ×(1 + respawn_time)
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署"
// next to the most advanced ground enemy: a real redeployment (retreat + free redeploy on the
// landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古)
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP ×(1 + base + per·L)
// until it leaves the field; later redeploys use its board tile again;
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy; tier 2: every
// operator on the field +sp SP
// 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal
// (elite ×damage_scale_extra)
// 独行 soloShip the member(s) ATK ×(1 + atk), HP ×(1 + max_hp), +sp SP on every deploy
// 绝技 suntShip tier 1: elite operators ATK ×(1 + power_atk); tier 2: elites (and their summons) SP cost ×sp_ratio
// 远见 / 奇迹 / 投资人 / 调和 have no battle effect (prep side, membership in support/index.js).
//
// Hooks registered only when a bond needs them. Priorities: `hit` −20 (坚守 redirect, after other modifiers had their
// say), `death` 10 (不屈, the bond slot of the revive/redeploy convention); everything else 0.
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { localOrder, localBefore } from '../../../dir.js';
import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
fxOn, N4, N8,
} from '../../support/index.js';
export const ID = Object.freeze({
preci: 'preciShip', swift: 'swiftShip', skillful: 'skillfulShip', arcane: 'arcaneShip', stead: 'steadShip',
deput: 'deputShip', raid: 'raidShip', indom: 'indomShip', empty: 'emptyShip', solo: 'soloShip', sunt: 'suntShip',
});
export const KEY = Object.freeze({
preci: 'bond:preciShip', stead: 'bond:steadShip', deput: 'bond:deputShip', skillful: 'bond:skillfulShip',
raid: 'bond:raidShip', raidAspd: 'bond:raidShip:aspd', empty: 'bond:emptyShip', emptyDmg: 'bond:emptyShip:dmg',
solo: 'bond:soloShip', sunt: 'bond:suntShip', arcane: 'bond:arcaneShip',
});
/** Bonds whose battle values depend on layers (recomputed on in-battle gains). */
const LAYERED = new Set([ID.preci, ID.swift, ID.skillful, ID.arcane, ID.stead, ID.deput, ID.raid, ID.indom]);
const STEAD_CD = 'bond:steadShip:cd';
const AURA_POLL = 0.25;
const RAID_POLL = 0.25;
const RAID_SEARCH = 2; // landing tiles within this Chebyshev distance of the target enemy
/** `env_gbuff_new` blackboard of a bond (numbers only). */
export function bondBb(bondId) {
return buffParams(bondRecord(bondId), 'env_gbuff_new') ?? {};
}
/** 迅捷 / 不屈 proc chance p = min(1, base_prob + prob_per_stack·L). */
export const procChance = (bb, L) => Math.max(0, Math.min(1, num(bb.base_prob) + num(bb.prob_per_stack) * L));
const prob = procChance;
// =====================================================================================================================
// per-player state
function makeState(battle, p) {
const pid = p.playerId;
const ops = playerOps(battle, pid);
const st = { pid, ops, tiers: {}, bb: {}, members: {} };
for (const id of Object.values(ID)) {
const t = bondTier(battle, pid, id);
if (t <= 0) continue;
st.tiers[id] = t;
st.bb[id] = bondBb(id);
st.members[id] = new Set(ops.filter((u) => isMember(battle, u, id)));
}
return st;
}
const L = (battle, st, id) => bondLayers(battle, st.pid, id);
// =====================================================================================================================
// match-long passives (re-applied on layer changes; passiveBuff replaces by key → idempotent)
function applyPassives(battle, st) {
const t = st.tiers;
if (t[ID.preci]) {
const bb = st.bb[ID.preci];
const wide = t[ID.preci] >= 2;
const mods = { atkMul: 1 + num(bb.base_atk) + num(bb.atk_per_stack) * L(battle, st, ID.preci) };
if (wide) { mods.defIgnorePct = num(bb.power_def_penetrate); mods.resIgnorePct = num(bb.power_magic_resist_penetrate); }
for (const u of st.ops) {
if (st.members[ID.preci].has(u) || (wide && u.def?.position === 'RANGED')) passiveBuff(battle, u, KEY.preci, mods);
}
}
if (t[ID.stead]) {
const bb = st.bb[ID.stead];
const mods = { hpMul: 1 + num(bb.base_max_hp) + num(bb.max_hp_per_stack) * L(battle, st, ID.stead) };
for (const u of st.ops) passiveBuff(battle, u, KEY.stead, mods);
}
if (t[ID.deput]) {
const bb = st.bb[ID.deput];
const mods = { defMul: 1 + num(bb.base_def) + num(bb.def_per_stack) * L(battle, st, ID.deput), redeployMul: Math.max(0, 1 + num(bb.respawn_time)) };
for (const u of st.ops) passiveBuff(battle, u, KEY.deput, mods);
}
if (t[ID.raid]) {
const bb = st.bb[ID.raid];
const lv = L(battle, st, ID.raid);
const milestone = num(bb.power_bond_stack_cnt, Infinity);
if (lv >= milestone) {
const mods = { aspd: num(bb.power_attack_speed) };
for (const u of st.ops) passiveBuff(battle, u, KEY.raidAspd, mods);
if (!st.raidMilestone) { st.raidMilestone = true; for (const u of st.members[ID.raid]) if (onField(u)) { fxOn(battle, 'bondMilestone', u, KEY.raid, 'aspd'); break; } }
}
st.raidMods = raidMods(bb, lv);
for (const u of st.members[ID.raid]) {
const b = u.findBuff(KEY.raid);
if (b) { b.mods = st.raidMods; u.markDirty(); }
}
}
if (t[ID.arcane]) {
const bb = st.bb[ID.arcane];
const m = num(bb.base_damage_scale, 1) + num(bb.damage_scale_per_stack) * L(battle, st, ID.arcane);
st.arcaneMods = { artsTakenMul: Math.max(0, m) };
st.arcaneLowMods = { artsTakenMul: Math.max(0, m * num(bb.power_weak_scale, 1)) };
}
if (t[ID.skillful]) {
const bb = st.bb[ID.skillful];
const lv = L(battle, st, ID.skillful);
const v = num(bb.base_attack_speed) + num(bb.attack_speed_per_stack) * lv;
if (!st.auraMods || st.auraMods.aspd !== v) st.auraMods = { aspd: v };
st.auraWide = lv >= num(bb.power_bond_stack_cnt, Infinity);
}
}
function raidMods(bb, lv) {
const mods = {
atkMul: 1 + num(bb.base_atk) + num(bb.atk_per_stack) * lv,
hpMul: 1 + num(bb.base_max_hp) + num(bb.max_hp_per_stack) * lv,
};
const aspd = num(bb.base_attack_speed) + num(bb.attack_speed_per_stack) * lv;
if (aspd) mods.aspd = aspd;
return mods;
}
/** Layer-independent passives (协防 / 独行 / 绝技), applied once. */
function applyStatic(battle, st) {
const t = st.tiers;
if (t[ID.empty]) {
const bb = st.bb[ID.empty];
const taken = Math.max(0, 1 - num(bb.damage_resistance));
for (const u of st.ops) {
passiveBuff(battle, u, KEY.empty, { physTakenMul: taken, artsTakenMul: taken });
if (st.members[ID.empty].has(u)) {
passiveBuff(battle, u, KEY.emptyDmg, { dmgDealtMul: num(isElite(u) ? bb.damage_scale_extra : bb.damage_scale_normal, 1) });
}
}
}
if (t[ID.solo]) {
const bb = st.bb[ID.solo];
const mods = { atkMul: 1 + num(bb.atk), hpMul: 1 + num(bb.max_hp) };
for (const u of st.members[ID.solo]) passiveBuff(battle, u, KEY.solo, mods);
}
if (t[ID.sunt]) {
const bb = st.bb[ID.sunt];
const mods = { atkMul: 1 + num(bb.power_atk) };
for (const u of st.ops) {
if (!isElite(u)) continue;
passiveBuff(battle, u, KEY.sunt, mods);
if (t[ID.sunt] >= 2) suntSpCost(u, bb);
}
}
}
function suntSpCost(u, bb) {
const sk = u && u.skill;
if (!sk || sk.noSkill || u.mem[KEY.sunt]) return;
u.mem[KEY.sunt] = true;
sk.spCostMul = sk.spCostMul * num(bb.sp_ratio, 1);
}
// =====================================================================================================================
// 灵巧 aura
function updateAura(battle, st) {
const mods = st.auraMods;
if (!mods) return;
const next = st.auraNext ?? (st.auraNext = new Set());
next.clear();
const offs = st.auraWide ? N8 : N4;
for (const m of st.members[ID.skillful]) {
if (!onField(m)) continue;
next.add(m);
for (const [dr, dc] of offs) {
const a = battle.unitAt(m.tileR + dr, m.tileC + dc);
if (a && a.kind === 'op' && a.ownerId === st.pid && onField(a)) next.add(a);
}
}
const cur = st.aura ?? (st.aura = new Set());
for (const u of cur) if (!next.has(u)) battle.removeBuff(u, KEY.skillful);
for (const u of next) {
const b = u.findBuff(KEY.skillful);
if (!b) battle.addBuff(u, { key: KEY.skillful, mods });
else if (b.mods !== mods) { b.mods = mods; u.markDirty(); }
}
st.aura = next;
st.auraNext = cur;
}
// =====================================================================================================================
// 突袭 relocation
function raidTile(battle, u, e) {
const er = Math.round(e.y), ec = Math.round(e.x);
const ek = er * COLS + ec;
const ranged = u.def?.position === 'RANGED';
const grid = u.rangeGrid || [[0, 0]];
const ext = num(u.s.rangeExtend);
let best = null, bs = null;
for (let dr = -RAID_SEARCH; dr <= RAID_SEARCH; dr++) {
for (let dc = -RAID_SEARCH; dc <= RAID_SEARCH; dc++) {
const r = er + dr, c = ec + dc;
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue;
if (!battle.grid.canStand(r, c, { ranged })) continue;
const covers = absoluteRangeKeys(grid, r, c, u.dir, ext).includes(ek) ? 0 : 1;
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
// last tie-break: the offset in the unit's facing-RIGHT frame (sim/dir.js localOrder; for a RIGHT-facing unit the
// plain tile-key order), so the landing tile turns with its direction
const s0 = covers, s1 = d, s2 = localOrder(dr, dc, u.dir);
if (!bs || s0 < bs[0] || (s0 === bs[0] && (s1 < bs[1] - 1e-9 || (Math.abs(s1 - bs[1]) <= 1e-9 && localBefore(s2, bs[2]))))) { best = [r, c, covers === 0]; bs = [s0, s1, s2]; }
}
}
return best; // [row, col, targetInRange]
}
function raidTargets(battle, u, pid) {
const own = [], other = [];
for (const e of battle.enemies) {
if (e.isFlying || !canTargetEnemy(u, e, { canHitFly: false })) continue;
(e.ownerId === pid ? own : other).push(e);
}
const list = own.length ? own : other;
const dist = new Map(list.map((e) => [e, num(battle.remainingDistance ? battle.remainingDistance(e) : 0)]));
list.sort((a, b) => dist.get(a) - dist.get(b) || a.id - b.id);
return list;
}
function raidPoll(battle, st) {
const bb = st.bb[ID.raid];
const idle = num(bb.no_attack_duration, 10);
for (const u of st.members[ID.raid]) {
if (!onField(u) || !u.canAct) continue;
const since = Math.max(u.lastAttackAt ?? -Infinity, u.deployedAt ?? -Infinity, u.mem[KEY.raid] ?? -Infinity);
const ready = !!(u.skill && u.skill.ready && !(u.skill.active && u.skill.isTimed));
const idleOk = battle.time - since >= idle - 1e-9;
if (!(ready || idleOk)) continue;
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length) continue;
const targets = raidTargets(battle, u, st.pid);
for (let i = 0; i < targets.length && i < 8; i++) {
const tile = raidTile(battle, u, targets[i]);
if (!tile) continue;
// the "skill ready" path has no idle gate: only jump where the target is in range, else a ready skill that never
// finds a target would redeploy (and fire every 部署时 effect) every poll
if (!idleOk && !tile[2]) continue;
const res = raidRedeploy(battle, u, tile[0], tile[1]);
if (!res) continue;
u.mem[KEY.raid] = battle.time;
if (res === 'raid') {
battle.addBuff(u, { key: KEY.raid, mods: st.raidMods });
fxOn(battle, 'blink', u, KEY.raid, 'relocate', { target: targets[i].id });
}
break;
}
}
}
/**
* "保留技力立即再部署": retreat the unit and redeploy it for free on (r, c) with its SP / charges kept (engine redeploy
* `tile` + `keepSp`: restored before the `deploy` handlers run; the board tile stays the home of later redeploys).
* Should the landing fail, the unit goes straight back to its home tile (never stranded off the field).
* Returns 'raid' | 'home' | false.
*/
function raidRedeploy(battle, u, r, c) {
battle.retreat(u, { reason: 'raid' });
if (u.alive) return false;
if (battle.redeploy(u, { free: true, tile: [r, c], keepSp: true })) return 'raid';
return battle.redeploy(u, { free: true, keepSp: true }) ? 'home' : false;
}
// =====================================================================================================================
// install
export function install(battle) {
if (!battle || !Array.isArray(battle.players) || !battle.players.length) return;
const states = [];
for (const p of battle.players) {
const st = makeState(battle, p);
if (Object.keys(st.tiers).length) states.push(st);
}
if (!states.length) return;
const byPid = Object.create(null);
for (const st of states) { byPid[st.pid] = st; applyPassives(battle, st); applyStatic(battle, st); }
const has = (id, tier = 1) => states.some((st) => (st.tiers[id] ?? 0) >= tier);
// live layers
if (states.some((st) => Object.keys(st.tiers).some((id) => LAYERED.has(id)))) {
battle.on('layerGain', (c) => {
const st = byPid[c.playerId];
if (!st || !st.tiers[c.bondId] || !LAYERED.has(c.bondId) || st.pending) return;
st.pending = true;
battle.after(0, () => {
st.pending = false;
applyPassives(battle, st);
if (st.tiers[ID.skillful]) updateAura(battle, st);
});
});
}
// 灵巧 aura (deploy / death immediately, relocations through a cheap poll)
if (has(ID.skillful)) {
const aura = states.filter((st) => st.tiers[ID.skillful]);
const refresh = (pid) => { for (const st of aura) if (pid == null || st.pid === pid) updateAura(battle, st); };
battle.on('battleStart', () => refresh(null));
battle.on('deploy', (c) => { if (c.unit && c.unit.kind === 'op' && byPid[c.unit.ownerId]?.tiers[ID.skillful]) refresh(c.unit.ownerId); });
battle.on('death', (c) => { if (c.unit && c.unit.kind === 'op' && byPid[c.unit.ownerId]?.tiers[ID.skillful]) refresh(c.unit.ownerId); });
battle.every(AURA_POLL, () => refresh(null));
}
// 突袭
if (has(ID.raid)) {
const raid = states.filter((st) => st.tiers[ID.raid] && st.members[ID.raid].size);
if (raid.length) {
battle.every(RAID_POLL, () => { for (const st of raid) raidPoll(battle, st); });
}
}
// 迅捷
if (has(ID.swift)) {
battle.on('skillEnd', (c) => {
const u = c.unit;
if (!u || u.kind !== 'op' || c.reason === 'death' || !u.alive || !u.skill) return;
const st = byPid[u.ownerId];
if (!st || !st.tiers[ID.swift]) return;
const bb = st.bb[ID.swift];
const lv = L(battle, st, ID.swift);
const p = prob(bb, lv);
let gained = 0;
if (st.members[ID.swift].has(u) && battle.rng() < p) gained += u.skill.gainSp(num(bb.normal_sp), 'bond');
if (lv >= num(bb.power_bond_stack_cnt, Infinity) && battle.rng() < p) gained += u.skill.gainSp(num(bb.power_sp), 'bond');
if (gained > 0) fxOn(battle, 'sp', u, 'bond:swiftShip', 'proc', { n: gained });
});
}
// 独行 (SP on every deploy) / 绝技 (summons of elites)
const soloOrSunt = has(ID.solo) || has(ID.sunt, 2);
if (soloOrSunt) {
battle.on('deploy', (c) => {
const u = c.unit;
if (!u || (u.kind !== 'op' && u.kind !== 'token')) return;
const st = byPid[u.ownerId];
if (!st) return;
if (u.kind === 'op' && st.tiers[ID.solo] && st.members[ID.solo].has(u) && u.skill) {
const n = num(st.bb[ID.solo].sp);
if (n > 0) u.skill.gainSp(n, 'init');
}
if (u.kind === 'token' && (st.tiers[ID.sunt] ?? 0) >= 2 && isElite(u.ownerUnit)) suntSpCost(u, st.bb[ID.sunt]);
});
}
// 不屈
if (has(ID.indom)) {
battle.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || !isGroundOp(u)) return;
const st = byPid[u.ownerId];
if (!st || !st.tiers[ID.indom]) return;
const bb = st.bb[ID.indom];
if (st.tiers[ID.indom] >= 2) {
const sp = num(bb.sp);
if (sp > 0) for (const o of st.ops) if (onField(o) && o.skill) o.skill.gainSp(sp, 'bond');
}
if (u.alive || u.removed || u.mem[ID.indom] === battle.time) return;
if (battle.rng() < prob(bb, L(battle, st, ID.indom))) {
u.mem[ID.indom] = battle.time;
if (battle.redeploy(u, { free: true })) fxOn(battle, 'revive', u, 'bond:indomShip', 'redeploy');
}
}, { priority: 10 });
}
// 坚守 (tier 2) redirect + thorns, 奥术 vulnerability
const stead = has(ID.stead, 2);
const arcane = has(ID.arcane);
if (stead) {
battle.on('hit', (c) => {
const t = c.target, dmg = c.dmg;
if (!t || t.side !== 'ally' || t.kind !== 'op' || !dmg || dmg.cancel || dmg.steadShare || dmg.steadCut || dmg.type === 'element') return;
const st = byPid[t.ownerId];
if (!st || (st.tiers[ID.stead] ?? 0) < 2 || st.members[ID.stead].has(t)) return;
let any = false;
for (const m of st.members[ID.stead]) if (onField(m)) { any = true; break; }
if (!any) return;
const ratio = Math.max(0, Math.min(1, num(st.bb[ID.stead].damage_resistance)));
if (!(ratio > 0) || ratio >= 1) return;
dmg.mul *= 1 - ratio;
dmg.steadCut = ratio;
}, { priority: -20 });
}
if (stead || arcane) {
battle.on('damaged', (c) => {
const t = c.target, dmg = c.dmg;
if (!t || !dmg) return;
if (t.side === 'ally') {
if (!stead || t.kind !== 'op') return;
const st = byPid[t.ownerId];
if (!st || (st.tiers[ID.stead] ?? 0) < 2) return;
if (dmg.steadCut) {
const ratio = dmg.steadCut;
dmg.steadCut = 0;
const share = (num(c.amount) / (1 - ratio)) * ratio;
if (!(share > 0)) return;
const on = [];
for (const m of st.members[ID.stead]) if (onField(m)) on.push(m);
// the share is already mitigated and already carries the attacker's damage multipliers: deal it sourceless
// (the member's own damage-taken modifiers and shields still apply), never re-scaled by the attacker
for (const m of on) {
const d = battle.makeDamage({ amount: share / on.length, type: 'true', canDodge: false, tags: ['bond:stead:share'] });
d.steadShare = true;
battle.dealDamage(null, m, d);
}
return;
}
if (dmg.steadShare || dmg.steadThorn || c.type === 'element' || !st.members[ID.stead].has(t)) return;
const bb = st.bb[ID.stead];
const last = t.mem[STEAD_CD] ?? -Infinity;
if (battle.time - last < num(bb.cd_duration) - 1e-9) return;
t.mem[STEAD_CD] = battle.time;
const src = c.source;
if (!src || src.side !== 'enemy' || !src.alive) return;
const d = battle.makeDamage({ amount: num(bb.base_damage_value) + num(bb.damage_value_per_stack) * L(battle, st, ID.stead), type: 'arts', canDodge: false });
d.steadThorn = true;
battle.dealDamage(null, src, d);
if (src.alive) battle.applyStatus(src, 'fragile', { duration: num(bb['weak[limit]'], 5), value: num(bb.damage_scale, 1) - 1, source: t });
return;
}
if (!arcane || c.type !== 'arts' || t.side !== 'enemy' || !t.alive) return;
const src = c.source;
if (!src || src.kind !== 'op') return;
const st = byPid[src.ownerId];
if (!st || !st.tiers[ID.arcane] || !st.members[ID.arcane].has(src)) return;
const bb = st.bb[ID.arcane];
const low = st.tiers[ID.arcane] >= 2 && t.hpRatio < num(bb.hp_ratio);
const mods = low ? st.arcaneLowMods : st.arcaneMods;
const key = st.arcaneKey ?? (st.arcaneKey = `${KEY.arcane}:${st.pid}`);
const dur = num(bb.weak_duration, 3);
const b = t.findBuff(key);
if (b) {
b.timeLeft = dur;
b.duration = dur;
if (b.mods !== mods) { b.mods = mods; t.markDirty(); }
} else {
battle.addBuff(t, { key, duration: dur, mods });
}
});
}
}
+135
View File
@@ -0,0 +1,135 @@
// server/sim/content/bonds/addon/meta.js — prep side of the add-on bonds (research 02 §3.14–§3.17, §3.20).
//
// 助力 deputShip onPrepEnd: every currently active bond +`layer` (tier 2 = 3 助力 by name or elite state:
// +`more_layer`) — buff bond_activated_add_layer
// 远见 visiShip every `layer` layers +`count` funds (bond_layer_gain_coin); first time at `layer1`: 远见 chess cost
// −`discount`; first time at `layer2`: every chess costs −`discount` instead (latched, permanent)
// 奇迹 miraShip manual refresh with no free refresh pending → p = min(1, baseprob + prob·L) the next refresh is free;
// every 100 layers +20 funds (bond_layer_gain_coin). `probk` has no known meaning and is unused.
// 投资人 ×2 / ×3 "获得时" garrisons: EffectDispatcher.investRepeat (server/match/effectsMeta.js).
// 调和 +1 member count for core bonds: server/match/bondsMeta.js.
//
// Milestones read the persistent layers and pay only while the bond is active; payouts owed for layers gained while it
// was inactive ("无需激活" sources) are caught up the next time a hook sees it active (research 02 §8 Q7 [ASSUMED]).
// Funds earned after the prep phase ended (助力 / 寒檀 / 号角 … prep-end layers — the match wipes leftover funds right
// after onPrepEnd) are paid as pending funds, credited at the next round start, so "获得…资金" is never silently lost
// [ASSUMED]; `global:bondaddon_prepend` (first in every dispatch) marks the round whose prep has ended.
// 远见 discounts never push a price below 1 (research 02 §3.15 [ASSUMED]; a price already below 1 is left alone).
// Latched state lives in player counters (prefix `bondaddon:`); onPrice only reads them (pure).
const C_VISI_PAID = 'bondaddon:visi:paid';
const C_VISI_DISC = 'bondaddon:visi:disc';
const C_MIRA_PAID = 'bondaddon:mira:paid';
const C_PREP_ENDED = 'bondaddon:prepEndedRound';
/** Hooks on which owed milestones are caught up (besides the bond's own onLayers). */
const SETTLE_HOOKS = Object.freeze(['onRoundStart', 'onPrepStart', 'onPrepEnd', 'onBuy', 'onGain', 'onSold', 'onMerge']);
const n = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
/** `{ ...bb, ...bbStr }` of a bond buff from the match data (ctx.data), or {}. */
function buff(ctx, bondId, key) {
const rec = ctx.data && ctx.data.bonds ? ctx.data.bonds[bondId] : null;
for (const b of (rec && Array.isArray(rec.buffs) ? rec.buffs : [])) {
if (b && b.key === key) return { ...(b.bb || {}), ...(b.bbStr || {}) };
}
return {};
}
/** Pay "every `layer` layers +`count` funds" up to the current layers (only while active). */
function settleCoins(ctx, bondId, counterKey, label) {
if (!ctx.bondActive(bondId)) return 0;
const p = buff(ctx, bondId, 'bond_layer_gain_coin');
const step = n(p.layer), count = n(p.count);
if (!(step > 0) || !(count > 0)) return 0;
const due = Math.floor(ctx.layers(bondId) / step);
const paid = ctx.counter(counterKey);
if (due <= paid) return 0;
ctx.setCounter(counterKey, due);
const gain = (due - paid) * count;
if (prepEnded(ctx)) {
ctx.addPendingFunds(gain);
ctx.toast(`【${label}】层数达成,下回合开始时获得${gain}资金`, 'info');
} else {
ctx.addFunds(gain, `bond:${bondId}`);
ctx.toast(`【${label}】层数达成,获得${gain}资金`, 'info');
}
return gain;
}
/** True between this round's onPrepEnd and the end of its PREP phase (leftover funds are about to be wiped). */
const prepEnded = (ctx) => ctx.phase === 'PREP' && ctx.counter(C_PREP_ENDED) === ctx.round;
function latchVisiDiscount(ctx) {
if (!ctx.bondActive('visiShip')) return;
const p = buff(ctx, 'visiShip', 'bond_multi_layer_char_goods_price_bond_discount');
const L = ctx.layers('visiShip');
const cur = ctx.counter(C_VISI_DISC);
const t = L >= n(p.layer2, Infinity) ? 2 : L >= n(p.layer1, Infinity) ? 1 : 0;
if (t > cur) {
ctx.setCounter(C_VISI_DISC, t);
ctx.toast(t >= 2 ? '【远见】所有干员购买价格永久降低' : '【远见】远见干员购买价格永久降低', 'info');
}
}
function visiSettle(ctx) {
settleCoins(ctx, 'visiShip', C_VISI_PAID, '远见');
latchVisiDiscount(ctx);
}
const miraSettle = (ctx) => { settleCoins(ctx, 'miraShip', C_MIRA_PAID, '奇迹'); };
/** A handler object running `fn(ctx)` on every catch-up hook and on the bond's own layer gains. */
function settler(bondId, fn) {
const h = { onLayers(ctx, ev) { if (ev && ev.bondId === bondId) fn(ctx); } };
for (const hook of SETTLE_HOOKS) h[hook] = (ctx) => fn(ctx);
return h;
}
export function registerMeta(registry) {
registry.global('bondaddon_prepend', {
onPrepEnd(ctx) { ctx.setCounter(C_PREP_ENDED, ctx.round); },
});
registry.bond('deputShip', {
onPrepEnd(ctx) {
const me = ctx.bond('deputShip');
if (!me || !me.active) return;
const p = buff(ctx, 'deputShip', 'bond_activated_add_layer');
const add = me.tier >= 2 ? n(p.more_layer, n(p.layer)) : n(p.layer);
if (!(add > 0)) return;
const active = Object.entries(ctx.bonds()).filter(([, b]) => b && b.active).map(([id]) => id);
for (const id of active) ctx.addLayers(id, add, { requireActive: true, reason: 'bond:deputShip' });
},
});
registry.bond('visiShip', {
...settler('visiShip', visiSettle),
onPrice(ctx, ev) {
if (!ev || ev.kind !== 'chess') return;
const t = ctx.counter(C_VISI_DISC);
if (t <= 0) return;
const p = buff(ctx, 'visiShip', 'bond_multi_layer_char_goods_price_bond_discount');
const disc = n(p.discount);
if (!(disc > 0)) return;
if (t < 2) {
const rec = ctx.chessRecord(ev.id);
if (!rec || !Array.isArray(rec.bonds) || !rec.bonds.includes(p.bond || 'visiShip')) return;
}
const price = n(ev.price);
if (price > 1) ctx.modifyPrice(-Math.min(disc, price - 1));
},
});
registry.bond('miraShip', {
...settler('miraShip', miraSettle),
onRefresh(ctx, ev) {
if (!ev || ev.trigger || !ctx.bondActive('miraShip')) return;
if (ctx.grantFreeRefresh(0) > 0) return;
const p = buff(ctx, 'miraShip', 'bond_refresh_shop_next_free');
const chance = Math.max(0, Math.min(1, n(p.baseprob) + n(p.prob) * ctx.layers('miraShip')));
if (ctx.rng() < chance) {
ctx.grantFreeRefresh(1);
ctx.toast('【奇迹】下次刷新不消耗资金', 'info');
}
},
});
}
+577
View File
@@ -0,0 +1,577 @@
// server/sim/content/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side.
//
// 炎 yanShip members ATK ×(1 + base_atk + atk_per_stack·L); 6: one “炎佑” (30 % of the 炎 ATK / max HP sums
// at battle start); 9: two 炎佑, ATK ×atk, damage taken ×(1 − damage_resistance)
// 萨尔贡 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 atkMul); 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 ×(1 + base_max_hp + max_hp_per_stack·L); battle start devour (see devour());
// 5: the first max_free_respawn_cnt members knocked out for the first time redeploy at once (free)
// 叙拉古 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, attacks proc (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), layerGain −100 (after 魔王-style modifiers).
import * as S from '../support/index.js';
import { spawnYanyou } from '../tokens.js';
import { kjeragColdWind } from '../devices.js';
import * as items from '../items.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 mul = 1 + num(bb.base_atk) + num(bb.atk_per_stack) * L();
for (const u of members) S.passiveBuff(battle, u, 'bond:yan', { atkMul: mul });
};
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: { atkMul: 1 + 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', { atkMul: 1 + 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', { atkMul: 1 + st.bonus });
});
}
// =====================================================================================================================
// 阿戈尔
/**
* 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
* 流失 (battle.loseHp: no DEF, shields, dodge or damage multipliers — research "5000-point physical 流失"; the kill is
* credited to the marker) in marking order. A unit that died cancels its remaining marks (as target and as marker).
* 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) {
const memberSet = new Set(members);
// "更靠左和靠上": 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`).
const boardCol = (u) => (u.player && u.player.mirror ? -u.tileC : u.tileC);
const order = members.filter(S.onField).sort((a, b) =>
boardCol(a) - boardCol(b) || b.tileR - a.tileR || a.id - b.id);
const opAt = (u) => {
const [r, c] = S.frontTile(u);
const a = S.allyAt(battle, r, c, pid);
return a && S.isOp(a) && a.alive ? a : 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);
if (memberSet.has(t)) 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();
for (const [m, t] of marks) {
if (!t.alive || !m.alive) continue;
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
if (amount > 0) battle.loseHp(t, amount, { 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 mul = 1 + 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', { hpMul: mul });
};
apply();
onLayers(battle, pid, 'egirShip', apply);
battle.on('battleStart', () => devour(battle, pid, bb, members), { once: true });
if (!reached(battle, pid, 'egirShip', bb.power_bond_char_cnt)) return;
// 5: "前3名【阿戈尔】干员首次被击倒时立刻复活" — 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 on the tile it was
// knocked out on (engine redeploy `tile`: a raid-relocated member comes back where it fell, later redeploys use its
// board tile) with full HP, SP reset and `deploy` effects (卡西米尔 / 叙拉古). Death priority 11: before 不屈 (10),
// whose redeploy "also consumes a 复活 charge" — with this order the charge is always the one used, same outcome.
const memberSet = new Set(members);
const max = Math.max(0, Math.floor(num(bb.max_free_respawn_cnt, 0)));
const st = { knocked: new Set(), revives: 0 };
battle.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || !memberSet.has(u) || st.knocked.has(u)) return;
st.knocked.add(u);
if (st.revives >= max || u.alive || u.removed) return;
if (!battle.redeploy(u, { free: true, tile: [u.tileR, u.tileC] }) && !battle.redeploy(u, { free: true })) return;
st.revives++;
S.fxOn(battle, 'revive', u, 'bond:egirShip', 'respawn', { n: st.revives });
}, { priority: 11 });
}
// =====================================================================================================================
// 叙拉古
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 (!dmg || !dmg.isAttack || dmg.isSplash || !isEnemyTarget(t) || ownTag(dmg, 'bond:siracusa')) 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', { atkMul: 1 + 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.enemiesInRadius(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 });
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);
}