卫戍协议:盟约 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

+497
View File
@@ -0,0 +1,497 @@
// server/sim/content/garrisons/battle.js — IN_BATTLE 特质 (garrisons) of the operators on the field.
//
// Each operator carries `unit.def.raw.garrisonIds` (elite chess carry the `_b` garrisons); the IN_BATTLE ones are
// dispatched here by their effectKey (bbStr.key for battle runes, else effectType — docs/DATA.md §4):
//
// ADD_BOND grants `give_garrison_id` to other operators before the first deployment (targets parsed from
// the text: 身前一格 / 身前一格【X】/ 自身和身前一格 / 同一行最右边 / 所有【X】). A unit owns a garrison id
// at most once (耀骑士临光 already carries the 144/159 it grants to itself). The 华法琳-granted trait
// (garrison_95) is capped 12 / 24 per battle instead of the data's 7 / 14 (research 02 Addendum 1).
// layer events act1autochess_gar_event_useskill (skillStart) · _selfkillenemy (kill, every check_cnt) ·
// _selfdead (death 'killed'; texts with 替身 also on every substitute ⇄ body swap, read from the
// dollkeeper flag unit.trait.doll) · _consume_ammo (ammoUsed; range_id 0-1 self, 1-1 front tile,
// x-5 self + 4 adjacent, pooled counter) · _enemy_abflag_inrange (an enemy in range ENTERS
// 'freeze', × prob) · act2autochess_gar_event_onstart (every deploy) ·
// act2autochess_gar_event_allyenemy_sleepstun_inrange (an enemy or operator in range ENTERS
// sleep / stun). "进入…时": re-applying a running status (a refresh) is not a new entry
// (engine statusApplied ctx.entered). Targets: bond_by_id / bond_self (own active bonds) / bond_actived_maxstack;
// amounts: by_count / by_charcount_samerow / by_charlevel; conditions character_same_row /
// character_same_col (≥ check_count incl. self). Gains go through support.gainLayers with
// reason 'garrison', source = the trait's owner, cap = max_add_count_per_battle per (instance, bond).
// act1autochess_gar_event_addition_cnt (魔王) layerGain: a 'garrison' gain whose source stands on the tile in front
// of 魔王 (range_id 1-1) — or was knocked out there this instant (幽灵鲨 "被击倒时"; engine ctx.tile)
// — gets +extra_cnt per bond (the extra does not count toward the source's per-battle cap).
// stat traits GAIN_BUFF (atkMul / hpMul), attr_common_global_buff (spRecoveryFlat), attrByBond,
// respawnTimeByBond, attrByBond_add_onstart, ab_damageScaleByBond — layer readers use
// floor(Σ layers of the listed ACTIVE bonds / divide_num) and are recomputed after every layer
// gain (research 02 §5 [ASSUMED] only active bonds count).
// hit modifiers act1autochess_gar_eff_attack_enemy (dmg.amount × atk vs check_tag enemies),
// ab_damageScaleByBond (bound / sluggish targets), _chaos (弱点伤害, research 04 glossary:
// phys/arts → whichever deals MORE after the engine's mitigation incl. the attacker's DEF/RES
// ignore and dealt/taken multipliers; a tie keeps the type).
// NONE 风丸 "2 copies merge" — prep rule (chess.upgradeNum 2), nothing in battle.
//
// Hooks: priority 0, except 弱点伤害 (hit, WEAKNESS_PRIORITY −10: decided on the final amount after the ATK
// multipliers). install() registers nothing when no operator carries an IN_BATTLE garrison.
import * as S from '../support/index.js';
import { mitigate } from '../../damage.js';
import { frontOf } from '../../dir.js';
/** 华法琳's granted trait: per-battle cap override (research 02 Addendum 1: PRTS 3/27 "初始7/精锐14 → 初始12/精锐24"). */
export const GRANTED_CAP_OVERRIDE = Object.freeze({ garrison_95_a: 12, garrison_95_b: 24 });
const ids = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const EMPTY = Object.freeze([]);
/** Status keys for check_ab_flag values used by the data (16 = 冻结). */
const AB_FLAG_STATUS = Object.freeze({ 16: 'freeze' });
function garrisonIdsOf(u) {
const list = u?.def?.raw?.garrisonIds ?? u?.def?.garrisonIds;
return Array.isArray(list) ? list : EMPTY;
}
// ---------------------------------------------------------------------------------------------------------------------
// install
export function install(battle) {
const all = [];
const owned = new Map(); // unit → Set(garrisonId)
const add = (unit, g, grantedBy = null) => {
let set = owned.get(unit);
if (!set) { set = new Set(); owned.set(unit, set); }
if (set.has(g.garrisonId)) return null;
set.add(g.garrisonId);
const bb = g.bb || {};
const override = grantedBy ? GRANTED_CAP_OVERRIDE[g.garrisonId] : undefined;
const cap = override ?? (S.num(bb.max_add_count_per_battle, 0) > 0 ? S.num(bb.max_add_count_per_battle) : Infinity);
const it = {
unit, g, gid: g.garrisonId, key: g.effectKey, bb, bbStr: g.bbStr || {}, grantedBy,
cap, capKey: `gar:${g.garrisonId}:${unit.id}`, cnt: 0, k: -1,
};
all.push(it);
return it;
};
for (const u of battle.allyUnits) {
if (!S.isOp(u)) continue;
for (const gid of garrisonIdsOf(u)) {
const g = S.garrisonRecord(gid);
if (g && g.eventType === 'IN_BATTLE') add(u, g);
}
}
if (!all.length) return;
const grants = [];
for (const it of all.slice()) {
if (it.key !== 'ADD_BOND') continue;
const g2 = S.garrisonRecord(it.bbStr.give_garrison_id);
if (!g2 || g2.eventType !== 'IN_BATTLE') continue;
for (const t of grantTargets(battle, it)) {
const got = add(t, g2, it);
if (got) grants.push(got);
}
}
const by = new Map();
for (const it of all) {
let arr = by.get(it.key);
if (!arr) { arr = []; by.set(it.key, arr); }
arr.push(it);
}
for (const [key, list] of by) {
const inst = INSTALLERS[key];
if (inst) inst(battle, list);
}
installHitMods(battle, by);
installReaders(battle, by);
if (grants.length) {
battle.on('battleStart', () => {
for (const it of grants) S.fxOn(battle, 'garrisonGrant', it.unit, `gar:${it.grantedBy.gid}`, it.gid, { from: it.grantedBy.unit.id });
});
}
}
// ---------------------------------------------------------------------------------------------------------------------
// ADD_BOND targets (before deployment: home tiles)
function opsOf(battle, pid) {
const out = [];
for (const u of battle.allyUnits) if (S.isOp(u) && u.ownerId === pid && !u.removed) out.push(u);
return out;
}
function opAtHome(battle, pid, r, c) {
for (const u of battle.allyUnits) if (S.isOp(u) && u.ownerId === pid && !u.removed && u.homeR === r && u.homeC === c) return u;
return null;
}
function grantTargets(battle, it) {
const u = it.unit;
const desc = it.g.desc || '';
const need = it.bbStr.check_bond_id || null;
const ok = (t) => !!t && (!need || S.isMember(battle, t, need));
const front = () => { const [fr, fc] = frontOf(u.homeR, u.homeC, u.dir); return opAtHome(battle, u.ownerId, fr, fc); };
if (desc.includes('所有')) return opsOf(battle, u.ownerId).filter(ok);
if (desc.includes('同一行最右边')) {
let best = null;
for (const t of opsOf(battle, u.ownerId)) {
if (t.homeR !== u.homeR) continue;
// a board position ("最右边" of the player's own board: the mirrored Final Assault right side counts from the left)
const col = (x) => (x.player && x.player.mirror ? -x.homeC : x.homeC);
if (!best || col(t) > col(best)) best = t;
}
return ok(best) ? [best] : [];
}
const out = [];
if (desc.includes('自身和身前') && ok(u)) out.push(u);
const f = front();
if (ok(f)) out.push(f);
return out;
}
// ---------------------------------------------------------------------------------------------------------------------
// layer gains
function targetBonds(battle, it) {
const u = it.unit;
switch (it.bbStr.bond_type) {
case 'bond_self': return S.unitBonds(u).filter((b) => S.bondActive(battle, u.ownerId, b));
case 'bond_actived_maxstack': { const b = S.topActiveBond(battle, u.ownerId); return b ? [b] : EMPTY; }
default: return ids(it.bbStr.bond_id);
}
}
function amountOf(battle, it) {
const bb = it.bb;
switch (it.bbStr.bond_add_type) {
case 'by_charcount_samerow': return S.rowMates(battle, it.unit).length * S.num(bb.bond_add_count_multi, S.num(bb.bond_add_count, 1));
case 'by_charlevel': return S.tierOf(it.unit) * S.num(bb.bond_add_count, 1);
default: return S.num(bb.bond_add_count, 0);
}
}
function conditionMet(battle, it) {
const ck = it.bbStr.conditionkey;
const need = S.num(it.bb.check_count, 0);
if (ck === 'character_same_row') return S.rowMates(battle, it.unit).length >= need;
if (ck === 'character_same_col') {
let n = 0;
for (const a of battle.allyUnits) if (S.isOp(a) && a.ownerId === it.unit.ownerId && S.onField(a) && a.tileC === it.unit.tileC) n++;
return n >= need;
}
return true;
}
/** Fire a layer-gain garrison instance. Returns the layers added. */
export function fireGain(battle, it) {
if (!conditionMet(battle, it)) return 0;
const bonds = targetBonds(battle, it);
if (!bonds.length) return 0;
const n = amountOf(battle, it);
const added = S.gainLayers(battle, {
playerId: it.unit.ownerId, bonds, n, requireActive: true, source: it.unit, reason: 'garrison', cap: it.cap, capKey: it.capKey,
});
if (added > 0) S.fxOn(battle, 'garrison', it.unit, `gar:${it.gid}`, it.key, { n: added });
return added;
}
const byUnit = (list) => {
const m = new Map();
for (const it of list) {
let a = m.get(it.unit);
if (!a) { a = []; m.set(it.unit, a); }
a.push(it);
}
return m;
};
function isFrontOf(holder, u) {
if (!u || u === holder || u.ownerId !== holder.ownerId || !S.onField(u)) return false;
const [fr, fc] = frontOf(holder.tileR, holder.tileC, holder.dir);
return u.tileR === fr && u.tileC === fc;
}
/**
* 魔王: the gain's source (an operator of the holder's player) stands in front of it. `tile` = the engine's layerGain
* ctx.tile: where the source stands, or where it was knocked out this very instant (幽灵鲨 "被击倒时" gains fire on death).
*/
function sourceInFront(holder, u, tile) {
if (!u || u === holder || u.ownerId !== holder.ownerId || !S.isOp(u) || !Array.isArray(tile)) return false;
const [fr, fc] = frontOf(holder.tileR, holder.tileC, holder.dir);
return tile[0] === fr && tile[1] === fc;
}
function ammoScope(it, u) {
const h = it.unit;
switch (it.bbStr.range_id) {
case '1-1': return S.onField(h) && isFrontOf(h, u);
case 'x-5': return u === h || (S.onField(h) && S.onField(u) && u.ownerId === h.ownerId && S.isOp(u) && Math.abs(u.tileR - h.tileR) + Math.abs(u.tileC - h.tileC) === 1);
default: return u === h;
}
}
const INSTALLERS = {
act1autochess_gar_event_useskill(battle, list) {
const m = byUnit(list);
battle.on('skillStart', ({ unit }) => {
const arr = m.get(unit);
if (arr) for (const it of arr) fireGain(battle, it);
});
},
act1autochess_gar_event_selfkillenemy(battle, list) {
const m = byUnit(list);
for (const it of list) it.allies = /我方干员/.test(it.g.desc || '');
battle.on('kill', ({ killer, victim }) => {
const arr = killer ? m.get(killer) : null;
if (!arr || !victim) return;
for (const it of arr) {
if (!(victim.side === 'enemy' || (it.allies && victim.side === 'ally' && victim.kind === 'op'))) continue;
it.cnt++;
if (it.cnt % Math.max(1, S.num(it.bb.check_cnt, 1)) === 0) fireGain(battle, it);
}
});
},
act1autochess_gar_event_selfdead(battle, list) {
const m = byUnit(list);
battle.on('death', ({ unit, reason }) => {
if (reason !== 'killed') return;
const arr = m.get(unit);
if (arr) for (const it of arr) fireGain(battle, it);
});
const swaps = list.filter((it) => /替身/.test(it.g.desc || ''));
if (!swaps.length) return;
for (const it of swaps) it.doll = false;
battle.on('tick', () => {
for (let i = 0; i < swaps.length; i++) {
const it = swaps[i];
const u = it.unit;
const doll = !!(u.trait && u.trait.doll);
if (doll === it.doll) continue;
it.doll = doll;
if (u.alive && u.deployed) fireGain(battle, it);
}
});
},
act1autochess_gar_event_consume_ammo(battle, list) {
battle.on('ammoUsed', ({ unit }) => {
if (!unit) return;
for (let i = 0; i < list.length; i++) {
const it = list[i];
if (!ammoScope(it, unit)) continue;
it.cnt++;
if (it.cnt % Math.max(1, S.num(it.bb.consume_count, 1)) === 0) fireGain(battle, it);
}
});
},
act1autochess_gar_event_enemy_abflag_inrange(battle, list) {
const watched = new Set(list.map((it) => AB_FLAG_STATUS[S.num(it.bb.check_ab_flag, 16)] ?? 'freeze'));
// "进入冻结时": only a new entry (engine ctx.entered — a refresh of a running freeze is not one)
battle.on('statusApplied', ({ target, status, entered }) => {
if (!target || !watched.has(status) || !entered || target.side !== 'enemy') return;
for (const it of list) {
if (status !== (AB_FLAG_STATUS[S.num(it.bb.check_ab_flag, 16)] ?? 'freeze')) continue;
if (!S.onField(it.unit) || !S.inRange(it.unit, target)) continue;
if (battle.rng() < S.num(it.bb.prob, 1)) fireGain(battle, it);
}
});
},
act2autochess_gar_event_onstart(battle, list) {
const m = byUnit(list);
battle.on('deploy', ({ unit }) => {
const arr = m.get(unit);
if (arr) for (const it of arr) fireGain(battle, it);
});
},
act2autochess_gar_event_allyenemy_sleepstun_inrange(battle, list) {
battle.on('statusApplied', ({ target, status, entered }) => { // "进入沉睡/晕眩时": new entries only (ctx.entered)
if (!target || (status !== 'sleep' && status !== 'stun') || !entered) return;
if (!(target.side === 'enemy' || (target.side === 'ally' && target.kind === 'op'))) return;
for (const it of list) if (S.onField(it.unit) && S.inRange(it.unit, target)) fireGain(battle, it);
});
},
act1autochess_gar_event_addition_cnt(battle, list) {
battle.on('layerGain', (ctx) => {
if (ctx.reason !== 'garrison' || !ctx.source) return;
for (const it of list) {
const h = it.unit;
if (h.ownerId !== ctx.playerId || !S.onField(h)) continue;
if (it.bbStr.range_id && it.bbStr.range_id !== '1-1') continue;
if (sourceInFront(h, ctx.source, ctx.tile)) ctx.n += S.num(it.bb.extra_cnt, 0);
}
});
},
GAIN_BUFF(battle, list) {
for (const it of list) {
const mods = {};
if (S.num(it.bb.atk, 0) > 0) mods.atkMul = S.num(it.bb.atk);
if (S.num(it.bb.max_hp, 0) > 0) mods.hpMul = S.num(it.bb.max_hp);
if (S.num(it.bb.def, 0) > 0) mods.defMul = S.num(it.bb.def);
S.passiveBuff(battle, it.unit, `gar:${it.gid}`, mods);
}
},
attr_common_global_buff(battle, list) {
for (const it of list) {
const mods = {};
if (S.num(it.bb.sp_recovery_per_sec, 0)) mods.spRecoveryFlat = S.num(it.bb.sp_recovery_per_sec);
if (S.num(it.bb.atk, 0)) mods.atkMul = 1 + S.num(it.bb.atk);
if (S.num(it.bb.max_hp, 0)) mods.hpMul = 1 + S.num(it.bb.max_hp);
if (S.num(it.bb.attack_speed, 0)) mods.aspd = S.num(it.bb.attack_speed);
S.passiveBuff(battle, it.unit, `gar:${it.gid}`, mods);
}
},
// grants are resolved in install(); 风丸's rule lives in the prep economy
ADD_BOND() {},
NONE() {},
};
// ---------------------------------------------------------------------------------------------------------------------
// layer readers (每叠加N层)
const READERS = new Set([
'act1autochess_gar_eff_attrByBond', 'act1autochess_gar_eff_respawnTimeByBond',
'act2autochess_gar_eff_attrByBond_add_onstart', 'act2autochess_gar_eff_ab_damageScaleByBond',
]);
/** floor(Σ layers of the listed active bonds / divide_num). */
export function stepsOf(battle, it) {
const div = Math.max(1, S.num(it.bb.divide_num, 1));
let sum = 0;
for (const b of it.bonds ?? (it.bonds = ids(it.bbStr.bond_id))) if (S.bondActive(battle, it.unit.ownerId, b)) sum += S.bondLayers(battle, it.unit.ownerId, b);
return Math.floor(sum / div);
}
function attrMods(bb, k) {
const m = {};
if (S.num(bb.atk, 0)) m.atkMul = 1 + S.num(bb.atk) * k;
if (S.num(bb.max_hp, 0)) m.hpMul = 1 + S.num(bb.max_hp) * k;
if (S.num(bb.def, 0)) m.defMul = 1 + S.num(bb.def) * k;
if (S.num(bb.attack_speed, 0)) m.aspd = S.num(bb.attack_speed) * k;
if (S.num(bb.hp_recovery_per_sec, 0)) m.hpRegen = S.num(bb.hp_recovery_per_sec) * k;
if (S.num(bb.sp_recovery_per_sec, 0)) m.spRecoveryFlat = S.num(bb.sp_recovery_per_sec) * k;
return m;
}
function applyReader(battle, it) {
const k = stepsOf(battle, it);
if (k === it.k) return;
it.k = k;
const u = it.unit;
const key = `gar:${it.gid}`;
switch (it.key) {
case 'act1autochess_gar_eff_attrByBond':
if (k > 0) S.passiveBuff(battle, u, key, attrMods(it.bb, k));
else battle.removeBuff(u, key);
break;
case 'act1autochess_gar_eff_respawnTimeByBond':
if (k > 0) S.passiveBuff(battle, u, key, { redeployMul: Math.max(0.05, 1 + S.num(it.bb.respawn_time, 0) * k) });
else battle.removeBuff(u, key);
break;
case 'act2autochess_gar_eff_attrByBond_add_onstart': {
const b = u.findBuff(key);
if (b && b.timeLeft > 0) onstartBuff(battle, it, b.timeLeft);
break;
}
default: break; // damageScale reads it.k in the hit hook
}
}
function onstartBuff(battle, it, duration) {
const k = Math.max(0, it.k);
const key = `gar:${it.gid}`;
if (!(k > 0) || !(duration > 0)) { battle.removeBuff(it.unit, key); return; }
battle.addBuff(it.unit, { key, duration, refresh: 'replace', mods: { atkFlat: S.num(it.bb.atk, 0) * k, hpFlat: S.num(it.bb.max_hp, 0) * k } });
}
function installReaders(battle, by) {
const list = [];
for (const key of READERS) for (const it of by.get(key) ?? EMPTY) list.push(it);
if (!list.length) return;
const recompute = () => { for (const it of list) applyReader(battle, it); };
recompute();
let pending = false;
battle.on('layerGain', () => {
if (pending) return;
pending = true;
battle.after(0, () => { pending = false; recompute(); });
});
const onstart = by.get('act2autochess_gar_eff_attrByBond_add_onstart');
if (onstart) {
const m = byUnit(onstart);
battle.on('deploy', ({ unit }) => {
const arr = m.get(unit);
if (!arr) return;
for (const it of arr) { it.k = stepsOf(battle, it); onstartBuff(battle, it, S.num(it.bb.duration, 0)); }
});
}
}
// ---------------------------------------------------------------------------------------------------------------------
// hit modifiers
/**
* 弱点伤害 (research 04 §glossary: "each hit is dealt as whichever of physical/arts yields MORE damage against that
* target's DEF/RES"): the engine's own mitigation formula incl. the attacker's DEF/RES ignore stats; a tie keeps the
* original type.
*/
export function weaknessType(source, target, dmg) {
const ss = source && source.s ? source.s : null;
const ign = {
defIgnorePct: S.num(dmg.defIgnorePct, 0) + (ss ? S.num(ss.defIgnorePct, 0) : 0),
defIgnoreFlat: S.num(dmg.defIgnoreFlat, 0) + (ss ? S.num(ss.defIgnoreFlat, 0) : 0),
resIgnorePct: S.num(dmg.resIgnorePct, 0) + (ss ? S.num(ss.resIgnorePct, 0) : 0),
resIgnoreFlat: S.num(dmg.resIgnoreFlat, 0) + (ss ? S.num(ss.resIgnoreFlat, 0) : 0),
};
const ph = mitigate(dmg.amount, 'phys', target.s, ign) * (ss ? S.num(ss.physDealtMul, 1) : 1) * S.num(target.s.physTakenMul, 1);
const ar = mitigate(dmg.amount, 'arts', target.s, ign) * (ss ? S.num(ss.artsDealtMul, 1) : 1) * S.num(target.s.artsTakenMul, 1);
if (ar > ph + 1e-9) return 'arts';
if (ph > ar + 1e-9) return 'phys';
return dmg.type;
}
const bound = (t) => !!(t.s && t.s.flags && t.s.flags.bind) || !!t.findBuff('bind');
/** Priority of the 弱点伤害 conversion: after every default-priority hit modifier, so the final amount decides. */
export const WEAKNESS_PRIORITY = -10;
function installHitMods(battle, by) {
const m = new Map();
const push = (it, kind) => {
let a = m.get(it.unit);
if (!a) { a = []; m.set(it.unit, a); }
a.push({ it, kind });
};
for (const it of by.get('act1autochess_gar_eff_attack_enemy') ?? EMPTY) push(it, 'tag');
for (const it of by.get('act2autochess_gar_eff_ab_damageScaleByBond') ?? EMPTY) push(it, 'ab');
if (m.size) {
battle.on('hit', ({ source, target, dmg }) => {
const arr = source ? m.get(source) : null;
if (!arr || !target || target.side !== 'enemy' || !dmg || dmg.type === 'element') return;
for (let i = 0; i < arr.length; i++) {
const { it, kind } = arr[i];
if (kind === 'tag') {
// an ATK multiplier: fixed-value DoT ticks are not ATK-based
if (Array.isArray(dmg.tags) && dmg.tags.includes('dot')) continue;
const tags = target.def && target.def.tags;
if (Array.isArray(tags) && tags.includes(it.bbStr.check_tag)) dmg.amount *= S.num(it.bb.atk, 1);
} else if (it.k > 0) {
const hit = (S.num(it.bb.check_ab_flag, 0) && bound(target)) || (S.num(it.bb.check_sluggish, 0) && !!target.findBuff('sluggish'));
if (hit) dmg.mul = S.num(dmg.mul, 1) * (1 + S.num(it.bb.damage_scale_per_stack, 0) * it.k);
}
}
});
}
const chaos = new Set((by.get('act1autochess_gar_eff_chaos') ?? EMPTY).map((it) => it.unit));
if (chaos.size) {
battle.on('hit', ({ source, target, dmg }) => {
if (!source || !chaos.has(source) || !target || target.side !== 'enemy' || !dmg) return;
if (dmg.type === 'phys' || dmg.type === 'arts') dmg.type = weaknessType(source, target, dmg);
}, { priority: WEAKNESS_PRIORITY });
}
}
+412
View File
@@ -0,0 +1,412 @@
// server/sim/content/garrisons/meta.js — prep-side ("SERVER_*") 特质 (docs/META.md §2.3).
//
// One registry handler per effectKey (`garrison:<effectKey>`); the dispatcher calls it on the garrison's eventType hook
// with ctx.source = { piece, garrisonId, garrison, bb, bbStr, where }. Numbers come from the garrison's own bb / bbStr
// (normal `_a` and elite `_b` records). Layer wording: "(无需激活盟约)" → requireActive false; "已激活" / "激活且" →
// requireActive true. The dispatcher already repeats onGain garrisons ×2 / ×3 under 投资人 and skips hand pieces for
// conditionkey character_target_inboard.
//
// Re-triggers (铃兰 SERVER_TRIGGER_*, 瑰盐, 塞雷娅 / 白面鸮 "特质与其相同") go through the engine's
// ctx.triggerGarrisons (same handlers, 投资人 repeat, per-handler isolation, shared depth cap).
// triggerGainEffects(ctx, piece) is exported for 铃兰-style effects of other modules (bands.js 铃兰 御守之力).
//
// Decisions (documented, see research 02 §4 / 03 §5.5):
// * "同一行有3名干员" / "同一行每有1名干员" count the trait's owner (board chess of that row, self included).
// * POSITION "使自身及身后/身前一格干员的已激活盟约分别各层数+N": per target operator (a shared bond gets +N twice).
// * 瑰盐 "优先…更靠上的和更靠右的": highest row first (DESIGN §3.1: row 0 is the bottom), then highest column.
// * 购买价格 (SERVER_CHESS_PRICE) replaces the base price; earlier price deltas (远见 discount …) are kept.
// * 至简 costs bb.price = 2 (its text says 1; blackboards are authoritative).
// * [ASSUMED] 余 SERVER_MOST_BOND: ties between most-member bonds are shuffled; the chess is a copy-weighted pool roll
// of any tier (the text gives no tier cap); a bond without an available chess falls through to the next tied one.
// * [ASSUMED] 松果: the "免费特殊招募" is a free pick-one offer of `rewardOffer.count` (3) chess of the pool's tier.
import { metaBonds, frontPiece, behindPiece, distinctTiers } from '../support/meta.js';
const ids = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d);
/** "(无需激活盟约)" → false; "使已激活的…" / "当前激活且…" / "自身已激活的盟约" → true. */
export function requireActiveOf(g) {
const d = (g && g.desc) || '';
if (d.includes('无需激活')) return false;
return /已激活|激活且/.test(d);
}
const bondOrder = (ctx) => (Array.isArray(ctx.gd && ctx.gd.bondIds) ? ctx.gd.bondIds : Object.keys(ctx.data.bonds || {}));
/** Active bond with the most layers (ties → bond data order), or null. */
export function topActiveBondMeta(ctx) {
let best = null, bl = -1;
for (const id of bondOrder(ctx)) {
if (!ctx.bondActive(id)) continue;
const l = ctx.layers(id);
if (l > bl) { bl = l; best = id; }
}
return best;
}
/** Fresh view of the source piece (with row/col on the board). */
function selfView(ctx) {
const p = ctx.source && ctx.source.piece;
if (!p) return null;
const v = Number.isInteger(p.uid) && p.uid > 0 ? ctx.piece(p.uid) : null;
return v || p;
}
const onBoard = (v) => !!v && v.area === 'board' && Number.isInteger(v.row);
const chessOnBoard = (ctx) => ctx.board().filter((p) => p && p.kind === 'chess');
function addAll(ctx, bonds, n, requireActive) {
let total = 0;
if (!(n > 0)) return 0;
for (const b of bonds) if (b) total += ctx.addLayers(b, n, { requireActive });
return total;
}
function sameRowCount(ctx, me) {
if (!onBoard(me)) return 0;
return chessOnBoard(ctx).filter((p) => p.row === me.row).length;
}
function rowCondition(ctx, me) {
const { bb, bbStr } = ctx.source;
if (bbStr.conditionkey === 'character_same_row') return sameRowCount(ctx, me) >= num(bb.check_count, 0);
return true;
}
// ---------------------------------------------------------------------------------------------------------------------
// handlers
const H = {};
H.SERVER_ADD_BOND = {
run(ctx) {
const { bb, bbStr, garrison, piece } = ctx.source;
const bonds = ids(bbStr.bond);
// 录武官 (hidden 4_15) carries garrison_157 (【奇迹】+3) next to garrison_156 (【炎】+6、【奇迹】+3) with the same text:
// the text grants 奇迹 once, so a bond already covered by the piece's SERVER_ADD_MULTIPLE_BOND is skipped.
const rec = piece ? ctx.chessRecord(piece.id) : null;
const covered = new Set();
for (const gid of (rec && Array.isArray(rec.garrisonIds) ? rec.garrisonIds : [])) {
const g = ctx.gd.garrison(gid);
if (g && g !== garrison && g.effectType === 'SERVER_ADD_MULTIPLE_BOND' && g.eventType === garrison.eventType && g.desc === garrison.desc) for (const b of ids(g.bbStr && g.bbStr.bond)) covered.add(b);
}
addAll(ctx, bonds.filter((b) => !covered.has(b)), num(bb.count), requireActiveOf(garrison));
},
};
H.SERVER_ADD_BOND_CHESS_ALL = {
run(ctx) {
const { bb, garrison } = ctx.source;
addAll(ctx, metaBonds(ctx, ctx.source.piece), num(bb.count), requireActiveOf(garrison));
},
};
H.SERVER_ADD_BOND_METHOD = {
run(ctx) {
const { bb, bbStr, garrison } = ctx.source;
const multi = num(bb.multi, 1);
const bonds = ids(bbStr.bond);
const ra = requireActiveOf(garrison);
const me = selfView(ctx);
switch (bbStr.add_method) {
case 'shoplv': addAll(ctx, bonds, ctx.shopLevel() * multi, ra); break;
case 'round_gain_char': addAll(ctx, bonds, (ctx.roundStats().gainedChess | 0) * multi, ra); break;
case 'hand_count': addAll(ctx, bonds, ctx.hand().filter((p) => p && p.kind === 'chess').length * multi, ra); break;
case 'same_row': addAll(ctx, bonds, sameRowCount(ctx, me) * multi, ra); break;
case 'same_bond_diff_lv':
for (const b of bonds) addAll(ctx, [b], distinctTiers(ctx, (p) => metaBonds(ctx, p).includes(b)).size * multi, ra);
break;
default: break;
}
},
};
H.SERVER_ADD_MULTIPLE_BOND = {
run(ctx) {
const { bbStr, garrison } = ctx.source;
const bonds = ids(bbStr.bond);
const counts = ids(bbStr.count).map((x) => num(x));
bonds.forEach((b, i) => addAll(ctx, [b], counts[i] ?? counts[counts.length - 1] ?? 0, requireActiveOf(garrison)));
},
};
H.SERVER_ADD_BOND_ACTIVATED_MOST_LAYER = {
run(ctx) {
const b = topActiveBondMeta(ctx);
if (b) ctx.addLayers(b, num(ctx.source.bb.count), { requireActive: true });
},
};
H.SERVER_ADD_ACT_BOND_DIFF_LV_MOST_LAYER = {
run(ctx) {
const b = topActiveBondMeta(ctx);
if (!b) return;
const n = distinctTiers(ctx, (p) => metaBonds(ctx, p).includes(b)).size;
ctx.addLayers(b, n * num(ctx.source.bb.multi, 1), { requireActive: true });
},
};
H.SERVER_ADD_BOND_IN_HAND = {
run(ctx) {
const n = num(ctx.source.bb.count);
for (const p of ctx.hand()) {
if (!p || p.kind !== 'chess') continue;
for (const b of metaBonds(ctx, p)) if (ctx.bondActive(b)) ctx.addLayers(b, n, { requireActive: true });
}
},
};
// "使自身及身后/身前一格干员的已激活盟约分别各层数+N": the trait's 付与对象 are two operators (PRTS 下半 更新记录 on
// 断崖: "自身外的付与对象从 身前一格干员 调整至 身后一格干员"); each target's active bonds get +N, so a bond both
// operators share gets +N twice (same per-operator reading as 流明 "每名干员所在的已激活盟约分别层数+N").
H.SERVER_ADD_BOND_POSITION = {
run(ctx) {
const { bb, bbStr } = ctx.source;
const me = selfView(ctx);
if (!onBoard(me)) return;
const other = bbStr.dir === 'behind' ? behindPiece(ctx, me) : frontPiece(ctx, me);
const targets = [me];
if (other && other.kind === 'chess' && other.uid !== me.uid) targets.push(other);
for (const t of targets) for (const b of metaBonds(ctx, t)) if (ctx.bondActive(b)) ctx.addLayers(b, num(bb.count), { requireActive: true });
},
};
H.SERVER_ADD_BOND_ROUND_COIN_COST = {
run(ctx) {
const { bb, bbStr, garrison } = ctx.source;
const per = Math.max(1, num(bb.count, 1));
addAll(ctx, ids(bbStr.bond), Math.floor((ctx.roundStats().spent | 0) / per) * num(bb.layer), requireActiveOf(garrison));
},
};
H.SERVER_ADD_REFRESH_CNT_MULTIPLIER_BOND_LAYER = {
run(ctx) {
const { bb, bbStr, garrison } = ctx.source;
let n = (ctx.roundStats().refreshes | 0) * num(bb.multiplier);
if (num(bb.max_layer, 0) > 0) n = Math.min(n, num(bb.max_layer));
addAll(ctx, ids(bbStr.bond), n, requireActiveOf(garrison));
},
};
H.SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT = {
onRefresh(ctx, ev) {
if (ev && ev.trigger) return;
const { bb, bbStr, garrison } = ctx.source;
if ((ctx.roundStats().refreshes | 0) !== num(bb.refresh_cnt, 1)) return;
addAll(ctx, ids(bbStr.bond), num(bb.layer), requireActiveOf(garrison));
},
};
// "购买价格为N": the trait replaces the chess's base price (bb.price — 至简's data says 2 although its text says 1;
// blackboards are authoritative). Modifiers that ran earlier in the dispatch (bonds such as 远见's discount) keep their
// delta relative to the base price instead of being overwritten.
H.SERVER_CHESS_PRICE = {
onPrice(ctx, ev) {
const p = ctx.source.bb.price;
if (!Number.isFinite(p) || !ev) return;
const base = ev.slot && Number.isFinite(ev.slot.basePrice) ? ev.slot.basePrice : null;
const cur = Number.isFinite(Number(ev.price)) ? Number(ev.price) : base;
ctx.setPrice(base == null || cur == null ? p : p + (cur - base));
},
};
H.SERVER_GAIN_EQUIP = {
run(ctx) {
const { bb, bbStr } = ctx.source;
if (!ctx.gd.item(bbStr.chess)) return;
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) ctx.grantItem(bbStr.chess);
},
};
H.SERVER_GAIN_FREE_REFRESH_COUNT = {
run(ctx) { ctx.grantFreeRefresh(num(ctx.source.bb.count, 1)); },
};
H.SERVER_GAIN_RANDOM_EQUIP_CHESS_IN_POOL = {
run(ctx) {
const { bb, bbStr } = ctx.source;
const rounds = ids(bbStr.round_list).map((x) => num(x, -1));
if (rounds.length && !rounds.includes(ctx.round)) return;
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) {
const r = ctx.rollPool(bbStr.pool);
const id = r && r.kind === 'item' ? r.id : ctx.rollItem({ maxTier: ctx.shopLevel() });
if (id) ctx.grantItem(id);
}
},
};
/** Weighted item roll of an equip pool; elite garrisons use the pool's `goldenWeights` when present. */
function rollEquip(ctx, poolId, golden) {
const pool = ctx.data.choices && ctx.data.choices.pools ? ctx.data.choices.pools[poolId] : null;
if (golden && pool && Array.isArray(pool.weighted) && Array.isArray(pool.goldenWeights) && pool.goldenWeights.length === pool.weighted.length) {
const pairs = pool.weighted.map((x, i) => [x[0], Math.max(0, num(pool.goldenWeights[i]))]).filter(([id, w]) => w > 0 && ctx.gd.item(id));
let total = 0;
for (const [, w] of pairs) total += w;
if (total > 0) {
let r = ctx.rng() * total;
for (const [id, w] of pairs) { r -= w; if (r < 0) return id; }
return pairs[pairs.length - 1][0];
}
}
const r = ctx.rollPool(poolId);
return r && r.kind === 'item' ? r.id : null;
}
H.SERVER_POOL_EQUIP = {
run(ctx) {
const { bb, bbStr, garrisonId } = ctx.source;
const golden = /_b$/.test(garrisonId);
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) {
const id = rollEquip(ctx, bbStr.pool, golden);
if (id) ctx.grantItem(id);
}
},
};
H.SERVER_POOL_CHAR = {
run(ctx) {
const { bb, bbStr } = ctx.source;
if (!rowCondition(ctx, selfView(ctx))) return;
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) {
const r = ctx.rollPool(bbStr.pool);
if (r && r.kind === 'chess') ctx.grantChess(r.id, { golden: !!r.golden });
}
},
};
H.SERVER_MOST_BOND = {
run(ctx) {
if (!rowCondition(ctx, selfView(ctx))) return;
let best = [], bc = 0;
for (const id of bondOrder(ctx)) {
const c = ctx.bondCount(id);
if (c > bc) { bc = c; best = [id]; } else if (c === bc && c > 0) best.push(id);
}
const order = best.length > 1 ? ctx.rng.shuffle(best.slice()) : best;
for (const bond of order) {
const id = ctx.rollChess({ bond, maxTier: 6 });
if (id && ctx.grantChess(id)) return;
}
},
};
H.SERVER_ONCE_GOLD = {
run(ctx) { ctx.addPendingFunds(num(ctx.source.bb.count)); },
};
H.SERVER_ONCE_GOLD_WITH_BOND_CONDITION = {
run(ctx) {
const { bb, bbStr, where } = ctx.source;
if (where === 'hand' && !ids(bbStr.bond).some((b) => ctx.bondActive(b))) return;
ctx.addPendingFunds(num(bb.count));
},
};
H.SERVER_SELL_CHESS_GAIN_SPECIAL_GOODS = {
onSold(ctx) {
const { bbStr } = ctx.source;
const poolId = bbStr[`pool${ctx.shopLevel()}`] || bbStr.max_pool;
const pool = ctx.data.choices && ctx.data.choices.pools ? ctx.data.choices.pools[poolId] : null;
const tier = pool && Number.isInteger(pool.tier) ? pool.tier : null;
const count = Math.max(1, num(ctx.gd.rewardOffer ? ctx.gd.rewardOffer().count : 3, 3));
const list = [];
for (let i = 0; i < count * 4 && list.length < count; i++) {
const r = ctx.rollPool(poolId);
if (r && r.kind === 'chess' && !list.includes(r.id)) list.push(r.id);
}
if (list.length) ctx.offerChess(list, { tier });
else if (tier) ctx.offerChess(null, { tier });
},
};
// ---------------------------------------------------------------------------------------------------------------------
// re-triggers — through the engine's ctx.triggerGarrisons (dispatcher: same handlers, 投资人 repeat for SERVER_GAIN,
// per-handler error isolation, shared depth cap)
const garrisonsOfPiece = (ctx, piece) => {
const rec = piece ? ctx.chessRecord(piece.id) : null;
return (rec && Array.isArray(rec.garrisonIds) ? rec.garrisonIds : []).map((gid) => ctx.gd.garrison(gid)).filter(Boolean);
};
const hasEvent = (ctx, piece, eventType) => garrisonsOfPiece(ctx, piece).some((g) => g.eventType === eventType);
/**
* Run the garrisons of the owned chess `piece` whose eventType is `eventType` once more (ctx.triggerGarrisons).
* `asPiece` runs them as that piece's own trait (塞雷娅 / 白面鸮 "特质与其相同"). SERVER_GAIN repeats ×投资人.
* Returns the number of garrison effects run.
*/
export function runGarrisonsOf(ctx, piece, eventType, { asPiece = null } = {}) {
if (!ctx || typeof ctx.triggerGarrisons !== 'function' || !piece || piece.kind !== 'chess' || !Number.isInteger(piece.uid)) return 0;
const opts = asPiece && Number.isInteger(asPiece.uid) ? { asUid: asPiece.uid } : {};
return ctx.triggerGarrisons(piece.uid, eventType, opts) | 0;
}
/** Run the 获得时 (SERVER_GAIN) garrisons of an owned chess piece once more (×投资人). Returns the effects run. */
export function triggerGainEffects(ctx, piece) {
const p = piece && Number.isInteger(piece.uid) && piece.uid > 0 ? (ctx.piece(piece.uid) || piece) : piece;
if (!p || p.kind !== 'chess') return 0;
return runGarrisonsOf(ctx, p, 'SERVER_GAIN');
}
// 铃兰 60_a: "<进入休整期时>触发身前一格的其他干员的“获得时”类效果" (scope front)
// 瑰盐 82: "<售出时>触发场上一名拥有“休整期结束时”的干员的特质(优先触发部署位置更靠上的和更靠右的)" (scope farright).
// DESIGN §3.1: row 0 is the bottom, so "更靠上" = the highest row; then the highest column ("更靠右").
H.SERVER_TRIGGER_ANOTHER = {
run(ctx) {
const { bbStr } = ctx.source;
const event = bbStr.event || 'SERVER_GAIN';
const self = selfView(ctx);
if (bbStr.scope === 'front') {
if (!onBoard(self)) return;
const f = frontPiece(ctx, self);
if (f && f.kind === 'chess' && f.uid !== self.uid) runGarrisonsOf(ctx, f, event);
return;
}
const cands = chessOnBoard(ctx).filter((p) => (!self || p.uid !== self.uid) && hasEvent(ctx, p, event));
cands.sort((a, b) => (b.row - a.row) || (b.col - a.col));
if (cands.length) runGarrisonsOf(ctx, cands[0], event);
},
};
// 铃兰 60_b: "<进入休整期时>触发身前两格的其他干员的“获得时”类效果" (the two tiles in front, nearest first)
H.SERVER_TRIGGER_FRONT_COUNT = {
run(ctx) {
const { bb, bbStr } = ctx.source;
const self = selfView(ctx);
if (!onBoard(self)) return;
for (let k = 1; k <= Math.min(8, num(bb.count, 1)); k++) {
const f = frontPiece(ctx, self, k);
if (f && f.kind === 'chess' && f.uid !== self.uid) runGarrisonsOf(ctx, f, bbStr.event || 'SERVER_GAIN');
}
},
};
// 塞雷娅 / 白面鸮: "身前一格干员若为“X”特质,本干员的特质与其相同" — the front operator's X garrisons run as the copier's
// own (self-relative effects use the copier's tile / bonds). A copier of the same kind in front copies what that one
// copies (the chain is followed, bounded).
function copyFront(ctx, eventType, selfKey) {
const self = selfView(ctx);
if (!onBoard(self)) return;
let target = frontPiece(ctx, self);
for (let i = 0; i < 8 && target && target.kind === 'chess'; i++) {
const gs = garrisonsOfPiece(ctx, target).filter((g) => g.eventType === eventType);
if (!gs.length) return;
if (!gs.every((g) => g.effectKey === selfKey)) break;
target = frontPiece(ctx, target);
}
if (!target || target.kind !== 'chess' || target.uid === self.uid) return;
runGarrisonsOf(ctx, target, eventType, { asPiece: self });
}
H.SERVER_FRONT_SAME_EFFECT_PREP_FIN = {
run(ctx) { copyFront(ctx, 'SERVER_PREP_FIN', 'SERVER_FRONT_SAME_EFFECT_PREP_FIN'); },
};
H.SERVER_FRONT_SAME_EFFECT_PREP_START = {
run(ctx) { copyFront(ctx, 'SERVER_PREP_START', 'SERVER_FRONT_SAME_EFFECT_PREP_START'); },
};
/** effectKey → handler (the registered objects). */
export const HANDLERS = Object.freeze(H);
export function registerMeta(registry) {
for (const [key, h] of Object.entries(H)) registry.garrison(key, h);
}