feedback7: merge fb7-owner-sim — community reports via the owner: 白铁's 铁钳号 opens only the skills that act on it (圣约送葬人 spends and stacks), 伊内丝 S3 after a 突袭 jump, 引星棘刺 alchemy units as HP regen (不屈者 / 禁疗), 不可对空 effects spare flying allies (炎佑), 望's 跟子 at battle start, 香槟炸弹 unhealable / no HP loss (owner decision) and not set off by unrevealed 隐匿 or sleeping enemies

- Grok review: MERGE AFTER FIXES — the HIGH finding (skills opened on the device but spending nothing) fixed on the branch
  (69d791f9: skills.js allyTargetsOk, per-kit allyTargets flags).
- Golden union OK; broad node --test 5500 (5499 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 21:14:27 +08:00
commit f5595a7a31
43 files changed
+1129 -356

No files matched your search

+5 -1
View File
@@ -56,7 +56,11 @@ export class BattleDeploy {
const sk = u.skill;
// "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js)
if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown;
if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) {
// keepSp (突袭 "保留技力"): the SP / charges before the jump — not for a skill that costs no SP (spCost 0, the
// deploy-timed skills), which reset() just re-armed as at every deployment: the retreat ended it, so its snapshot
// holds 0 charges, and writing that back took the charge 伊内丝 S3's deploy hook casts with (community report:
// 伊内丝激活突袭瞬移以后3技能被吞; those with activateOnDeploy run already, skipped by `!sk.active`)
if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active && sk.spCost > 0) {
sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0))));
sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0)));
if (sk.charges >= sk.maxCharges) sk.sp = sk.spCost;
+19 -4
View File
@@ -45,19 +45,34 @@ export class BattleQueries {
/**
* Ally units an ally's attack selects like an enemy — the "ally targets" content registers (setAllyTarget): 白铁's
* 铁钳号·原型机, a summon of the enemy camp that "可被我方干员攻击但不受伤害" (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方";
* kits/ops/op-ironmn.js). Those standing on one of `keys`, alive and deployed — never `attacker` itself, and an ally target
* selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级 −2 puts them last). [] at once
* while none is registered, so every other battle runs exactly as before.
* kits/ops/op-ironmn.js). Those standing on one of `keys` (tile keys, or a Set of them), alive and deployed — never
* `attacker` itself, and an ally target selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级
* −2 puts them last); a skill that acts on them counts them like an enemy for its automatic start (skills.js
* `_allyTargetIn` / `allyTargetsOk`). [] at
* once while none is registered, so every other battle runs exactly as before.
*/
allyTargetsInKeys(keys, attacker) {
const set = this._allyTargets;
if (!set || !set.size || !keys || !attacker || set.has(attacker)) return [];
const ks = keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys);
const ks = keys instanceof Set ? keys : (keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys));
const out = [];
for (const a of set) if (a.alive && a.deployed && !a.hidden && ks.has(a.tileR * COLS + a.tileC)) out.push(a);
return out;
}
/**
* The registered ally targets within `r` of (x, y) (centre distance) — the radius form of allyTargetsInKeys, for a kit
* whose area selection takes them too (a skill flagged `allyTargets`, skills.js allyTargetsOk). [] while none is registered.
*/
allyTargetsInRadius(x, y, r, attacker) {
const set = this._allyTargets;
if (!set || !set.size || !attacker || set.has(attacker)) return [];
const r2 = r * r + 1e-9;
const out = [];
for (const a of set) if (a.alive && a.deployed && !a.hidden && (a.x - x) * (a.x - x) + (a.y - y) * (a.y - y) <= r2) out.push(a);
return out;
}
/** Register (`on`) or drop an ally target (allyTargetsInKeys); one off the field is skipped while registered. */
setAllyTarget(unit, on = true) {
if (!unit || unit.side !== 'ally') return false;
+4 -3
View File
@@ -524,11 +524,12 @@ function kitBlade(ab, e, b, tpl) {
// 自缚 (moved by hand) · 不可阻挡 · 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},不可阻挡、失衡免疫…" — data `staticBody` too)
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, selfBound: true, unblockable: true, noDisplace: true } });
if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId];
e2.profile.canTarget = (u) => !u.isFlying; // "…不可对空" (below)
},
// 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害": a normal attack on every operator it can target
// 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空": a normal attack on every operator it can target
// (canTargetAlly: no 隐匿 or 迷彩 one — it is never blocked —, PRTS 选择器: a normal attack does not ignore 迷彩; until
// 0.1.2 it took them all, `ranged: false`)
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6); if (l.length) c.targets = l; },
// 0.1.2 it took them all, `ranged: false`), never a flying ally (the 炎佑 dragon — until 0.2.1 it was hit)
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6).filter((u) => !u.isFlying); if (l.length) c.targets = l; },
taken(c, b2, e2) {
if (c.amount > 0) { // 受到伤害时令假想敌:胄受到等量的无来源生命流失 (【瘫痪】; 出击模式 [ASSUMED], see PART_TRANSFER)
const L = leader(b2);
+4 -2
View File
@@ -181,15 +181,17 @@ const deathSpawn = (key, cnt, extra = {}) => ({
/**
* Death explosion on the allies within r it can select (areaAllies — PRTS 高能源石虫 / 冰爆源石虫 / 卷心籽 死亡爆炸 "…无视迷彩,
* 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody).
* 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody). `noAir` (default — all three say so):
* a flying ally (the 炎佑 dragon) is skipped; until 0.2.1 it took the blast (community report: 炎祐吃到了不该吃到的地面伤害).
*/
const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null }) => ({
const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null, noAir = true }) => ({
sil,
death(c, b, e, a) {
if (c.reason !== 'killed' || (cond && !cond(e, a))) return;
const atk = e.s.atk;
b.fx('explode', { x: e.x, y: e.y, r, kind: 'deathBoom', id: e.id });
for (const u of areaAllies(b, e, e.x, e.y, r)) {
if (noAir && u.isFlying) continue;
if (scale > 0) hurt(b, e, u, atk * scale, type);
if (status && u.alive) b.applyStatus(u, status.key, { duration: status.dur, source: e, value: status.value });
}
+7 -1
View File
@@ -128,7 +128,13 @@ export const DOT_KITS = Object.freeze({
tick(b, e2) {
const r = (e2.def.raw.stats && e2.def.raw.stats.rawRangeRadius) || 1.6;
b.fx('explode', { x: e2.x, y: e2.y, r, kind: 'boil' });
for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts'); elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0)); }
// PRTS 鼎沸 "攻击范围内的所有我方单位每秒受到…法术持续伤害与…灼燃损伤(不可对空)": never a flying ally (the 炎佑 dragon;
// until 0.2.1 it burned)
for (const u of areaAllies(b, e2, e2.x, e2.y, r)) {
if (u.isFlying) continue;
hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts');
elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0));
}
},
}],
});
+3 -2
View File
@@ -117,12 +117,13 @@ function kitSteal(ab) {
}
/** “萨科塔昂首” 【祈祷邀约】 (PRTS "令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】攻击速度-30"): a whole-field skill
* selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择). */
* selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择) and, 不可对空, a
* flying one (the 炎佑 dragon; until 0.2.1 it was slowed too). */
function kitRoar(ab) {
const s = ab.sk.Roar;
return [selfFear(ab), skill(s, (b, e) => {
b.fx('telegraph', { x: e.x, y: e.y, r: 99, kind: 'roar', id: e.id });
for (const u of fieldAllies(b, e)) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true });
for (const u of fieldAllies(b, e)) if (!u.isFlying) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true });
}, { sil: true })];
}
+3 -2
View File
@@ -379,10 +379,11 @@ export const SPECIAL_KITS = Object.freeze({
fire(b, e, a) { a.until = b.time + (T(ab, 'EndRotate.rotate_duration') ?? 0); b.fx('telegraph', { x: e.x, y: e.y, r: 1, kind: 'spin', id: e.id }); },
iv: T(ab, 'RotateDamage.interval') ?? 1,
// "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空),自身受阻止攻击类异常效果影响期间无法造成此伤害": an area
// selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on)
// selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on);
// 不可对空: never a flying ally (the 炎佑 dragon — until 0.2.1 it was hit)
tick(b, e, a) {
if (!(a.until > b.time) || e.s.flags.stun || e.s.flags.sleep || e.s.flags.disarm) return;
for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys');
for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) if (!u.isFlying) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys');
},
};
// 漩涡形态 "不进行普通攻击" (PRTS 天赋): no blocked attack while it spins (until 0.1.3 its blocker took both)
@@ -28,7 +28,9 @@ export default {
const s2On = (u) => !!(u.skill && u.skill.active && u.skill.id === S2);
return {
skill: { kind: 'passive' },
skills: { [S2]: { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, S2).atk) } } },
// [ASSUMED] S2 never shoots 白铁's 铁钳号 (its damage is cancelled, so it never drops below 80 %): the device alone does
// not open it (skills.js allyTargetsOk)
skills: { [S2]: { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, S2).atk) }, allyTargets: false } },
trait: {
canAttack: (battle, u) => !s2On(u) || battle.enemiesInKeys(u.rangeKeys, u, u.profile).some(huntable),
},
@@ -8,6 +8,8 @@ import {
groundTile, enemiesOn,
} from '../shared/tier3.js';
import { powi } from '../../../detmath.js';
import { champagneHold, CHAMPAGNE_TRIGGER } from '../../tokens.js';
import { canTargetEnemy } from '../../../targeting.js';
/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */
const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]);
@@ -73,10 +75,12 @@ export default {
skill: null,
trait: { noAttack: true },
install(battle, bomb) {
champagneHold(battle, bomb); // 禁疗, no HP loss, never gone because of its HP (the owner's decision D2 of 2026-10-08)
battle.on('tick', () => {
if (!bomb.alive || !bomb.deployed) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || e.s.flags.untargetable) continue;
// a ground enemy it may select — no 隐匿 one that is neither revealed nor blocked (tokens.js CHAMPAGNE_TRIGGER)
if (!canTargetEnemy(bomb, e, CHAMPAGNE_TRIGGER)) continue;
if (!bodyOnTile(e, bomb.tileR, bomb.tileC)) continue;
// first enemy touching it; after switchT s on the field the bomb deals its damage one extra time
const hits = battle.time - bomb.deployedAt >= switchT - 1e-9 ? 2 : 1;
@@ -37,6 +37,8 @@ export default withDefaults({
skills: alt(def, {
skchr_billro_1: () => ({
kind: 'duration',
// [ASSUMED] a charged cast makes no attack at all: 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk)
allyTargets: false,
mods: { atkPct: num(bb.atk), defPct: num(bb.def) },
onStart({ battle, unit, skill }) {
unit.mem.billroCharged = isCharged(skill);
@@ -37,12 +37,16 @@ function straightRun(battle, r, c) {
export default {
// ---------------------------------------------------------------------------------------------------------------
// 引星棘刺 — S2 解构涌潮 (instant / 2 charges elite): throws an alchemy unit at the target: for 12/15 s ground enemies
// around it get healing ×0.5, take 120/140 % ATK arts per second, allies heal 12/15 % ATK per second; the unit drifts
// around it get healing ×0.5, take 120/140 % ATK arts per second, allies recover 12/15 % ATK per second; the unit drifts
// along the throw direction and its radius grows. T1 心相: ATK +10 %, +3 s when another op is in range.
// T2 视界: allies ASPD +5, enemies −5 (doubled on straight roads ≥ 6 tiles). Module (elite): +0.1 SP/s with a unit out.
// S1 度算浪波 (instant): an alchemy unit thrown at the ally in her range with the lowest HP ratio: for
// projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and heal
// hp_recovery_per_sec_ratio × ATK per second. S3 “我的海疆”: passive — her range is the skill range; active
// projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and recover
// hp_recovery_per_sec_ratio × ATK per second. The recovery of S1 / S2 ("每秒回复/恢复相当于攻击力…的生命") is 生命回复速度,
// an hpRegen buff per alchemy unit set at each 1 s pulse until the next — the wording and blackboard key of 锡人 S2, whose
// PRTS 备注 says "生命恢复的提供方式为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响" (the only other 炼金师): 不屈者 and
// 禁疗 units recover too (community report: 炼金单元无法给不屈者等禁疗的干员提供生命恢复; it was a heal up to 0.2.1).
// S3 “我的海疆”: passive — her range is the skill range; active
// (instant) — alchemy units on the max_target_token operators with the lowest block count: for projectile_delay_time s
// the enemies around each of them (RING1, following it) get ATK/DEF/RES −, one strongest instance (不叠加), and take
// atk_scale × ATK arts per second, everything ramping +per_interval each `interval` s up to max_stack_cnt steps.
@@ -66,8 +70,8 @@ export default {
for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
if (z.acc >= 1 - 1e-9) {
z.acc -= 1;
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio);
if (heal > 0) for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
const regen = unit.s.atk * num(bb.hp_recovery_per_sec_ratio); // 生命回复速度 (see the header)
if (regen > 0) for (const a of allies) battle.addBuff(a, { key: z.key, duration: 1 + 2 * battle.dt, source: unit, mods: { hpRegen: regen } });
}
return;
}
@@ -95,7 +99,8 @@ export default {
onStart({ battle, unit }) {
const t = battle.alliesInGrid(unit).filter((a) => a.hp > 0).sort((a, b) => a.hpRatio - b.hpRatio || b.blocking.length - a.blocking.length || dist(a, unit) - dist(b, unit) || a.id - b.id)[0];
if (!t) return;
const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit) };
const key = `thorn2:guard:${unit.id}:${unit.mem.zoneSeq = (unit.mem.zoneSeq ?? 0) + 1}`; // its regen buff ("效果均可叠加")
const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit), key };
(unit.mem.zones ??= []).push(z);
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: RING1, duration: z.dur });
},
@@ -116,10 +121,13 @@ export default {
}),
skill: {
kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant',
// 白铁's 铁钳号·原型机 (a registered ally target) is a target like an enemy when no enemy is in her range — the owner's
// rule of 2026-10-08: the unit lands on it and its pulses hit it (cancelled by its kit) [ASSUMED] (skills.js allyTargetsOk)
allyTargets: true,
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
sortEnemyTargets(battle, unit, list, null);
let t = list.find((e) => !e.isFlying) ?? list[0];
let t = list.find((e) => !e.isFlying) ?? list[0] ?? battle.allyTargetsInKeys(unit.rangeKeys, unit)[0];
if (!t) {
// PRTS 备注: no enemy in range → thrown at the farthest tile straight ahead inside her range
let best = null;
@@ -186,8 +194,14 @@ export default {
if (z.acc >= 1 - 1e-9) {
z.acc -= 1;
for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] });
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr);
if (heal > 0) for (const a of battle.alliesInRadius(z.x, z.y, r, null)) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
// (白铁's 铁钳号 too, like a ground enemy — its kit cancels the hit)
for (const a of battle.allyTargetsInRadius(z.x, z.y, r, unit)) battle.dealDamage(unit, a, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] });
const regen = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr); // 生命回复速度 (see the header)
if (regen > 0) {
for (const a of battle.alliesInRadius(z.x, z.y, r, null)) {
if (battle.allySelectable(a, unit)) battle.addBuff(a, { key: `${z.key}:regen`, duration: 1 + 2 * battle.dt, source: unit, mods: { hpRegen: regen } });
}
}
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t), key: z.key });
}
}
@@ -39,6 +39,9 @@ export default {
}),
skill: {
kind: 'toggle',
// [ASSUMED] its attacks reach the enemies he blocks; the unblockable 铁钳号 of 白铁 never: it does not open it (skills.js
// allyTargetsOk)
allyTargets: false,
mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }),
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
attack: { hitAllBlocked: true },
@@ -58,7 +58,11 @@ function lemuen(bb, chess, def) {
const d2 = num(bb['attack@dist_2'], num(bb['attack@projectile_range'], 1.5));
const s1 = num(bb['attack@proj_atk_scale_1'], 1), s2 = num(bb['attack@proj_atk_scale_2'], 1);
const pickLock = (battle, unit, locks) => {
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
let cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
// 白铁's 铁钳号·原型机 (a registered ally target, an enemy-camp summon) is locked like an enemy when no enemy is in her
// range — the owner's rule of 2026-10-08 (it draws aggro like an enemy): her bullets are spent on it, the shells' hits
// are cancelled by it [ASSUMED: after every enemy, its 嘲讽等级 −2]
if (!cands.length) cands = battle.allyTargetsInKeys(unit.rangeKeys, unit);
if (!cands.length) return null;
sortEnemyTargets(battle, unit, cands, 'lowDef');
const cnt = (e) => locks.reduce((n, L) => n + (L.e === e ? 1 : 0), 0);
@@ -85,6 +89,10 @@ function lemuen(bb, chess, def) {
const d = bodyDist(e, x, y);
battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
// (the 铁钳号 too, like an enemy — its kit cancels the hit)
for (const a of battle.allyTargetsInRadius(x, y, d2, unit)) {
battle.dealDamage(unit, a, { amount: atk * s2, type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
};
const bombard = (battle, unit, locks) => {
const atk = unit.s.atk;
@@ -164,6 +172,7 @@ function lemuen(bb, chess, def) {
kind: 'ammo',
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))),
attack: { noAttack: true },
allyTargets: true, // its locks take 白铁's 铁钳号 too (pickLock) — skills.js allyTargetsOk
onStart({ unit }) { unit.mem.lemLocks = []; unit.mem.lemAcc = aim; },
onTick({ battle, unit, skill, dt }) {
const m = unit.mem;
@@ -51,6 +51,8 @@ function yu(bb, chess, def) {
// 'teleport' fx). PRTS's 0.13 s between the damage and the teleport is not modelled (same tick).
skchr_yu_2: {
kind: 'duration',
// [ASSUMED] its burst and teleport pick enemies; 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk)
allyTargets: false,
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) },
attack: { dmgType: 'arts' },
onStart({ battle, unit }) {
@@ -79,6 +81,7 @@ function yu(bb, chess, def) {
skill: {
kind: 'duration',
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) },
allyTargets: false, // [ASSUMED] its wall burns enemies crossing it; 白铁's 铁钳号 alone does not open it
// the wall runs through his tile perpendicular to his direction (RIGHT / LEFT: his column; UP / DOWN: his row —
// it turns with the deploy direction like a range) [ASSUMED orientation for UP / DOWN]
onStart({ battle, unit }) {
@@ -31,6 +31,8 @@ function rosmon(bb, chess, def) {
// two 战术装备 on melee tiles of her range (the token kit: appear stun, blocked enemies DEF −160, lifetime)
skchr_rosmon_3: {
kind: 'duration',
// [ASSUMED] blocked enemies only; 白铁's unblockable 铁钳号 never: it does not open it (skills.js allyTargetsOk)
allyTargets: false,
mods: { batPct: batOf(bb.base_attack_time, def, true), atkPct: num(bb.atk) },
targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))) },
onStart({ battle, unit }) {
@@ -128,6 +128,8 @@ function whitw2(bb, chess, def) {
// grid only selects those targets — no rangeId, no 攻击范围 in the text — so the card keeps her 3-1 (showOwnRange)
skchr_whitw2_1: {
kind: 'toggle',
// [ASSUMED] her drones lock enemies only (install): 白铁's 铁钳号 alone does not open S1 / S2 (skills.js allyTargetsOk)
allyTargets: false,
mods: { atkPct: num(bb.atk) },
targeting: { rangeGrid: WHOLE_FIELD, showOwnRange: true },
onStart({ unit }) { unit.mem.lazyLock = null; },
@@ -136,6 +138,7 @@ function whitw2(bb, chess, def) {
// (install); each drone hit ramps on its own target (trait init → max) and fears it attack@fear s with attack@prob
skchr_whitw2_2: {
kind: 'duration',
allyTargets: false,
mods: { atkPct: num(bb.atk) },
...(skillGridW ? { targeting: { rangeGrid: skillGridW } } : {}),
attack: {
+7
View File
@@ -114,9 +114,13 @@ export default {
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// 白铁's 铁钳号·原型机 (a registered ally target) is drawn on like an enemy, after every enemy (嘲讽等级 −2) — the
// owner's rule of 2026-10-08; its kit cancels the hits [ASSUMED] (skills.js allyTargetsOk)
allyTargets: true,
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR);
sortEnemyTargets(battle, unit, list, null);
list.push(...battle.allyTargetsInKeys(keysOf(unit, grid2), unit));
const v = list.slice(0, Math.max(1, Math.floor(num(b2.max_target, 1))));
battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length, skill: 'chen:draw' });
const amount = () => unit.s.atk * num(b2.atk_scale, 1);
@@ -132,6 +136,9 @@ export default {
duration: S3_CLIP,
flags: { invulnerable: true, noBlock: true },
attack: { noAttack: true },
// [ASSUMED] its slashes and its guard (guardActivation) take ground enemies only: 白铁's 铁钳号 alone does not open
// it (skills.js allyTargetsOk) — the skill ends at once without a target
allyTargets: false,
onStart({ battle, unit }) {
battle.releaseBlocked(unit); // 无法阻挡
unit.mem.chenS3 = { t: 0, n: 0, target: null };
+4 -2
View File
@@ -168,9 +168,10 @@ export default {
return {
trait: atkTargets > 1 ? { maxTargets: atkTargets } : null,
skills: {
[S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: healAttack(healTargets) },
// [ASSUMED] her skills' attacks heal: 白铁's 铁钳号 (禁疗, no patient) alone does not open them (skills.js allyTargetsOk)
[S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: healAttack(healTargets), allyTargets: false },
[S2]: {
kind: 'duration', attack: healAttack(healTargets + Math.max(0, Math.floor(num(b2['attack@max_target_heal_add'])))),
kind: 'duration', attack: healAttack(healTargets + Math.max(0, Math.floor(num(b2['attack@max_target_heal_add'])))), allyTargets: false,
onStart({ battle, unit }) {
unit.mem.harukaS2Uses = (unit.mem.harukaS2Uses ?? 0) + 1;
unit.mem.harukaS2Endless = unit.mem.harukaS2Uses >= 2;
@@ -185,6 +186,7 @@ export default {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeGrid: s3?.rangeGrid ?? null },
allyTargets: false,
attack: healAttack(healTargets),
onStart({ battle, unit }) { rescale(battle, unit); },
onEnd({ battle, unit }) { rescale(battle, unit); }, // (`active` is already false: ×1 again)
+10 -3
View File
@@ -100,6 +100,9 @@ export default {
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// [ASSUMED] its strike and its guard (guardActivation) take ground enemies only: 白铁's 铁钳号 alone does not open
// it (skills.js allyTargetsOk)
allyTargets: false,
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, GROUND);
sortEnemyTargets(battle, unit, list, null);
@@ -116,9 +119,12 @@ export default {
kind: 'duration',
duration: S3_DURATION,
attack: { noAttack: true },
// 白铁's 铁钳号·原型机 (a registered ally target) is struck like an enemy — the owner's rule of 2026-10-08; its kit
// cancels the hits [ASSUMED: its strikes take it like a ground enemy of the skill range] (skills.js allyTargetsOk)
allyTargets: true,
onStart({ battle, unit }) {
unit.mem.ireneS3 = { t: 0, n: 0 };
const v = battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND);
const v = [...battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND), ...battle.allyTargetsInKeys(keysOf(unit, grid3), unit)];
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'irene:judgment' });
for (const e of v) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
@@ -132,11 +138,12 @@ export default {
const iv = Math.max(0.05, num(b3.multi_hit_interval, 0.3)), times = Math.max(0, Math.floor(num(b3.multi_times, 10)));
while (m.n < times && m.t + 1e-9 >= (m.n + 1) * iv) {
m.n++;
const cands = battle.enemiesInKeys(keysOf(unit, grid3), unit, AIR);
const foes = battle.enemiesInKeys(keysOf(unit, grid3), unit, AIR);
const cands = foes.length ? foes : battle.allyTargetsInKeys(keysOf(unit, grid3), unit); // (the 铁钳号 when no enemy)
if (!cands.length) continue;
const c = battle.rng.pick(cands);
battle.fx('aoe', { x: c.x, y: c.y, radius: S3_SPLASH, id: unit.id, skill: 'irene:judgment' });
for (const e of battle.foesInRadius(c.x, c.y, S3_SPLASH, true)) {
for (const e of [...battle.foesInRadius(c.x, c.y, S3_SPLASH, true), ...battle.allyTargetsInRadius(c.x, c.y, S3_SPLASH, unit)]) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.multi_atk_scale, 1), type: 'phys', isSkill: true, isSplash: e !== c, tags: ['skill'] });
}
if (!unit.alive || !unit.skill?.active) return;
+2 -1
View File
@@ -56,7 +56,8 @@
// 白铁's S2 runs: s2.interval s and s2.hp_ratio (both timers restart when the mode changes, as the client re-creates the
// buffs on its mode switch).
// - 铁钳号·原型机 (S3), an enemy-camp summon: our attacks select it like an enemy, after every real one (嘲讽等级 −2;
// Battle.setAllyTarget — the engine extension of this kit), enemies never (untargetable); every damage it takes is cancelled
// Battle.setAllyTarget — the engine extension of this kit), and a non-heal skill's automatic start counts it like an enemy
// (skills.js `_allyTargetIn`, the owner's rule of 2026-10-08), enemies never (untargetable); every damage it takes is cancelled
// and gives it 1 SP (受击回复), and +hp_ratio of its max HP when the source is a 工匠 operator (trait, ignores 禁疗). 团结的力
// 量: the operator on its b-1 (its tile and the one behind it) takes physical damage ×(1 − damage_resistance) — one effect per
// device. Its skill (AUTO, 5 SP, data DEFAULT — it never attacks, so the engine checks it every tick: an enemy, flyers too, in
+2
View File
@@ -269,6 +269,8 @@ export default {
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
// [ASSUMED] the cross lands on an enemy target only: 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk)
allyTargets: false,
// the attack is the cross: no damage on the target itself (its 0.8 s later landing does it)
attack: { dmgType: 'arts', hitsFn: () => 0 },
onAttack({ battle, unit, targets }) {
+4 -3
View File
@@ -158,9 +158,10 @@ export default {
return {
trait: targets > 1 ? { maxTargets: targets } : null,
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) }, attack: healAttack },
// [ASSUMED] her skills' attacks heal: 白铁's 铁钳号 (禁疗, no patient) alone does not open them (skills.js allyTargetsOk)
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) }, attack: healAttack, allyTargets: false },
[S2]: {
kind: 'duration', mods: { aspd: num(b2.attack_speed) }, attack: healAttack,
kind: 'duration', mods: { aspd: num(b2.attack_speed) }, attack: healAttack, allyTargets: false,
onStart({ battle, unit }) {
// a 夜灯 already up (the battle-start deploy) is this skill's drone; else her placed piece takes the field
if (!battle.allyUnits.some((t) => t.kind === 'token' && t.defId === LAMP && t.ownerUnit === unit && t.alive)) releaseSkillSummon(battle, unit, LAMP);
@@ -168,7 +169,7 @@ export default {
onEnd({ battle, unit }) { withdrawLamps(battle, unit); },
},
[S3]: {
kind: 'duration', mods: { atkPct: num(b3.atk) }, attack: healAttack,
kind: 'duration', mods: { atkPct: num(b3.atk) }, attack: healAttack, allyTargets: false,
onStart({ unit }) { unit.mem.slent2Grave = true; }, // the one 不死 of this cast (slent2_shallow_grave[self])
onEnd({ unit }) { unit.mem.slent2Grave = false; },
},
+3
View File
@@ -177,6 +177,8 @@ export default {
},
[S2]: {
kind: 'duration',
// [ASSUMED] its glue / belt act on ground enemies only: 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk)
allyTargets: false,
// the owner's rule for a skill whose area is larger than her range (her tile): cast as soon as a ground enemy she
// can affect is on the glue's first five tiles — her tile, the front one, its two sides and the one beyond
// (glueTiles' PRTS order), as 余 S2's x-1 (§22.10); the data's DEFAULT (重装 exception) only saw her own tile
@@ -200,6 +202,7 @@ export default {
},
[S3]: {
kind: 'duration',
allyTargets: false,
// likewise on the conveyor: her tile and the four ahead (the owner's larger-area rule; 余 S2 precedent)
trigger: { rule: 'ACTIVE_RANGE', grid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]] },
mods: nz({ atkPct: num(b3.atk), defPct: num(b3.def), blockCnt: num(b3.block_cnt) }),
+32 -22
View File
@@ -34,11 +34,14 @@
// that touch on a side (上下 / 左右) activate each other and stay active once activated [ASSUMED from the S2 备注]; an
// enemy (air units too) on an active one's tile sets it off: its effect, then it is used up (a 棋子 leaves the field; a
// 跟子 is gone) [ASSUMED: a trap is spent by its trigger]. A spent 棋子 comes back on its tile ("原地再部署") after its
// redeploy time (2 s), paying its cost and one of her stock, while 望 is on the field. That return is the mode's
// deployment of her card: it counts as a 手动部署 for 铸子 —
// one 跟子 on a tile next to it (the PRTS order above), at most attack@max_spawn_cnt on the field [ASSUMED: the battle-start
// deployment does not count]. When 望 leaves the field her 棋子 and 跟子 vanish (PRTS 分支特性信息); the 棋子 come back
// with her.
// redeploy time (2 s), paying its cost and one of her stock, while 望 is on the field. 铸子 "手动部署棋子时,望在相邻位置额外
// 部署一枚棋子": the 棋子 the player placed deploy at the battle start (PRTS 卫戍协议/帮助: a placement in 休整 fires no
// deployment effect, the piece deploys when the combat starts) — that deployment drops one 跟子 on a tile next to it (the
// PRTS order above; community report, 0.2.2 — until 0.2.1 the battle start dropped none, so a lone 棋子 never set off), at
// most attack@max_spawn_cnt on the field; each later return counts as one too [ASSUMED]. The battle start deploys one
// piece after another (Battle.start), so a 跟子 may land on the tile of a piece still to come: "跟子所在格进行部署(或进行类似
// 部署的行为)时,该跟子会随之消失" (PRTS 铸子 备注) — any deployment on its tile removes it. When 望 leaves the field her 棋子
// and 跟子 vanish (PRTS 分支特性信息); the 棋子 come back with her.
// - T2 料敌机先 (attack@per_atk_scale / attack@per_magic_resist_penetrate_fixed / attack@max_trigger_cnt): an active 棋子 / 跟子
// gets one stack per piece of the unbroken line it is part of (itself included; the longer of its lines; ≤ the cap),
// kept once reached [ASSUMED]; its damage ×(1 + n × per) and n × pen RES ignored. TRP-X stage 3: 15 % / 13.
@@ -52,8 +55,9 @@
// attack@duration s, each one on its own timer, added up (speed ≥ 0.1). Active as S1.
// - S3 天下劫 (MANUAL, data ACTIVE_RANGE on its 4-12; ammo trigger_time): passive — the pieces' trigger and damage area is the
// token skill's range (x-6 in the data; PRTS writes x-1), atk_scale × her ATK arts on every enemy there. Active — no
// attacks, range 4-12; +cnt stock, the part over the cap placed at once as 跟子 on the enemies of her range first (her
// target order), then on other tiles of it by the 铸子 order [ASSUMED for the ties: nearest to her, then by tile]; a 棋子
// attacks, range 4-12; +cnt stock, the part over the cap placed at once as 跟子 on the tiles of her range by the 铸子 order
// (S3 备注 "采用相同的位置选择优先顺序"): enemy tiles first, then 不可部署 > 可部署地面 > 高台 [ASSUMED for the ties: her target
// order among enemy tiles, then nearest to her, then by tile]; a 棋子
// coming back inside her range during it drops up to 3 跟子, one bullet each ("第一天赋额外至多部署3枚棋子并消耗等量弹药");
// it ends when the bullets are spent ("棋子耗尽" [ASSUMED]); bullets left at another end return to the stock.
@@ -231,6 +235,9 @@ export default {
const w = owner.mem.wang;
unit.mem.wangNode = { seq: ++w.seq, act: false, h: false, v: false, stacks: 0 };
w.stock = Math.max(0, w.stock - 1); // it occupies her stock (the owner's decision of 2026-10-06)
// 铸子: the battle-start deployment of a placed 棋子 is its 手动部署 — one 跟子 next to it (a return drops its own in
// scheduleReturn)
if (ctx.initial && up(owner)) dropFollowers(battle, owner, unit.tileR, unit.tileC, 1);
}, { owner: unit });
battle.on('death', (ctx) => {
if (ctx.unit !== unit || battle.finished) return;
@@ -264,28 +271,22 @@ export default {
const total = w.stock + Math.max(0, Math.floor(num(b3.cnt)));
const over = Math.max(0, total - stockCap);
w.stock = Math.min(stockCap, total);
// "将超出上限的棋子优先部署在范围内敌人所在位置": enemies of her (4-12) range first, then other tiles of it
let n = Math.min(over, followCap - w.followers.length);
// "将超出上限的棋子优先部署在范围内敌人所在位置" and the 备注's "采用相同的位置选择优先顺序" (铸子): the free tiles of her
// (4-12) range, those holding an enemy she can target first, then 不可部署 > 可部署地面 > 高台; [ASSUMED] ties: her
// target order among enemy tiles, then nearest to her, then by tile
const n = Math.min(over, followCap - w.followers.length);
if (!(n > 0)) return;
const taken = new Set(piecesOf(battle, unit).map((p) => keyOf(p.r, p.c)));
const foes = sortEnemyTargets(battle, unit, battle.enemiesInKeys(unit.rangeKeys, unit, ANY), unit.profile?.priority ?? null);
for (const e of foes) {
if (n <= 0) break;
const r = Math.round(e.y), c = Math.round(e.x);
if (!followerFree(battle, unit, r, c, taken)) continue;
taken.add(keyOf(r, c));
addFollower(battle, unit, r, c);
n--;
}
if (n <= 0) return;
const rest = [];
const cands = [];
for (const k of unit.rangeKeys ?? []) {
const r = Math.floor(k / COLS), c = k % COLS;
if (!followerFree(battle, unit, r, c, taken)) continue;
rest.push({ r, c, k, cls: tileClass(battle, r, c), d: Math.max(Math.abs(r - unit.tileR), Math.abs(c - unit.tileC)) });
const fi = foes.findIndex((e) => e.alive && bodyOnTile(e, r, c));
cands.push({ r, c, k, foe: fi >= 0 ? 0 : 1, fi: fi >= 0 ? fi : 0, cls: tileClass(battle, r, c), d: Math.max(Math.abs(r - unit.tileR), Math.abs(c - unit.tileC)) });
}
rest.sort((a, b) => a.cls - b.cls || a.d - b.d || a.k - b.k);
for (const t of rest.slice(0, n)) addFollower(battle, unit, t.r, t.c);
cands.sort((a, b) => a.foe - b.foe || a.cls - b.cls || a.fi - b.fi || a.d - b.d || a.k - b.k);
for (const t of cands.slice(0, n)) addFollower(battle, unit, t.r, t.c);
},
onEnd({ unit, reason }) {
const w = unit.mem.wang;
@@ -360,6 +361,15 @@ export default {
const w = unit.mem.wang;
w.followers = w.followers.filter((f) => f !== n);
};
// "跟子所在格进行部署(或进行类似部署的行为)时,该跟子会随之消失" (PRTS 铸子 备注): any ally deploying on a 跟子's tile
// (priority 50: before that piece's own 铸子 drop)
battle.on('deploy', (ctx) => {
const a = ctx.unit, w = unit.mem.wang;
if (!a || a.side !== 'ally' || !w || !w.followers.length) return;
const n0 = w.followers.length;
w.followers = w.followers.filter((f) => f.r !== a.tileR || f.c !== a.tileC);
if (w.followers.length !== n0) battle.fx('disappear', { x: a.tileC, y: a.tileR, id: unit.id });
}, { owner: unit, priority: 50 });
battle.on('tick', () => {
if (!up(unit) || battle.finished) return;
const nodes = piecesOf(battle, unit);
+32 -4
View File
@@ -33,7 +33,9 @@
// (never while 缪尔赛思 is down: her redeploy brings it back; split clones are never her 援军)
// 香槟炸弹 untargetable trap: the first ground enemy touching its tile takes owner ATK × atk_scale phys (+1 hit
// after duration_switch s on the field) and sluggish; the bomb is used up ('expired', never a knock-out;
// its blast fx carries `consumed: true` so clients play the explosion, not a death sound)
// its blast fx carries `consumed: true` so clients play the explosion, not a death sound); 禁疗, no HP
// loss, never gone because of its HP (champagneHold — the owner's decision D2 of 2026-10-08); an
// unrevealed 隐匿 enemy does not set it off (CHAMPAGNE_TRIGGER)
// 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile
// 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs; 维娜 S3 places one on every
// free deployable tile around her (kits/ops/chess_char_6_07-siege2.js) — no per-owner deploy limit (SKILL_SUMMON_UNCAPPED)
@@ -79,6 +81,8 @@
//
// Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows,
// COUNTDOWN_SUMMONS / startCountdown (the countdown summons' 无敌 + 禁疗 and timer bar, shared with the kits that time them),
// champagneHold / CHAMPAGNE_TRIGGER (香槟炸弹's durability and the enemies it goes off on, shared with 琳琅诗怀雅's kit,
// kits/ops/chess_char_3_04-swire2.js),
// wolfShadowInterval, installWolfTacticalPoint, wolfTacticalPoint, wolfReturnNow (the 狼群's 战术点形态, shared with the
// 伺夜 kit's own pack, kits/ops/chess_char_3_19-vigil.js), releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY,
// TOKEN_IDS; mapCharTalents and touchGospel (Touch's 攫升 / 超脱 and 恳切福音, shared with the Touch 补位 stand-in kit,
@@ -1030,7 +1034,31 @@ function manifold(bb, raw, def) {
};
}
/** 香槟炸弹 (琳琅诗怀雅 S2): untargetable trap consumed by the first ground enemy touching its tile. */
/**
* 香槟炸弹's durability — the owner's decision D2 of 2026-10-08, a deliberate deviation (PRTS 香槟炸弹: 1000 HP and the 备注
* "即使自身生命值未满,模型下方也不会显示生命值槽" — officially it can lose HP; no 无敌 / 禁疗 note): it holds 禁疗 (no heal
* reaches it, no healer picks it), loses no HP (every damage to it is cancelled — the 活性源石 tick, an element hit too —
* and a 流失 is given back before the knock-out check) and so never leaves because of its HP; it goes when its 见面礼 is
* spent, as before. Flags only: no status icon, no 无敌 badge (its HP bar is hidden by the client). The token kit below
* and 琳琅诗怀雅's own bomb (kits/ops/chess_char_3_04-swire2.js) both install it. Until 0.2.1 a bomb on 活性源石 lost 70 HP/s
* and a medic healed a hurt one.
*/
export function champagneHold(battle, bomb) {
battle.addBuff(bomb, { key: 'token:champagneHold', flags: { noHeal: true, healFree: true }, persist: true, allowDead: true });
battle.on('hit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 });
battle.on('elementHit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 });
battle.on('damaged', (ctx) => { if (ctx.target === bomb && bomb.alive) bomb.hp = bomb.s.maxHp; }, { owner: bomb, priority: 1000 });
}
/**
* The enemies a 香槟炸弹 may set off on: ground and selectable (canTargetEnemy) — a 隐匿 enemy that is neither revealed nor
* blocked is none (PRTS 作战机制 §隐匿 "隐匿效果使得获得该效果的单位无法被任何敌方的能力索敌选中"; its pages carry no 无视隐匿 /
* 无视可选性 note; the other contact traps — 多萝西's 共振装置, 望's 棋子 — select so too). A community report the owner relayed
* (「香槟炸到没有破隐的隐匿敌人了」): until 0.2.1 it went off on any ground enemy, 隐匿 or not.
*/
export const CHAMPAGNE_TRIGGER = Object.freeze({ canHitFly: false, groundOnly: true });
/** 香槟炸弹 (琳琅诗怀雅 S2): untargetable trap consumed by the first ground enemy touching its tile (champagneHold). */
function champagne(bb) {
const scale = num(bb['attack@atk_scale'] ?? bb.atk_scale, 0);
const slug = num(bb['attack@sluggish'] ?? bb.sluggish, 0);
@@ -1042,7 +1070,7 @@ function champagne(bb) {
if (!unit.alive) return;
let hit = null;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying) continue;
if (!canTargetEnemy(unit, e, CHAMPAGNE_TRIGGER)) continue; // (no unrevealed 隐匿 enemy)
// touching its tile: a huge enemy on any tile of its body (body.js)
const on = e.hitArea ? bodyOnTile(e, unit.tileR, unit.tileC) : Math.abs(e.x - unit.tileC) <= 0.5 && Math.abs(e.y - unit.tileR) <= 0.5;
if (on && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e;
@@ -1057,7 +1085,7 @@ function champagne(bb) {
},
},
trait: { noAttack: true },
install(battle, unit) { untargetable(battle, unit); },
install(battle, unit) { untargetable(battle, unit); champagneHold(battle, unit); },
};
}
+49 -7
View File
@@ -29,6 +29,10 @@
// (noAttack profiles) evaluate DEFAULT every tick instead. DEFAULT with `trigger.allies` (+ `hpAtMost`): the basic
// rule AND such an ally on the trigger grid — the cast replaces the attack about to be made (塞雷娅 S1 "触发时会替换当
// 次攻击"); should that ally condition fail before the attack, the cast is withdrawn and its charge returned.
// A registered ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon our attacks select) counts
// like an enemy in each of these enemy conditions for a skill that acts on it (`_allyTargetIn`, `allyTargetsOk` — the
// owner's rule of 2026-10-08): one that acts through the unit's own attacks, or whose kit flags `allyTargets: true` (its
// selectors take ally targets too); a skill that picks its own victims among enemies only is not opened by it.
// Automatic operations cool down (constants.js AUTO_OP_COOLDOWN, "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位
// 将进入冷却"): the engine auto-casts a MANUAL skill (def.skillType) no sooner than 3 s after its previous cast (so a
// charged skill spends its charges 3 s apart) or after the unit's deployment at the battle start (Battle._deploy
@@ -42,7 +46,8 @@
// mods, flags, targeting {maxTargets, rangeGrid, priority, allInRange, rangeExtend, noRangeExtend (the range ignores
// the unit's 攻击距离), showOwnRange (rangeGrid only selects targets: the detail card keeps the unit's own range)},
// attack {dmgType, atkScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul, onHit, heal…},
// heal (bool: heal-type skill for the trigger rule), onStart(ctx), onEnd(ctx), onHit(ctx), onAttack(ctx), onTick(ctx).
// heal (bool: heal-type skill for the trigger rule), allyTargets (bool: the skill acts on a registered ally target — see
// `allyTargetsOk`), onStart(ctx), onEnd(ctx), onHit(ctx), onAttack(ctx), onTick(ctx).
// ctx passed to spec callbacks: { battle, unit, skill, bb, target?, dealt?, targets?, dt?, reason? }; onAttack's ctx
// also carries `noAmmo` (set it to true: this attack spends no ammo). onEnd runs while the skill's mods / range are
// still applied (`active` is already false); they are removed right after it (unless onEnd re-activated the skill).
@@ -161,12 +166,45 @@ export class SkillRuntime {
if (Array.isArray(x)) keys = x;
else if (x && typeof x === 'object' && x.side === 'ally' && x.alive && x.deployed && !x.hidden) keys = x.rangeKeys;
else if (x && typeof x === 'object' && Array.isArray(x.keys)) { keys = x.keys; if (x.profile) prof = x.profile; }
if (keys && keys.length && b.enemiesInKeys(keys, u, prof).length) return true;
if (keys && keys.length && (b.enemiesInKeys(keys, u, prof).length || this._allyTargetIn(keys))) return true;
}
}
return false;
}
/**
* A registered ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, a summon of the enemy camp our attacks select) on
* `keys` (tile keys or a Set): a non-heal skill's automatic start counts it like an enemy — the owner's rule of
* 2026-10-08 (a community report and their recall of the official mode: 干员攻击范围/技能范围内有白铁的装置时技能也自动开、自动
* 消耗,装置对干员的仇恨类似敌方单位; PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方"). It stays out of enemiesInKeys /
* canTargetEnemy (every other selector keeps to enemies); a heal skill never counts it (禁疗: not a patient).
*/
_allyTargetIn(keys) {
if (!keys || !this.allyTargetsOk) return false;
const b = this.battle;
return !!(b._allyTargets && b._allyTargets.size) && b.allyTargetsInKeys(keys, this.unit).length > 0;
}
/**
* Does this skill act on a registered ally target, so that one may start it (`_allyTargetIn`)? Never a heal skill.
* SkillSpec `allyTargets` (true / false) answers for a kit: true where its selectors take ally targets too
* (allyTargetsInKeys / allyTargetsInRadius — 蕾缪安 S3, 艾丽妮 S3, 陈 S2, 引星棘刺 S2), false where they never do although
* the skill keeps attacking. Without it: a skill that acts through the unit's own attacks — which select the device
* (ai.js acquireTargets) —, a timed one that keeps attacking (no `attack.noAttack`) or a cast that is the next attack
* (`spec.attack`), on a unit that attacks. [ASSUMED] Any other skill picks its victims itself among the enemies
* (enemiesInKeys, foesInRadius …) and would be spent on nothing: the device does not open it — it waits for an enemy.
* (Grok's review of round 22: 蕾缪安 S3 / 艾丽妮 S3 / 陈 S2 / 引星棘刺 S2 opened on the device alone and did nothing.)
*/
get allyTargetsOk() {
if (this.healSkill || this.noSkill || this.kind === 'passive') return false;
const s = this.spec;
if (s.allyTargets != null) return !!s.allyTargets;
const p = this.unit.profile;
if (p && p.noAttack) return false;
if (s.attack && s.attack.noAttack) return false;
return this.isTimed || !!s.attack;
}
get spCost() {
const c = Math.floor(this.baseSpCost * this.spCostMul + (this.unit.s ? this.unit.s.spCostFlat : 0));
// content writes spCostMul / spCostFlat: a NaN/Infinity cost would freeze SP forever — use the base cost instead
@@ -405,27 +443,31 @@ export class SkillRuntime {
// SEARCH: an enemy inside the initial attack range, every tick (librators / phalanxes and 安洁莉娜 do not attack
// while the skill is off, so DEFAULT's "about to attack" never comes)
if (this.rule === 'SEARCH') return this._defaultCondition();
// (CUSTOM_RANGE / SKILL_RANGE / GDGLOW_SKILL_2 / the DEFAULT condition: a registered ally target — 白铁's 铁钳号·原型机 —
// counts like an enemy for a non-heal skill, `_allyTargetIn`)
if (this.rule === 'CUSTOM_RANGE') {
if (!this.triggerGrid) return this._defaultCondition();
return b.enemiesInKeys(this._triggerKeys(), u, { canHitFly: true }).length > 0;
const keys = this._triggerKeys();
return b.enemiesInKeys(keys, u, { canHitFly: true }).length > 0 || this._allyTargetIn(this._trigSet);
}
if (this.rule === 'SKILL_RANGE') {
if (this.triggerAllies) return this._allyTriggerSatisfied();
if (!this.triggerGrid) return this._defaultCondition();
return b.anyEnemyInKeys(this._triggerKeys());
return b.anyEnemyInKeys(this._triggerKeys()) || this._allyTargetIn(this._trigSet);
}
if (this.rule === 'ACTIVE_RANGE') return this._defaultCondition(this.triggerGrid ? this._triggerKeys() : null);
// GDGLOW_SKILL_2 "全场存在可选目标时释放技能": a targetable enemy anywhere (heal skill: an ally that needs healing)
if (this.rule === 'GDGLOW_SKILL_2') {
if (this.healSkill) return b.injuredAlliesInKeys(ALL_TILES, u, !!u.profile?.heal?.elementHealRatio).length > 0;
return b.enemies.some((e) => canTargetEnemy(u, e, TRIGGER_PROFILE));
return b.enemies.some((e) => canTargetEnemy(u, e, TRIGGER_PROFILE)) || this._allyTargetIn(ALL_TILES);
}
return false;
}
/**
* DEFAULT rule condition: an enemy (or injured ally for heal skills) inside the initial range (baseRangeKeys: own
* grid + permanent rangeExtend), or an enemy inside a content trigger range (addTriggerRange; not for heal skills).
* grid + permanent rangeExtend) — a registered ally target there too (`_allyTargetIn`: 铁钳号·原型机; not for heal
* skills) — or an enemy inside a content trigger range (addTriggerRange; not for heal skills).
* `range` (ACTIVE_RANGE): those absolute tile keys instead of the initial range.
*/
_defaultCondition(range = null) {
@@ -435,7 +477,7 @@ export class SkillRuntime {
const keys = range || u.baseRangeKeys || u.rangeKeys;
if (keys) {
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0;
if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true;
if (b.enemiesInKeys(keys, u, u.profile).length > 0 || this._allyTargetIn(keys)) return true;
}
// the enemies a unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy (a ranged blocker too: user playtest #6)