Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit

- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
sganggs committed 2026-10-02 04:19:28 +08:00
1 parent 6464e4b46a
commit 620f88c0d8
81 files changed
+4194 -447

No files matched your search

+10 -14
View File
@@ -4,8 +4,8 @@
// (numbers from the blackboard, never hard-coded). Band buffs apply in every battle kind (normal / 联防 / boss /
// hidden); IN_BATTLE layer gains are no-ops where the match disables them (support.gainLayers).
//
// act1autochess_band2_buff 阿米娅 众志合一 ≥ value_i active bonds (highest i) ⇒ every operator ATK × (1 + atk_i),
// HP × (1 + max_hp_i) (直接乘算, bonds snapshot at combat start)
// act1autochess_band2_buff 阿米娅 众志合一 ≥ value_i active bonds (highest i) ⇒ every operator ATK +atk_i,
// HP +max_hp_i (直接乘算 — support directMods —, bonds snapshot at combat start)
// act1autochess_band28_buff 埃芒加德 命结之秘 the first max_respawn_cnt operator knock-downs of the battle revive at
// once at full HP (runs after every other death saver, items included)
// act1autochess_band13_buff 克莱门莎 崇高牺牲 a <bond_id> operator knocked down ⇒ +its tier (等阶) <bond_id> layers
@@ -18,13 +18,13 @@
// act1autochess_band18_buff 休谟斯 回收利用 a ground (地面) operator's skill ends ⇒ a random operator on its 4
// neighbouring tiles gains `sp` SP
// act1autochess_band15_buff 卡莱莎 食腐之蝶 an operator knocked down ⇒ every other unit of the player on the field
// ATK × (1 + atk × n), n ≤ max_stack_cnt (+20 % … +200 %, additive stacks),
// ATK +atk × n, n ≤ max_stack_cnt (+20 % … +200 %, additive stacks),
// until that unit is knocked down itself or the battle ends
// act1autochess_band19_buff 陈 以己之长 the player's operators' physical / arts damage becomes 弱点伤害
// act2autochess_band12_buff 夕 墨色真颜 at combat start operators of which ≥ 2 of the same name (same base chess,
// elite or not) stand on the field get ATK × (1 + atk)
// elite or not) stand on the field get ATK +atk
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
// ATK × (1 + atk_per_cnt × n), HP × (1 + max_hp_per_cnt × n)
// ATK +atk_per_cnt × n, HP +max_hp_per_cnt × n (all 直接乘算)
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match
// holds band_amedic, every player's field gets the map character of the
// variant whose common_condition holds for that player's elites on the field
@@ -34,7 +34,7 @@
import {
num, buffsOf, bandRecord, isOp, onField, isElite, tierOf, unitBonds, activeBondIds, playerOps, passiveBuff, fxOn,
matchBands, gainLayers, alliesAround, N4, baseChessId, isGroundOp,
matchBands, gainLayers, alliesAround, N4, baseChessId, isGroundOp, directMods,
} from '../support/index.js';
import { weaknessRetype, addShieldLayer, PRIO_REVIVE } from '../items/battle.js';
import { spawnMapChar } from '../tokens.js';
@@ -58,9 +58,7 @@ const BY_KEY = {
if (n >= num(p[`value_${i}`], Infinity)) { atk = num(p[`atk_${i}`]); hp = num(p[`max_hp_${i}`]); }
}
if (!(atk > 0 || hp > 0)) return;
const mods = {};
if (atk) mods.atkMul = 1 + atk;
if (hp) mods.hpMul = 1 + hp;
const mods = directMods({ atk, hp });
for (const u of playerOps(battle, ps.playerId)) passiveBuff(battle, u, keyOf(bandId), mods);
},
@@ -157,7 +155,7 @@ const BY_KEY = {
if (a === d) continue;
const cur = a.findBuff(key);
const n = Math.min(cap, (cur && cur.data ? cur.data.n : 0) + 1);
battle.addBuff(a, { key, mods: { atkMul: 1 + step * n }, refresh: 'replace', data: { n }, visible: true });
battle.addBuff(a, { key, mods: directMods({ atk: step * n }), refresh: 'replace', data: { n }, visible: true });
}
});
},
@@ -184,7 +182,7 @@ const BY_KEY = {
}
for (const list of groups.values()) {
if (list.length < 2) continue;
for (const u of list) passiveBuff(battle, u, keyOf(bandId), { atkMul: 1 + atk });
for (const u of list) passiveBuff(battle, u, keyOf(bandId), directMods({ atk }));
}
});
},
@@ -197,9 +195,7 @@ const BY_KEY = {
const elites = deployedOps(battle, ps.playerId).filter(isElite);
const n = elites.length;
if (!n) return;
const mods = {};
if (a) mods.atkMul = 1 + a * n;
if (h) mods.hpMul = 1 + h * n;
const mods = directMods({ atk: a * n, hp: h * n });
for (const u of elites) passiveBuff(battle, u, keyOf(bandId), mods);
});
},
+38 -35
View File
@@ -1,33 +1,39 @@
// 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
// Every number comes from data/bonds.json (`env_gbuff_new` buff blackboards). ATK / DEF / max HP "+X%" are 直接乘算
// (support directMods: additive with every other percentage, PRTS 盟约记录). 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,
// 精准 preciShip members ATK +(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
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s; tier 2: ×power_weak_scale
// when the target is below hp_ratio at application. ONE instance per target whatever applies it — the
// two players of a pair field compete for it, the strongest wins (battle.applyStrongest, 同名效果取最高:
// PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 first-hand: "共享型buff會跟對面搶
// 如果對面層數比你高就不需要再特別激活直接吃他的奧術buff"). v2.5 kept one per player, so two players'
// instances multiplied (×5.4 × ×5.6 on a leader at ~250 layers, DESIGN §20.10)
// 坚守 steadShip all operators max HP +(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)
// (base + per·L arts, sourceless but credited to the member hit, ≤ 1 per cd_duration per member)
// + 脆弱 ×damage_scale for weak[limit] s
// 助力 deputShip all operators DEF +(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)
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP +(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
// 独行 soloShip the member(s) ATK +atk, HP +max_hp, +sp SP on every deploy
// 绝技 suntShip tier 1: elite operators ATK +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
@@ -37,7 +43,7 @@ import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { localOrder, localBefore } from '../../../dir.js';
import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
fxOn, N4, N8, bodyInKeys,
fxOn, N4, N8, bodyInKeys, directMods,
} from '../../support/index.js';
export const ID = Object.freeze({
@@ -56,6 +62,9 @@ const AURA_POLL = 0.25;
const RAID_POLL = 0.25;
const RAID_SEARCH = 2; // landing tiles within this Chebyshev distance of the target enemy
/** 奥术 vulnerability mods for a multiplier (battle.applyStrongest). */
const arcaneMods = (v) => ({ artsTakenMul: v });
/** `env_gbuff_new` blackboard of a bond (numbers only). */
export function bondBb(bondId) {
return buffParams(bondRecord(bondId), 'env_gbuff_new') ?? {};
@@ -90,7 +99,7 @@ function applyPassives(battle, st) {
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) };
const mods = directMods({ atk: 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);
@@ -98,12 +107,12 @@ function applyPassives(battle, st) {
}
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) };
const mods = directMods({ hp: 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)) };
const mods = directMods({ def: 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]) {
@@ -124,8 +133,8 @@ function applyPassives(battle, st) {
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)) };
st.arcaneMul = Math.max(0, m);
st.arcaneLowMul = Math.max(0, m * num(bb.power_weak_scale, 1));
}
if (t[ID.skillful]) {
const bb = st.bb[ID.skillful];
@@ -137,10 +146,10 @@ function applyPassives(battle, st) {
}
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 mods = directMods({
atk: num(bb.base_atk) + num(bb.atk_per_stack) * lv,
hp: 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;
@@ -161,12 +170,12 @@ function applyStatic(battle, st) {
}
if (t[ID.solo]) {
const bb = st.bb[ID.solo];
const mods = { atkMul: 1 + num(bb.atk), hpMul: 1 + num(bb.max_hp) };
const mods = directMods({ atk: num(bb.atk), hp: 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) };
const mods = directMods({ atk: num(bb.power_atk) });
for (const u of st.ops) {
if (!isElite(u)) continue;
passiveBuff(battle, u, KEY.sunt, mods);
@@ -438,9 +447,12 @@ export function install(battle) {
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 });
// "伤害来源受到(850+10×L)点法术伤害": 无来源 (no attacker multipliers, hooks see no source) but credited to the
// member hit — its player's stats and shared-pool tally count it, like an element burst (v2.5: no credit at all,
// up to 19 % of a co-op leader's pool and 88 % of a solo one went unattributed, DESIGN §20.10)
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, sourceless: true });
d.steadThorn = true;
battle.dealDamage(null, src, d);
battle.dealDamage(t, 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;
}
@@ -451,17 +463,8 @@ export function install(battle) {
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 });
}
// one 奥术 instance per target, the strongest (both players of a pair field included): see the header
battle.applyStrongest(t, KEY.arcane, { duration: num(bb.weak_duration, 3), value: low ? st.arcaneLowMul : st.arcaneMul, mods: arcaneMods, source: src });
});
}
}
+13 -12
View File
@@ -1,20 +1,21 @@
// 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 “炎佑” (its template ATK / max HP + 30 %
// 炎 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 atkMul); 6 + band_narant: instead lends the caster's equipment (tier ≤
// (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);
// 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());
// 阿戈尔 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 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 +
@@ -176,8 +177,8 @@ const ownTag = (dmg, tag) => !!dmg && Array.isArray(dmg.tags) && dmg.tags.includ
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 });
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);
@@ -220,7 +221,7 @@ function installSargon(battle, pid, bb, members) {
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 } });
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 }) => {
@@ -278,7 +279,7 @@ function installVictoria(battle, pid, bb, members) {
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 });
if (add > 0) S.passiveBuff(battle, u, 'bond:victoria:atk', S.directMods({ atk: add }));
}
}
@@ -320,7 +321,7 @@ function installLaterano(battle, pid, bb, members) {
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 });
for (const m of members) S.passiveBuff(battle, m, 'bond:laterano:ammo', S.directMods({ atk: st.bonus }));
});
}
@@ -391,8 +392,8 @@ function devour(battle, pid, bb, members) {
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 });
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);
@@ -464,7 +465,7 @@ function installKazimierz(battle, pid, bb, members) {
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 });
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;
+150 -50
View File
@@ -2,8 +2,8 @@
//
// install(battle) ensures the enemy dispatch hooks (enemies.js) and attaches a boss kit to every spawned leader / part
// listed in BOSS_KITS. Damage dealt TO a leader routes to the shared boss pool through the engine (tag 'boss' +
// sharedBoss); parts that "传递伤害" push a share of the damage they take into the pool with battle.loseHp(leader, …)
// credited to the attacker. Numbers come from the (template-overridden) talent / skill blackboards; the rest are named
// sharedBoss); parts that "传递伤害" push the damage they take into the pool with a 无来源 battle.loseHp(leader, …)
// credited to the attacker (PART_TRANSFER = 1, "等量"). Numbers come from the (template-overridden) talent / skill blackboards; the rest are named
// constants ([ASSUMED] or PRTS-sourced). Boss HP rules ("<20 %", "≤50 %") read the leader's HP ratio, which the engine
// syncs from the shared pool, so both mirrored copies switch phase together.
//
@@ -17,18 +17,23 @@
// (skipped if the match already spawned a second copy, or opts.bossMirror === false).
//
// Leaders (see each kit for details):
// boss_1/8 假想敌:胄 random arts beam (range 8); 灭顶之灾 刺胄之弹 at the highest-ATK operator (8-hit flying shell,
// 3×3 stun + phys DoT on arrival); <20 %: damage ×0.5 + 2 shells; 死亡集群 drones — a drone
// killed by an operator costs the leader 2 % max HP. boss_8 adds 斩胄之剑 / 破胄之锤.
// 斩胄之剑 / 破胄之锤 hover invulnerable next to 胄, AoE attacks; dive at the lowest-ATK operator (3×3 stun + DoT);
// 15 hits during the dive shoot it down: grounded 20 s, damage ×1.3, PART_TRANSFER to 胄.
// boss_1/8 假想敌:胄 random arts beam (range 8); 灭顶之灾 one 刺胄之弹 at the highest-ATK operator in range (8-hit
// flying shell, 3×3 stun + phys DoT on arrival); <20 %: damage ×0.5 (no extra shell: PRTS
// 能力修正); 死亡集群 drones — a drone that dies costs the leader 2 % of its max HP = the shared
// pool's (DRONE_LINK_BASE, [ASSUMED] reading). boss_8 adds 斩胄之剑 / 破胄之锤.
// 斩胄之剑 / 破胄之锤 初始模式: hover invulnerable next to 胄, AoE attacks (锤 150 % ATK). 出击模式: blink to the
// level's blink route, fly at the lowest-ATK operator (3×3 stun + DoT); 15 hits shoot it
// down (瘫痪: stun 10 s, ground unit, damage ×1.3). Either way a new 初始模式 copy with its HP
// replaces it at home. Every damage it takes costs 胄 as much (PART_TRANSFER, 无来源, 等量) —
// grounded per both texts, during the dive per the PRTS talent [ASSUMED].
// boss_2/9 假想敌:铳 unblockable; highest-DEF target in range; erosion; ASPD ramp (+80 × 5) on the same target
// (floor 20); 最终之罚 charge at the highest-DEF ground unit (disabled by the h07_02 override).
// boss_9: damage ×0.2 while springs live, 盲信之誓 links (100 phys/s on the lines), 末日布道 dash.
// 碎铳之簧 a/b/c arts barrier (absorbs arts after RES; phys ×0.1, counter phys + erosion) / element shield
// (phys+arts ×0.1, broken by its own element burst; every (spCost+1)-th attack a bouncing erosion
// shot) / 5-hit shield (every (spCost+1)-th attack a ten-hit combo); unblockable while shielded;
// shield back 25 s after breaking; PART_TRANSFER of damage to 铳 and, split, to the other springs.
// shield back 25 s after breaking; every damage taken costs 铳 as much (PART_TRANSFER, 无来源)
// and, split, the other springs.
// boss_3/10 假想敌:管 summons 余音 (dark) every 40 s (20 s < 50 %); normal attacks strike dark 余音 (their pulse hurts
// operators); 裂管之奏 3 strikes on every dark 余音 (AoE arts + apoptosis). boss_10 + 假想敌:弦
// (invulnerable, gold 余音, 断弦之奏 AoE + flips them dark; leaves with 管).
@@ -63,8 +68,42 @@ import {
// ---------------------------------------------------------------------------------------------------------------
// constants
/** Share of the damage a grounded 剑/锤 or a 碎铳之簧 passes to its leader ("以一定比例") [ASSUMED]. */
export const PART_TRANSFER = 0.5;
/**
* Share of the damage a 剑/锤 or a 碎铳之簧 takes that its leader loses: 1, 等量 — PRTS talents (“斩胄之剑” / “破胄之锤”
* "受到伤害时令假想敌:胄受到等量的无来源生命流失"; 碎铳之簧 (all three) "…令全场范围内仇恨值最高的1名假想敌:铳受到等量的无来源
* 生命流失"); the enemy data carries no ratio blackboard (the handbook's "以一定比例" is that 1). The loss is 无来源 (hooks see
* no source; the attacker keeps the credit — stats, the per-player pool tally) and follows EVERY damage taken: a
* 剑/锤 only takes damage outside its invulnerable 初始模式 (its 出击模式 dive and 【瘫痪】). The two official texts
* disagree on the dive: the PRTS talent says "受到伤害时" (any damage), the in-game handbook (data/enemies.json abilities,
* PRTS 能力) "被击落时受到伤害以一定比例传递" (only once shot down) — the dive hits passing on is [ASSUMED] (the talent
* text; ≤ 15 hits per dive). A loss passed on to a leader is one hit for 限伤 (sim/damage.js leaderHitCancelled, via
* Battle.loseHp). v2.5: 0.5 [ASSUMED], and a 剑/锤 passed damage on only while grounded (DESIGN §20.10, §20.13).
*/
export const PART_TRANSFER = 1;
/** The 斩胄之剑 / 破胄之锤 share — the same PRTS 等量 as PART_TRANSFER (kept as a name for the blade kit and its tests). */
export const BLADE_TRANSFER = PART_TRANSFER;
/** 破胄之锤 normal attack: 150 % ATK (PRTS 天赋 "普通攻击对攻击范围内的所有我方单位造成攻击力150%的物理普通伤害"; 斩胄之剑 100 %). */
const BLADE_ATK_SCALE = Object.freeze({ enemy_9015_acstmb: 1.5 });
/**
* Level branches of each part (act1autochess_h08_01 / _s): 斩胄之剑 spawns on the start of `left_hand_origin` (3,12),
* 破胄之锤 on `right_hand_origin` (3,8). `<hand>_blink` = the preset route 掷剑 blinks to (its start; PRTS "将自身路径改为
* 关卡预设路径并闪现至路径起点"), `<hand>_origin` = the route the new 初始模式 copy is summoned on ("以关卡预设路径召唤").
*/
const BLADE_HAND = Object.freeze({ enemy_9014_acstma: 'left_hand', enemy_9015_acstmb: 'right_hand' });
/**
* 死亡集群: a drone that dies (PRTS 能力修正 "该无人机单位死亡时", whoever kills it) costs 胄 `hp_ratio` (2 %) of "最大生命值"
* (PRTS 假想敌:胄 "该妖怪死亡时令假想敌:胄受到最大生命值2%的真实伤害"). WHICH max HP is not documented [ASSUMED reading]:
* 'pool' (default, = v2.5): the leader's max HP as the battle shows it — the shared pool (the engine syncs the unit's
* HP to it). It is the same HP the "自身生命值低于20%" talent reads, and that one must read the pool (the
* unit's data 600 000 is above the co-op 标准 pool 247 500 and above every solo pool, where a fixed 600 000
* would never drop below 20 %). A drone = 2 % of the leader bar in every mode and difficulty (终极 72 000).
* 'unit': the in-battle unit's data max HP (600 000 / hidden 1 200 000, 不死) — 12 000 / 24 000 per drone at every
* difficulty: 0.33 % of the 终极 bar but 19 % of the solo 标准 bar (61 875), so drones decide solo fights.
* Round 2 of the boss-HP review tried 'unit'; the review measured the solo regression, so the default is back to 'pool'.
* 限伤 (shared/constants.js BOSS_HIT_LIMIT): the loss is one hit through Battle.loseHp — it lands up to the largest pool
* today (boss_8 终极 7 200 000 → 144 000) and would be cancelled above a 14 999 950 pool (ceil(0.02 × pool) ≥ 300000).
*/
export const DRONE_LINK_BASE = 'pool';
/** 卢西恩 / 不祥幻影 AoE radius (PRTS "半径2"). */
const LUCIEN_AOE_RADIUS = 2;
/** 余音 on-hit pulse radius (PRTS 1.6) and 合奏 radii per form (PRTS gold 0.8 / dark 1.6). */
@@ -159,6 +198,13 @@ function toGoal(b, x, y, motion = 'WALK') {
return { motion, start: [y, x], end: best, checkpoints: [] };
}
/** The extra route of a template branch's first spawn (or null). */
function branchRoute(tpl, name) {
const br = tpl && tpl.branches && tpl.branches[name];
const s = Array.isArray(br) && Array.isArray(br[0]) ? br[0][0] : null;
return (s && tpl.extraRoutes && tpl.extraRoutes[s.routeIndex]) || null;
}
/** Spawn one phase of a template branch. Returns the spawned enemies. */
function branchSpawn(b, tpl, name, phaseIdx, { fallback = null, mods = null, at = null } = {}) {
const br = tpl && tpl.branches && tpl.branches[name];
@@ -295,10 +341,10 @@ function segDist(px, py, ax, ay, bx, by) {
function kitHelm(ab, e, b, tpl) {
const s1 = ab.sk['1'], s2 = ab.sk['2'];
const lowRatio = T(ab, '1.hp_ratio') ?? 0, lowScale = T(ab, '1.damage_scale') ?? 1;
const hidden = /_2$/.test(e.defId);
const P = { low: false };
const range = () => e.base.rangeRadius || 8;
const shellTargets = (b2) => opsOnly(hidden ? targetsNear(b2, e, range()) : allTargets(b2, e));
// 灭顶之灾 "触发索敌和普通攻击相同:选择攻击范围内攻击力最高的1名我方干员" (PRTS 技能, both copies)
const shellTargets = (b2) => opsOnly(targetsNear(b2, e, range()));
return [
{
spawn(b2, e2, a, ab2) { ab2.atkType = 'arts'; },
@@ -307,22 +353,27 @@ function kitHelm(ab, e, b, tpl) {
iv: 0.25,
tick(b2, e2) {
if (P.low || !(e2.hpRatio < lowRatio)) return;
P.low = true; // <20 %: damage taken ×0.5, extra shell
P.low = true; // <20 %: damage taken ×0.5 (no extra shell: see 灭顶之灾)
b2.addBuff(e2, { key: 'boss:helmGuard', persist: true, visible: true, mods: { physTakenMul: lowScale, artsTakenMul: lowScale } }); // 物理与法术伤害降低
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'helmLow' });
},
},
s1 && {
cd: s1.cd, icd: s1.icd, cond: (b2) => shellTargets(b2).length > 0,
fire(b2, e2) { // 【灭顶之灾】
const n = P.low ? 2 : 1;
const ts = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q)).slice(0, n);
for (const t of ts) fireShell(b2, e2, t);
// 【灭顶之灾】 one shell, also below 20 %: the handbook's "额外发射<刺胄之弹>" is wrong in game (PRTS 能力修正 原因 6
// "描述与游戏实际表现不符合": "【灭顶之灾】不会额外发射") and no blackboard holds a second shell (skill 1 has none)
fire(b2, e2) {
const t = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q))[0];
if (t) fireShell(b2, e2, t);
},
},
s2 && {
cd: s2.cd, icd: s2.icd,
fire(b2, e2) { // 【死亡集群】: drones; one killed by an operator costs the leader hp_ratio × max HP
// 【死亡集群】: a drone on the branch route; when it dies (PRTS 能力修正 "该无人机单位死亡时", whoever kills it) the leader
// loses hp_ratio × max HP. Drone HP: `summon.hp_ratio` (隐秘核心 only) scales it [ASSUMED: the summon's max HP]; the
// `max_hp` 0.5 both copies carry is not read [ASSUMED] — no source says what it does (PRTS 技能 lists the 2 % only,
// the 妖怪 page has no summon rule, the level gives the 妖怪 no override; as an attribute key it would be +50 %).
fire(b2, e2) {
const hpMul = s2.bb['summon.hp_ratio'] > 0 ? s2.bb['summon.hp_ratio'] : null;
const key = s2.bs.enemy_key ?? 'enemy_1005_yokai';
const drones = branchSpawn(b2, tpl, s2.bs.branch_id ?? 'boss_summon_enemy', 0, {
@@ -333,9 +384,10 @@ function kitHelm(ab, e, b, tpl) {
const ratio = s2.bb.hp_ratio ?? 0;
for (const d of drones) attach(b2, d, [{
death(c, b3) {
if (c.reason !== 'killed' || !c.killer || c.killer.side !== 'ally' || !(ratio > 0)) return;
if (c.reason !== 'killed' || !(ratio > 0)) return; // a leak is no death
const boss = e2.alive ? e2 : b3.aliveEnemies().find((o) => o.isBoss && o.defId === e2.defId);
if (boss) { b3.loseHp(boss, boss.s.maxHp * ratio, { source: c.killer }); b3.fx('beam', { x: d.x, y: d.y, from: d.id, to: boss.id, kind: 'droneLink' }); }
const by = c.killer && c.killer.side === 'ally' ? c.killer : null; // credited to the killing operator
if (boss) { b3.loseHp(boss, droneLinkBase(boss) * ratio, { source: by }); b3.fx('beam', { x: d.x, y: d.y, from: d.id, to: boss.id, kind: 'droneLink' }); }
},
}]);
},
@@ -343,6 +395,16 @@ function kitHelm(ab, e, b, tpl) {
];
}
/**
* Max HP the 死亡集群 drone link reads (DRONE_LINK_BASE): 'pool' = the leader's shown max HP (the shared pool), 'unit' =
* the unit's data max HP (template × spawn hpMul).
*/
export function droneLinkBase(boss, base = DRONE_LINK_BASE) {
if (base !== 'unit' || !boss.def || !(boss.def.maxHp > 0)) return boss.s.maxHp;
const m = boss.mods && Number(boss.mods.hpMul);
return boss.def.maxHp * (Number.isFinite(m) && m > 0 ? m : 1);
}
/** Launch a 刺胄之弹 from `boss` at `target`'s tile. */
function fireShell(b, boss, target) {
const tr = target.tileR, tc = target.tileC;
@@ -368,54 +430,90 @@ function kitShell(ab, e) {
}];
}
/**
* “斩胄之剑” / “破胄之锤” (PRTS 天赋 + 技能 掷剑 / 掷锤; numbers from skill 1's blackboard).
* 初始模式 ('hover'): 无敌 + 自缚 [ASSUMED: also untargetable, so operators spend no attacks on it]; AoE physical attack
* on every ally in its range (锤 150 % ATK, BLADE_ATK_SCALE).
* 掷剑 (skill 1 cd / icd, an operator on the field) → 出击模式 ('dive'): no normal attack, vulnerable; blinks to the start
* of its `<hand>_blink` route and flies at the tile of the lowest-ATK operator (无视无法选择).
* Arriving (no 【瘫痪】): `stun` s stun + `dot_damage`/s for `dot_duration` s on the 3×3 around it, then it is replaced.
* Once, after `max_hit_cnt` damage instances: 【瘫痪】 ('down') — `special_stun_duration` s, but it ends with its own
* `stun` s stun ("该晕眩结束时也会结束【瘫痪】"): ground unit, damage taken ×`damage_scale`; when it ends, replaced.
* Replaced: a new 初始模式 copy with its current HP is summoned on its `<hand>_origin` route (at home without a template),
* then it is killed (强制击杀自身; a part: no kill count, no bounty). The copy is a new unit [ASSUMED: only its HP is
* inherited] — its 掷剑 starts from the initial cooldown again, so the data's cooldown (80 s) never comes into play.
* Every damage it takes is lost by 假想敌:胄 too (BLADE_TRANSFER = PART_TRANSFER 1, 无来源, credited to the attacker).
*/
function kitBlade(ab, e, b, tpl) {
const s = ab.sk['1'];
const bb = s ? s.bb : {};
const hand = BLADE_HAND[e.defId];
const P = { state: 'hover', home: { x: e.x, y: e.y }, hits: 0, target: null };
const leader = (b2) => nearestOf(b2, e, (o) => o.isBoss && /enemy_9013_acstmk/.test(o.defId));
const hover = (b2, on) => {
if (on) b2.addBuff(e, { key: 'boss:hover', persist: true, visible: true, flags: { invulnerable: true, untargetable: true } });
else b2.removeBuff(e, 'boss:hover');
};
const replace = (b2, e2) => { // 以关卡预设路径召唤一个继承自身生命值的初始模式的 copy,随后强制击杀自身
P.state = 'gone';
e2.motion = 'FLY';
const origin = hand ? branchRoute(tpl, `${hand}_origin`) : null;
const home = [P.home.y, P.home.x];
const n = b2.spawnEnemy(e2.defId, {
route: origin || { motion: 'FLY', start: home, end: home, steps: [{ t: 'wait', s: 99999 }] }, pos: origin ? undefined : home,
tag: e2.tag, countInTotal: false, mods: e2.mods, ownerPlayerId: e2.ownerId,
});
if (n) { n.hp = Math.min(n.s.maxHp, e2.hp); b2.fx('blink', { x: n.x, y: n.y, id: n.id, fx: e2.x, fy: e2.y }); }
b2.kill(e2, null);
};
const shotDown = (b2, e2) => { // 【瘫痪】
P.state = 'down';
e2.motion = 'WALK'; // 变为地面单位(不改变寻路方式)
const stun = bb.stun ?? 0;
b2.applyStatus(e2, 'stun', { duration: stun, source: null, force: true });
b2.addBuff(e2, { key: 'boss:downed', duration: Math.min(bb.special_stun_duration ?? stun, stun), visible: true,
mods: { dmgTakenMul: bb.damage_scale ?? 1 }, onExpire: () => { if (e2.alive && P.state === 'down') replace(b2, e2); } });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'shotDown' });
};
return [
{
spawn(b2, e2) { hover(b2, true); b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true } }); },
spawn(b2, e2) {
hover(b2, true);
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, unblockable: true } }); // 自缚 (moved by hand) · 不可阻挡
if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId];
},
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6, { ranged: false }); if (l.length) c.targets = l; }, // 范围物理伤害
taken(c, b2, e2) {
if (P.state === 'dive' && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) {
// shot down: ground unit, damage ×damage_scale, stunned special_stun_duration, then flies home
P.state = 'down';
e2.motion = 'WALK';
b2.addBuff(e2, { key: 'boss:downed', duration: bb.special_stun_duration ?? 0, visible: true, mods: { dmgTakenMul: bb.damage_scale ?? 1 },
onExpire: () => { if (e2.alive) { e2.motion = 'FLY'; P.state = 'return'; } } });
b2.applyStatus(e2, 'stun', { duration: bb.special_stun_duration ?? 0, source: null, force: true });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'shotDown' });
return;
}
if (P.state === 'down' && c.source && c.source.side === 'ally' && c.amount > 0) {
if (c.amount > 0) { // 受到伤害时令假想敌:胄受到等量的无来源生命流失 (【瘫痪】; 出击模式 [ASSUMED], see PART_TRANSFER)
const L = leader(b2);
if (L) b2.loseHp(L, c.amount * PART_TRANSFER, { source: c.source, from: c.dmg }); // 被击落时受到伤害以一定比例传递给假想敌:胄
const src = c.source || c.credit; // a 无来源 burst still passes on (credited)
if (L) b2.loseHp(L, c.amount * BLADE_TRANSFER, { source: src && src.side === 'ally' ? src : null, from: c.dmg, sourceless: true });
}
if (P.state === 'dive' && e2.alive && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) shotDown(b2, e2); // 仅1次
},
tick(b2, e2, a, dt) {
const sp = e2.s.moveSpeed * MOVE_SCALE * dt;
if (P.state === 'dive') {
const t = P.target;
if (!stepToward(e2, t.c, t.r, sp)) return;
stunBlast(b2, e2, t.r, t.c, bb.stun ?? 0, bb.dot_damage ?? 0, bb.dot_duration ?? 0, 'bladeDive');
P.state = 'return';
} else if (P.state === 'return') {
if (stepToward(e2, P.home.x, P.home.y, sp)) { P.state = 'hover'; hover(b2, true); }
}
if (P.state !== 'dive' || !canCast(e2)) return; // a stunned blade does not fly on
const t = P.target;
if (!stepToward(e2, t.c, t.r, e2.s.moveSpeed * MOVE_SCALE * dt)) return;
// arrival: 3×3 stun + DoT (无来源: credited to 胄, as the shell's), then the 初始模式 copy takes over
stunBlast(b2, leader(b2) || e2, t.r, t.c, bb.stun ?? 0, bb.dot_damage ?? 0, bb.dot_duration ?? 0, 'bladeDive');
replace(b2, e2);
},
},
s && {
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0,
fire(b2, e2) { // fly at the lowest-ATK operator
const t = opsOnly(allTargets(b2, e2)).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0];
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0, // 存在至少1名可选我方干员
fire(b2, e2) { // 【掷剑】/【掷锤】 → 出击模式
const t = opsOnly(b2.allies()).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0];
if (!t) return;
P.state = 'dive'; P.hits = 0; P.target = { r: t.tileR, c: t.tileC };
hover(b2, false);
b2.addBuff(e2, { key: 'boss:sortie', persist: true, flags: { disarm: true } }); // 出击模式:不进行普通攻击
const blink = hand ? branchRoute(tpl, `${hand}_blink`) : null;
if (blink && Array.isArray(blink.start)) {
const from = { x: e2.x, y: e2.y };
e2.x = blink.start[1]; e2.y = blink.start[0];
b2.fx('blink', { x: e2.x, y: e2.y, id: e2.id, fx: from.x, fy: from.y });
}
b2.fx('charge', { x: e2.x, y: e2.y, id: e2.id, tx: t.tileC, ty: t.tileR, kind: 'bladeDive' });
},
},
@@ -545,14 +643,16 @@ function kitSpring(ab, e) {
},
burst(c, b) { if (kind === 'element' && P.up) drop(b); }, // 自身元素损伤爆发时,护盾消失
taken(c, b, e2) {
const src = c.source || c.credit; // a 无来源 burst still passes on (credited)
if (!src || src.side !== 'ally' || !(c.amount > 0)) return;
if (!(c.amount > 0)) return;
if (c.dmg.tags && c.dmg.tags.includes(SPRING_SHARE_TAG)) return; // a share never passes on again
// 【盲信之誓】 受到伤害时以一定比例传递给假想敌:铳 (and, split, to the other springs — 隐秘核心 text)
const s0 = c.source || c.credit; // a 无来源 burst still passes on (credited)
const src = s0 && s0.side === 'ally' ? s0 : null;
// 【盲信之誓】 受到伤害时令假想敌:铳受到等量的无来源生命流失 (PRTS); the even split to the other springs follows the
// 隐秘核心 handbook ("传递给<假想敌:铳>和场上其他<“碎铳之簧”>") — not in the PRTS talent, [ASSUMED] amount
const g = gun(b);
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg });
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg, sourceless: true });
const others = b.enemies.filter((o) => o.alive && o !== e2 && isSpring(o));
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG] });
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG], sourceless: true });
},
tick(b, e2, a, dt) {
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
+10 -8
View File
@@ -48,16 +48,17 @@
// env_gbuff_new_with_verify act1autochess_debuff_3 {value} 自愈: every damage instance an own unit (operator or
// summon) takes heals it `value` (not HP loss 流失, not gauge
// fills, not while a 禁疗 status is on)
// env_gbuff_new_with_verify act1autochess_debuff_9 {atk, def} 无瑕: operators at full HP get ATK/DEF ×(1 + x)
// env_gbuff_new_with_verify act1autochess_debuff_9 {atk, def} 无瑕: operators at full HP get ATK/DEF +x
// env_gbuff_new halfIdle_magic_resist_penetration {magic_resist_penetrate_fixed} 火力: operators ignore N RES
// env_gbuff_new halfIdle_def_penetration {def_penetrate} 锐利: operators ignore x DEF
// char_respawntime_mul {scale} 征召: operators' redeploy time × scale
// enemy_attribute_mul / enemy_attribute_add {atk|def|magic_resistance|max_hp|attack_speed|move_speed}
// (+ bbStr enemy_level_type ELITE / BOSS) 排斥/责罚/裁决: enemies of the player's half (ownerId)
// of that rank get the debuff at spawn (all ranks without a type)
// Attribute bonuses are 直接乘算 (support/index.js convention): ×(1 + x) multipliers; flat values stay flat.
// Operator attribute bonuses are 直接乘算 (support directMods: additive with every other percentage, PRTS 盟约记录 /
// 游戏数据基础); flat values stay flat; the enemies' enemy_attribute_mul stays a multiplier.
import { gameData, num, buffsOf, passiveBuff, effectRecord } from './support/index.js';
import { gameData, num, buffsOf, passiveBuff, effectRecord, directMods } from './support/index.js';
import { isShopItem } from '../simdata.js';
// =====================================================================================================================
@@ -305,9 +306,10 @@ export function registerMeta(registry) {
const RUNE = Object.freeze({ heal: 'act1autochess_debuff_3', flawless: 'act1autochess_debuff_9' });
/** Operator mods of an env_gbuff blackboard (numbers only; 直接乘算 for attributes). */
/** Operator mods of an env_gbuff blackboard (numbers only; 直接乘算 for attributes — support directMods). */
function opModsOf(p) {
const mods = {};
const direct = { atk: 0, def: 0, hp: 0 };
const add = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 0) + v; };
const mul = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 1) * (1 + v); };
for (const [k, raw] of Object.entries(p || {})) {
@@ -318,15 +320,15 @@ function opModsOf(p) {
case 'magic_resist_penetrate': add('resIgnorePct', v); break;
case 'def_penetrate': add('defIgnorePct', v); break;
case 'def_penetrate_fixed': add('defIgnoreFlat', v); break;
case 'atk': mul('atkMul', v); break;
case 'def': mul('defMul', v); break;
case 'max_hp': mul('hpMul', v); break;
case 'atk': direct.atk += v; break;
case 'def': direct.def += v; break;
case 'max_hp': direct.hp += v; break;
case 'attack_speed': add('aspd', Math.abs(v) < 1 ? v * 100 : v); break;
case 'damage_scale': mul('dmgDealtMul', v); break;
default: break;
}
}
return mods;
return directMods(direct, mods);
}
/** Enemy mods of an enemy_attribute_mul / _add blackboard. */
+6 -7
View File
@@ -21,7 +21,9 @@
// 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,
// stat traits GAIN_BUFF (battleRuneKey char_attribute_mul "攻击力和生命值+20%": a rune on the chess's base
// attributes, so atkMul / hpMul), attr_common_global_buff (spRecoveryFlat; "+X%" ATK / HP would be
// 直接乘算), attrByBond ("每叠加N层…+X%": 直接乘算, support directMods),
// 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).
@@ -340,8 +342,7 @@ const INSTALLERS = {
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);
Object.assign(mods, S.directMods({ atk: S.num(it.bb.atk, 0), hp: S.num(it.bb.max_hp, 0) }));
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);
}
@@ -369,10 +370,8 @@ export function stepsOf(battle, it) {
}
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;
// "每叠加N层,本干员攻击力+X%": a 直接乘算 bonus like every other "+X%" of the 卫戍 systems (support directMods)
const m = S.directMods({ atk: S.num(bb.atk, 0) * k, hp: S.num(bb.max_hp, 0) * k, def: S.num(bb.def, 0) * 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;
+12 -12
View File
@@ -8,8 +8,9 @@
// Numbers always come from the concrete record's buffs (support.buffsOf: `{ ...bb, ...bbStr }` per buff, looked up by
// the buff's bbStr.key — the flattened `params` of data/items.json lose duplicate keys such as 蒸汽之心 attack_speed).
//
// Stat rule (research 02 §2.1, PRTS "直接乘算"; research 04 §2 assumed additive %): every equipment percentage is its own
// multiplier on the final stat — atk/def/max_hp → atkMul/defMul/hpMul = 1 + x; attack_speed → aspd (additive);
// Stat rule (PRTS 盟约记录 "装备效果提供的属性加成均为直接乘算"; 直接乘算 = summed with every other percentage, PRTS 游戏数据
// 基础 — research 04 §2's additive reading; v2.5 had each item its own multiplier): atk/def/max_hp → support directMods
// (atkPct/defPct/hpPct += x; constants.js DIRECT_BONUS_STACKING); attack_speed → aspd (additive);
// magic_resistance → resFlat; respawn_time → redeployMul 1 + x; sp_recovery_per_sec → spRecoveryFlat;
// magic_resist_penetrate → resIgnorePct; taunt_level → taunt. Two different items on one unit both apply, and a
// normal + golden copy of the same item may coexist (different ids → different buff keys) and both apply.
@@ -33,7 +34,7 @@
import {
num, itemRecord, itemKeyOf, buffsOf, isOp, onField, unitBonds, isMember, bondActive, isGroundOp, frontTile,
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf,
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, directMods,
} from '../support/index.js';
import { mitigate, hasHp } from '../../damage.js';
@@ -68,9 +69,8 @@ function statMods(p) {
const m = {};
let any = false;
const set = (k, v) => { m[k] = v; any = true; };
if (num(p.atk)) set('atkMul', 1 + num(p.atk));
if (num(p.def)) set('defMul', 1 + num(p.def));
if (num(p.max_hp)) set('hpMul', Math.max(0.01, 1 + num(p.max_hp)));
// ATK / DEF / max HP "+X%" are 直接乘算 (support directMods: additive with every other percentage)
for (const [k, v] of Object.entries(directMods({ atk: num(p.atk), def: num(p.def), hp: num(p.max_hp) ? Math.max(-0.99, num(p.max_hp)) : 0 }))) set(k, v);
if (num(p.attack_speed)) set('aspd', num(p.attack_speed));
if (num(p.magic_resistance)) set('resFlat', num(p.magic_resistance));
if (num(p.respawn_time)) set('redeployMul', Math.max(0, 1 + num(p.respawn_time)));
@@ -366,7 +366,7 @@ const BY_ITEM = {
if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc;
});
},
// 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one multiplier 1 + atk × stacks
// 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one 直接乘算 bonus of atk × stacks
chess_item_3_04_e(battle, u, rec, S) {
const p = bp(rec, 'act1vautochess_equip_acarm024_global_buff');
if (!p) return;
@@ -375,7 +375,7 @@ const BY_ITEM = {
S.on('skillStart', (c) => {
if (c.unit !== u || n >= cap || !chance(battle, num(p.prob, 1))) return;
n++;
S.stat('stacks', { atkMul: 1 + num(p.atk) * n });
S.stat('stacks', directMods({ atk: num(p.atk) * n }));
});
},
// 迅捷作战粮: on deploy SP += sp_each_person × (1 + other operators sharing a bond)
@@ -401,7 +401,7 @@ const BY_ITEM = {
chess_item_3_06_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm049_global_buff');
if (!p) return;
if (num(p.init_max_hp)) S.stat('hp', { hpMul: 1 + num(p.init_max_hp) });
if (num(p.init_max_hp)) S.stat('hp', directMods({ hp: num(p.init_max_hp) }));
const ex = num(p.ex_max_hp);
if (!ex) return;
const key = S.key('front');
@@ -413,7 +413,7 @@ const BY_ITEM = {
const here = initial ? a.homeR === fr && a.homeC === fc : onField(a) && a.tileR === fr && a.tileC === fc;
if (here) { occupied = true; break; }
}
if (!occupied) S.buff(u, { key, mods: { hpMul: 1 + ex }, refresh: 'replace' });
if (!occupied) S.buff(u, { key, mods: directMods({ hp: ex }), refresh: 'replace' });
else battle.removeBuff(u, key);
};
S.on('deploy', (c) => { if (c.unit === u) check(!!c.initial); });
@@ -675,7 +675,7 @@ const BY_ITEM = {
if (partner && carries(battle, u, partner) && c.skill && c.skill.isTimed) {
combo = true;
doomed = false;
S.buff(u, { key: comboKey, mods: { atkMul: 1 + num(p.atk) } });
S.buff(u, { key: comboKey, mods: directMods({ atk: num(p.atk) }) });
}
});
S.on('skillEnd', (c) => {
@@ -715,7 +715,7 @@ const BY_ITEM = {
const stealth = !!u.s.flags.stealth;
if (stealth && st.bonus < max) {
st.bonus = Math.min(max, st.bonus + per * TICKS);
S.buff(u, { key, mods: { atkMul: 1 + st.bonus }, refresh: 'replace' });
S.buff(u, { key, mods: directMods({ atk: st.bonus }), refresh: 'replace' });
}
if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true;
st.wasStealth = stealth;
+8 -5
View File
@@ -109,6 +109,8 @@ function inspire(battle, target, val, src, stat = 'atk') {
battle.addBuff(target, { key, mods: stat === 'hp' ? { hpFlat: flat } : { atkFlat: flat }, duration: AURA_DUR, source: src, visible: true, data: { src: src.id, val } });
}
/** Reveal stealthed enemies on the given tiles. */
/** RES cut mods for a blackboard magic_resistance value (battle.applyStrongest): |v| < 1 = ×(1 + v), else flat v. */
const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v });
const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); };
/**
* Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉;
@@ -1077,7 +1079,8 @@ const kits = {
for (const e of enemiesOnRange(battle, unit)) {
const f = e.s.flags;
const m = f.freeze ? num(t0.damage_scale_freeze, 1.5) : f.cold ? num(t0.damage_scale_cold, 1.25) : 0;
if (m) battle.addBuff(e, { key: `gnosis:fragile:${unit.id}`, mods: { dmgTakenMul: m }, duration: 0.15 });
// 同名效果取最高: one 坚冰 per enemy, the strongest — two 灵知 (a pair / 联防 partner's copy) never compound
if (m) battle.applyStrongest(e, 'gnosis:fragile', { duration: 0.15, value: m, mods: (v) => ({ dmgTakenMul: v }), source: unit });
}
});
} },
@@ -1105,10 +1108,10 @@ const kits = {
kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant',
targeting: g ? { rangeGrid: g } : undefined,
onStart({ battle, unit }) {
const debuff = Math.abs(mr) < 1 ? { resMul: Math.max(0, 1 + mr) } : { resFlat: mr };
for (const e of targetsInRange(battle, unit)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.7), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
if (e.alive && mr) battle.addBuff(e, { key: `lionhd:res:${unit.id}`, mods: debuff, duration: num(bb.duration, 6), source: unit });
// 同名效果取最高: one RES cut per enemy (the strongest), never one per 莱恩哈特
if (e.alive && mr) battle.applyStrongest(e, 'lionhd:res', { duration: num(bb.duration, 6), value: mr, mods: resCut, source: unit });
}
battle.fx('explosion', { x: unit.x, y: unit.y, id: unit.id });
},
@@ -1179,10 +1182,10 @@ const kits = {
talentAtk(battle, unit);
battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true });
const mr = num(bb.magic_resistance, 0);
const debuff = Math.abs(mr) < 1 ? { resMul: Math.max(0, 1 + mr) } : { resFlat: mr };
for (const e of targetsInGrid(battle, unit, g)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
if (e.alive && mr) battle.addBuff(e, { key: `texas2:resDown:${unit.id}`, mods: debuff, duration: num(bb.debuff_duration, 8), source: unit });
// 同名效果取最高: one RES cut per enemy (the strongest), never one per 缄默德克萨斯
if (e.alive && mr) battle.applyStrongest(e, 'texas2:resDown', { duration: num(bb.debuff_duration, 8), value: mr, mods: resCut, source: unit });
}
battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id });
};
+25 -4
View File
@@ -5,9 +5,11 @@
// never edit it; module-specific helpers live in the submodules.
//
// Conventions shared by every wave-B module (details in docs/CONTENT.md):
// * Attribute bonuses from bonds, bands, equipment and 机变 cards are "直接乘算" (research 02 §2.1): each source is its
// own multiplier on the final stat → `atkMul` / `defMul` / `hpMul` (1 + x), never the skill-style `atkPct`.
// ASPD stays additive (`aspd`), damage bonuses are `dmgDealtMul`, redeploy changes `redeployMul`.
// * Attribute bonuses ("+X%" ATK / DEF / max HP) from bonds, bands, equipment, 机变 cards and the per-layer 特质 are
// "直接乘算" (PRTS 盟约记录; PRTS 游戏数据基础: 直接乘算 values are summed with every other 直接乘算, a skill's "攻击力+X%"
// included) → `directMods({ atk, def, hp })` = the additive `atkPct` / `defPct` / `hpPct` (constants.js
// DIRECT_BONUS_STACKING; 'multiply' restores the v2.5 per-source ×(1 + x) `atkMul`). "提升至X%" effects stay
// multipliers. ASPD stays additive (`aspd`), damage bonuses are `dmgDealtMul`, redeploy changes `redeployMul`.
// * Match-long passives use `passiveBuff()` (persist + allowDead + replace): idempotent by key, survive death.
// * Buff keys / fx `src` are namespaced: `bond:<bondId>…`, `item:<itemKey>…`, `gar:<effectKey>…`, `band:<bandId>…`,
// `choice:<effectId>…`.
@@ -16,7 +18,7 @@
// `PlayerBattleInput.contentInfo`, written by the `global:contentb_info` meta handler (support/meta.js).
import { getData } from '../../../data.js';
import { COLS } from '../../constants.js';
import { COLS, DIRECT_BONUS_STACKING } from '../../constants.js';
import { frontOf, offsetTile } from '../../dir.js';
import { bodyInKeys, bodyDist, bodyInRadius, bodyOnTile, bodyTileReach } from '../../body.js';
@@ -267,6 +269,25 @@ export const inRange = (u, target) => !!(u && target && u.rangeKeySet && onKeys(
// =====================================================================================================================
// battle: buffs, fx, per-battle state
/**
* Buff mods of a 直接乘算 attribute bonus (bonds, bands, equipment, 机变 cards, per-layer 特质): ratios `atk` / `def` /
* `hp` (+0.3 = "+30%"; negative lowers) → `atkPct` / `defPct` / `hpPct`, summed by the engine with every other additive
* percentage (constants.js DIRECT_BONUS_STACKING 'add', the official rule); 'multiply' → `atkMul` / `defMul` / `hpMul`
* = 1 + x (the v2.5 reading). Zero / non-finite parts are left out; `extra` mods are merged in.
*/
export function directMods({ atk = 0, def = 0, hp = 0 } = {}, extra = null) {
const m = {};
const put = (pct, mul, v) => {
if (!(typeof v === 'number' && Number.isFinite(v)) || v === 0) return;
if (DIRECT_BONUS_STACKING === 'multiply') m[mul] = Math.max(0, 1 + v);
else m[pct] = v;
};
put('atkPct', 'atkMul', atk);
put('defPct', 'defMul', def);
put('hpPct', 'hpMul', hp);
return extra ? Object.assign(m, extra) : m;
}
/** Match-long passive buff (survives death/redeploy; re-adding the same key replaces it — idempotent). */
export function passiveBuff(battle, u, key, mods, extra = {}) {
return battle.addBuff(u, { key, mods, persist: true, allowDead: true, refresh: 'replace', ...extra });