feedback7: merge fb7-gh-sim — PR #348 (take-the-highest chains), PR #293 (REA-Y in the engine), #329 ports (异客 13 tiles, 歌蕾蒂娅 net radius 1, 搅拌机 latest first, 大帝 uncapped), PR #365 (骨刺 forms + PRTS targeting), #298 (instant non-attack skills recast at most once per attack interval, attacks untouched), #364 (先兆者 shell 3×3), #220 (速射手 air first), 余 skips noStack bonds (owner)
- Conflict: docs/PLAYING.md 技能自动释放 bullet — both insertions kept (the 铁钳号 rule from fb7-owner-sim, the #298
pacing sentence).
- Grok review: MERGE AFTER FIXES — the HIGH #298 finding (the cast replaced the due attack) fixed on the branch
(db29acf9: per-skill nextCastAt; golden of #298 alone shrank from 84 scenarios to 2).
- Golden union: 4 matches differ from HEAD though the branch alone left them (coop2-NORMAL-3, coop4-ABYSS-7,
coop2-NORMAL-8-serverrun, solo-HARD-9-boosted): HEAD's side had moved all four (#333's shop RNG …) and on those new
bot paths the merged rules apply — the diffs are in-battle layer gains / damage (卡西米尔 50 → 45, 精准 80 → 76,
叙拉古 176 → 168, kills ±2, solo-HARD-9 LP 397 → 399), no outcome change.
- Broad node --test 5571 (5570 pass, 0 fail, 1 skipped); lint 0 errors; typecheck.
This commit is contained in:
commit
52f56319ac
41 files changed
+2065
-648
No files matched your search
@@ -10,6 +10,7 @@ import { SkillRuntime } from '../skills.js';
|
||||
import { resolveProfile } from '../professions.js';
|
||||
import { normalizeToken } from '../simdata.js';
|
||||
import { setupUnitKit } from '../content/index.js';
|
||||
import { installTraitAttackSpeed } from '../content/traitMods.js';
|
||||
import { clone } from './util.js';
|
||||
|
||||
export class BattlePlayers {
|
||||
@@ -220,6 +221,13 @@ export class BattlePlayers {
|
||||
if (t && typeof t.install === 'function') this._safe(() => t.install(this, u), 'talent.install', u);
|
||||
}
|
||||
if (typeof u.kit.install === 'function') this._safe(() => u.kit.install(this, u), 'kit.install', u);
|
||||
// trait lines the ENGINE owns for every operator (content/traitMods.js: the module attack-speed riders whose
|
||||
// condition is a pure function of the field — 「攻击范围内存在N名及以上敌人时攻击速度+X」). Runs after kit.install:
|
||||
// a kit that implements its own line for this trait is never double-counted — traitMods.js refuses every condition
|
||||
// shape a kit already owns (`reason: 'kit'`), and the kits whose line it DOES take over (the two REA-Y ones) had
|
||||
// their hand-written copy deleted in the same change. Calling it before kit.install would let a kit's own buff and
|
||||
// this rule both land on the same unit.
|
||||
this._safe(() => installTraitAttackSpeed(this, u), 'traitMods.attackSpeed', u);
|
||||
return u.kit;
|
||||
}
|
||||
}
|
||||
@@ -274,23 +274,39 @@ export class BattleStatus {
|
||||
}
|
||||
|
||||
/**
|
||||
* "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). A
|
||||
* "同名效果取最高": keep the strongest value, then resume weaker effects in order of strength at their original expiry times.
|
||||
* `buff.data.tail` links the waiting effects, with decreasing strength and increasing expiry times. A
|
||||
* `plain` template (applyStrongest) is an ordinary invisible buff, not a status. `stackAs` (finite): the strength this
|
||||
* application competes with instead of its value (applyStatus opts.stackAs); kept in `data.stackAs` / the tail.
|
||||
*/
|
||||
_applyValuedStatus(target, key, tpl, duration, value, source, stackAs = null) {
|
||||
const as = Number.isFinite(stackAs) ? stackAs : null;
|
||||
const strength = (v, s = null) => Math.abs(Number.isFinite(s) ? s : Number.isFinite(v) ? v : tpl.valued);
|
||||
// (no stackAs: the very objects of before — data { value, tail }, tails { value, until })
|
||||
const entry = (v, s, extra) => (Number.isFinite(s) ? { value: v, stackAs: s, ...extra } : { value: v, ...extra });
|
||||
const after = (tail, until) => {
|
||||
while (tail && tail.until <= until) tail = tail.tail;
|
||||
return tail || null;
|
||||
};
|
||||
const insertTail = (tail, incoming) => {
|
||||
if (!tail) return incoming;
|
||||
const diff = strength(incoming.value, incoming.stackAs) - strength(tail.value, tail.stackAs);
|
||||
if (diff > 1e-12) return { ...incoming, tail: after(tail, incoming.until) };
|
||||
if (diff < -1e-12) {
|
||||
if (incoming.until <= tail.until) return tail;
|
||||
return { ...tail, tail: insertTail(tail.tail, incoming) };
|
||||
}
|
||||
// Equal-priority waiting effects keep the longest-lived application's value, including its stackAs.
|
||||
const kept = incoming.until > tail.until ? incoming : tail;
|
||||
return { ...kept, tail: after(tail.tail, kept.until) };
|
||||
};
|
||||
const make = (v, dur, tail, s = null) => ({
|
||||
...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick)
|
||||
key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: tpl.plain ? null : key,
|
||||
visible: !tpl.plain, source,
|
||||
data: entry(v, s, { tail }),
|
||||
onExpire: ({ battle, unit, buff }) => {
|
||||
const t = buff.data.tail;
|
||||
if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null, t.stackAs));
|
||||
const t = after(buff.data.tail, battle.time + 1e-6);
|
||||
if (t && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, t.tail || null, t.stackAs));
|
||||
},
|
||||
});
|
||||
const old = target.buffs.find((b) => b.key === key && (tpl.plain || b.status === key));
|
||||
@@ -299,17 +315,17 @@ export class BattleStatus {
|
||||
const oldAs = old.data && Number.isFinite(old.data.stackAs) ? old.data.stackAs : null;
|
||||
const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration;
|
||||
const oldTail = old.data && old.data.tail;
|
||||
const longerTail = (a, b) => (!a ? b : !b ? a : (b.until > a.until ? b : a));
|
||||
if (strength(value, as) > strength(oldV, oldAs) + 1e-12) {
|
||||
// stronger: takes over now; the weaker old one (or its tail) resumes if it lasts longer
|
||||
const tail = longerTail(oldEnd > newEnd ? entry(oldV, oldAs, { until: oldEnd }) : null, oldTail && oldTail.until > newEnd ? oldTail : null);
|
||||
// Stronger: take over now, retaining every old effect that can still become active afterwards.
|
||||
const tail = after(entry(oldV, oldAs, { until: oldEnd, tail: oldTail }), newEnd);
|
||||
this.addBuff(target, make(value, duration, tail, as));
|
||||
} else if (strength(value, as) < strength(oldV, oldAs) - 1e-12) {
|
||||
// weaker: never overrides; remembered as the tail when it outlasts the running one
|
||||
if (newEnd > oldEnd && (!oldTail || newEnd > oldTail.until)) old.data = { ...old.data, value: oldV, tail: entry(value, as, { until: newEnd }) };
|
||||
// Weaker: insert among the waiting effects instead of replacing the one with the latest expiry.
|
||||
if (newEnd > oldEnd) old.data = { ...old.data, value: oldV, tail: insertTail(oldTail, entry(value, as, { until: newEnd })) };
|
||||
} else if (duration > old.timeLeft) {
|
||||
old.timeLeft = duration;
|
||||
old.duration = Math.max(old.duration, duration);
|
||||
old.data.tail = after(oldTail, newEnd);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
// act1autochess_band13_buff 克莱门莎 崇高牺牲 a <bond_id> operator knocked down ⇒ +its tier (等阶) <bond_id> layers
|
||||
// (bond_add_type by_charlevel; no "已激活" in the text ⇒ requireActive false)
|
||||
// act1autochess_band16_buff 大帝 加急调派 "每次部署后再部署时间减少50%": every deployment of an operator stacks one
|
||||
// redeploy ×(1 + respawn_time) for the rest of the battle [ASSUMED cumulative:
|
||||
// "每次" — the first redeploy is −50 % under either reading]
|
||||
// redeploy ×(1 + respawn_time) for the rest of the battle, with no cap (PRTS
|
||||
// 卫戍协议:盟约 下半 / PRTS盟约记录 备注 "※该策略效果可无限叠加"; a 20-stack cap
|
||||
// until 0.2.2 — GitHub #328, PR #329)
|
||||
// act1autochess_band17_buff 桑葚 药枚实验 at combat start the player's units on the front-most (最右边) column: each
|
||||
// attack has `prob` to gain 1 shield layer (max 1)
|
||||
// act1autochess_band18_buff 休谟斯 回收利用 a ground (地面) operator's skill ends ⇒ a random operator on its 4
|
||||
@@ -106,7 +107,7 @@ const BY_KEY = {
|
||||
battle.on('deploy', (c) => {
|
||||
const u = c.unit;
|
||||
if (!isOp(u) || u.ownerId !== ps.playerId) return;
|
||||
battle.addBuff(u, { key, mods: { redeployMul: mul }, refresh: 'stack', stacks: 1, maxStacks: 20, persist: true, allowDead: true });
|
||||
battle.addBuff(u, { key, mods: { redeployMul: mul }, refresh: 'stack', stacks: 1, maxStacks: Infinity, persist: true, allowDead: true });
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// shop: onBuy — a free pick / grant never uses it up)
|
||||
// band_cost_coin_reach_cnt_gain_chess_from_pool 潘格尼尼 once coin_cnt funds spent in total: 1 chess of pool (elite 拉特兰 ≥ T4)
|
||||
// round_start_bond_check_gain_layer 余 round start of `round`: exactly factioncount active bonds → +count1, else each active +count2
|
||||
// — never to a bond without layers (noStack; the owner's decision of 2026-10-08)
|
||||
// up_shop_add_special_goods {count,choice,pool} 凯瑟琳 every level-up: pick 1 of `count` items of pool (free)
|
||||
// coin_carry_over {capital,interest,max} 坎诺特 leftover ≥ capital at round start → +min(max, ⌊left/capital⌋×interest) income
|
||||
// round_start_all_player_change_enemy_2 鸭爵 global: every alive player's battle from `round` (own + teammates;
|
||||
@@ -244,15 +245,22 @@ K.band_cost_coin_reach_cnt_gain_chess_from_pool = (ps) => ({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 余 【文火慢炖】 「第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层」. The owner's decision of 2026-10-08
|
||||
* overrides PRTS here (下半/PRTS盟约记录: layers of a bond that shows none 「仍然对其生效」): a bond without layers — bonds.json
|
||||
* `noStack`: 绝技, 独行, 调和, 协防干员 — gets none. The count stays the sentence's (every active bond), so one active bond
|
||||
* that is such a bond means nothing is added, and with two active bonds a layered one still gets count2. Only this
|
||||
* strategy: 协防's 「使所有已激活的盟约层数」 (bonds/addon/meta.js) and 华法琳's 【重点监护】 keep adding to every bond they name.
|
||||
*/
|
||||
K.round_start_bond_check_gain_layer = (ps) => ({
|
||||
onRoundStart(ctx) {
|
||||
const p = ps[0];
|
||||
if (ctx.round !== int(p.round, -1)) return;
|
||||
const active = Object.entries(ctx.bonds()).filter(([, b]) => b && b.active).map(([id]) => id);
|
||||
if (active.length === int(p.factioncount, 1)) {
|
||||
for (const b of active) ctx.addLayers(b, int(p.count1, 0), { requireActive: true, reason: 'band' });
|
||||
} else {
|
||||
for (const b of active) ctx.addLayers(b, int(p.count2, 0), { requireActive: true, reason: 'band' });
|
||||
const n = active.length === int(p.factioncount, 1) ? int(p.count1, 0) : int(p.count2, 0);
|
||||
for (const b of active) {
|
||||
if (ctx.gd.bond(b)?.noStack) continue;
|
||||
ctx.addLayers(b, n, { requireActive: true, reason: 'band' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// server/sim/content/enemies/fly.js — FLY 飞行 kits (auras, 暴鸰, the “萨科塔”, 假想敌:黑云, shells, seeds) and their part of KITS
|
||||
// (split from content/enemies.js).
|
||||
|
||||
import { TICK, MOVE_SCALE, PROJECTILE_SPEEDS } from '../../constants.js';
|
||||
import { TICK, MOVE_SCALE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../../constants.js';
|
||||
import {
|
||||
num, T, hurt, targetsNear, allTargets, areaAllies, areaAlliesInTiles, fieldAllies, byPriority, spawnChildren,
|
||||
stepToward, setForm, expose,
|
||||
@@ -27,9 +27,14 @@ export const BOMBD_RELEASE = 8 / 30;
|
||||
* 提升至200%"; the cast's end clip is already the bomb-less Idle_2 (`_endAnimKey`). */
|
||||
export const BOMBD_POST_DELAY = 0.667;
|
||||
|
||||
/** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力
|
||||
* 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */
|
||||
const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2;
|
||||
/** 帝国炮火先兆者 shell (PRTS 天赋 of both 先兆者 "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成
|
||||
* 攻击力100%的无来源物理伤害(此弹道不会强制击中主目标,碰撞无视迷彩)… ※弹道始终使用缓存攻击力"): flight time and the written
|
||||
* blast radius. The landing is a 碰撞 — PRTS 作战机制 §碰撞体积: collider tests are the common way, an ally's collider a
|
||||
* circle of ALLY_COLLIDER_RADIUS — so an ally is hit when its collider touches the 1.2 circle: centre distance ≤ 1.45
|
||||
* (SHELL_REACH), the impact tile and the 8 around it (a diagonal tile centre is √2 ≈ 1.414 away; two tiles, 2, stay
|
||||
* out) — the 3×3 a player saw in the official game (GitHub #364; until 0.2.2 the bare 1.2: the cross only).
|
||||
* [ASSUMED] for this shell (its text names the 碰撞); other content zones keep their point radius (constants.js). */
|
||||
const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2, SHELL_REACH = SHELL_RADIUS + ALLY_COLLIDER_RADIUS;
|
||||
|
||||
/** 假想敌:黑云 抓取: the blackboard radius counts ×2.5 (PRTS "2.5倍可变半径": range_radius 1.5 → 3.75), at most 3 prey,
|
||||
* "短暂延迟后" the 延迟吞噬 lands (delay [ASSUMED] 0.5 s); its SP (= 全弹发射 hits) caps at the data's spData.maxSp. */
|
||||
@@ -207,7 +212,8 @@ function kitBombd(ab) {
|
||||
|
||||
/**
|
||||
* 帝国炮火先兆者 / 中枢先兆者 (PRTS): every normal attack fires a shell at the target's position that lands SHELL_FLIGHT s
|
||||
* later and deals 100 % of the ATK at launch as physical damage without a source to every ally within SHELL_RADIUS
|
||||
* later and deals 100 % of the ATK at launch as physical damage without a source to every ally whose collider touches the
|
||||
* SHELL_RADIUS circle (SHELL_REACH)
|
||||
* (it may miss the target that moved away; "碰撞无视迷彩"). The landing is the shell's area selection (areaAllies of the
|
||||
* enemy that fired it: no unblocking 隐匿 ally; the damage stays 无来源). The attack itself is the engine's (cooldown,
|
||||
* pause, 'atk' event of kind 'mortar'); the damage is the shell's (ai.js `profile.deferHit`).
|
||||
@@ -219,10 +225,11 @@ function kitShell() {
|
||||
const atk = e.s.atk * (e.profile.atkScale ?? 1);
|
||||
for (const t of c.targets) {
|
||||
const x = t.x, y = t.y;
|
||||
b.fx('bombardShell', { x, y, id: e.id, r: SHELL_RADIUS, t: SHELL_FLIGHT });
|
||||
// (the telegraph and the blast are drawn at the reach: every ally whose centre lies in the circle is hit)
|
||||
b.fx('bombardShell', { x, y, id: e.id, r: SHELL_REACH, t: SHELL_FLIGHT });
|
||||
b.after(SHELL_FLIGHT, () => {
|
||||
b.fx('bombard', { x, y, r: SHELL_RADIUS, kind: 'emppnt' });
|
||||
for (const u of areaAllies(b, e, x, y, SHELL_RADIUS)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] });
|
||||
b.fx('bombard', { x, y, r: SHELL_REACH, kind: 'emppnt' });
|
||||
for (const u of areaAllies(b, e, x, y, SHELL_REACH)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// content/enemies.js).
|
||||
|
||||
import { canTargetAlly, enemyStealthed } from '../../targeting.js';
|
||||
import { ALLY_COLLIDER_RADIUS } from '../../constants.js';
|
||||
import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js';
|
||||
import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js';
|
||||
import { hypot } from '../../detmath.js';
|
||||
@@ -9,7 +10,8 @@ import { hypot } from '../../detmath.js';
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// constants (numbers that exist nowhere in the data)
|
||||
|
||||
/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */
|
||||
/** 假想敌:骨刺's targets at once while its 隐匿 holds: 3 — the level's own description 「隐匿;隐匿状态下同时攻击3个目标。」
|
||||
* (enemy_database; the handbook line says 「数个目标」) and PRTS 天赋 「隐匿期间可同时攻击3个目标」 (GitHub #365). */
|
||||
const ACBUNN_TARGETS = 3;
|
||||
|
||||
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后
|
||||
@@ -114,11 +116,24 @@ function kitShadowBlade(ab) {
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* 假想敌:骨刺 — PRTS 天赋 (级别0): 「自身普通攻击索敌不受阻挡影响」 — profile `blockFree` (as 自制投石机): blocked, and so out of its
|
||||
* 隐匿, it still picks by priority among the allies in reach, not its blocker first, and a 隐匿 / 迷彩 blocker is no target
|
||||
* (targeting.js canTargetAlly); 「不会攻击飞行单位」 — profile `canTarget`; 「隐匿(解除阻挡0秒后恢复)」 — stealth(); 「隐匿期间
|
||||
* 可同时攻击3个目标」: while its 隐匿 holds, the first ACBUNN_TARGETS by priority among the allies its normal attack could hit
|
||||
* (ai.js attackTargets: the same reach — rangeRadius + the ally collider — and the same rules); one otherwise. Until
|
||||
* 0.2.2 (PR #365 by @Sukvii, the PRTS lines checked for this port): blocker first, flyers hit, the 3 picked within the bare
|
||||
* rangeRadius.
|
||||
*/
|
||||
function kitBoneSpike() {
|
||||
return [stealth(), {
|
||||
spawn(b, e) {
|
||||
e.profile.blockFree = true;
|
||||
e.profile.canTarget = (u) => !u.isFlying;
|
||||
},
|
||||
before(c, b, e) {
|
||||
if (!enemyStealthed(e)) return; // 隐匿状态下同时攻击数个目标 (back 0 s after a block)
|
||||
const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius));
|
||||
if (!enemyStealthed(e)) return;
|
||||
const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius + ALLY_COLLIDER_RADIUS).filter(e.profile.canTarget));
|
||||
if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS);
|
||||
},
|
||||
}];
|
||||
|
||||
@@ -74,6 +74,11 @@ export default {
|
||||
`../shared/tierN.js` are usable too, but same-named helpers are not interchangeable: `num` of tier 1 / 4 also reads
|
||||
numeric strings, those of tiers 3 / 5 / 6 do not; `toggleBuff`, `talentBb`, `moduleBb`, `instantKind` take different
|
||||
arguments per tier. Read the helper before using it.
|
||||
- **Not in a kit**: a module's trait line 「攻击范围内存在N名及以上敌人时攻击速度+X」 (REA-Y: 圣约送葬人, 隐德来希) is applied
|
||||
to every operator by the engine (`../traitMods.js`, called from `battle/players.js` `_setupUnit` after `kit.install`);
|
||||
a kit adding it too would count it twice. The other conditional attack-speed lines of modules stay in their kits —
|
||||
traitMods.js lists them and refuses them, and `test/sim/trait_attack_speed.test.js` scans every chess, 补位 and 自选
|
||||
loadout so a new record with an unknown shape is noticed.
|
||||
- **The battle API** documented in docs/SIM.md: units (§2), buffs / mods / statuses (§3), the damage & heal pipeline
|
||||
(§4), the hook bus and its re-entrancy rule (§5), the engine helpers content must use (§6), the skill runtime (§7.1);
|
||||
engine modules by relative path (`../../../constants.js`, `targeting.js`, `body.js` for huge enemies, `dir.js` for
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
AURA, num, tbb, grid, nationOf, enemiesOnRange, targetsInRange, whileDeployed, reveal, skillActive, isSel, alt,
|
||||
instantKind, withDefaults,
|
||||
} from '../shared/tier4.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
/** Is module `id` the active one? (elites; `'none'` / normal chess ⇒ false) */
|
||||
const moduleIs = (def, id) => !!(def?.raw?.module?.active && def.raw.module.id === id);
|
||||
@@ -31,7 +30,9 @@ export default withDefaults({
|
||||
return {
|
||||
skills: alt(def, {
|
||||
// 铳骑主考官: the next attack is a triple hit of atk_scale each and reloads `charge` bullet(s) into ONE other
|
||||
// 拉特兰 operator around him (8 tiles, [ASSUMED] nearest first) whose ammo skill runs
|
||||
// 拉特兰 operator around him whose ammo skill runs — PRTS 备注 「补弹范围为周围8格,仅选择可补弹的干员为目标,优先选择最后
|
||||
// 部署的干员」: the latest deployment first (battle-wide deploySeq: a redeploy is a new deployment). Until 0.2.2 the
|
||||
// nearest first [ASSUMED] (GitHub #325, PR #329)
|
||||
skchr_rmixer_1: () => ({
|
||||
kind: instantKind(def),
|
||||
attack: {
|
||||
@@ -40,7 +41,7 @@ export default withDefaults({
|
||||
const n = num(bb.charge, 1);
|
||||
const cand = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId)
|
||||
.filter((a) => a !== unit && a.kind === 'op' && nationOf(a) === 'laterano' && a.skill && a.skill.active && a.skill.kind === 'ammo')
|
||||
.sort((a, b) => hypot(a.x - unit.x, a.y - unit.y) - hypot(b.x - unit.x, b.y - unit.y) || a.id - b.id);
|
||||
.sort((a, b) => b.deploySeq - a.deploySeq || a.id - b.id);
|
||||
if (!cand[0] || !(n > 0)) return;
|
||||
cand[0].skill.addAmmo(n);
|
||||
battle.fx('reload', { x: cand[0].x, y: cand[0].y, id: cand[0].id, n });
|
||||
|
||||
@@ -12,14 +12,17 @@ const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', '
|
||||
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId);
|
||||
|
||||
export default withDefaults({
|
||||
// ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado: slow, 85 % arts pulses + pull, final pull
|
||||
// ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado (r 1.5): slow, 85 % arts pulses + pull,
|
||||
// final pull (the 捕网, r 1)
|
||||
// S1 缺水的大洋裂断 (charges: next attack pulls the target to her front, 150 %/180 %); S2 缺水的掌握怒海 (BAT +0.5 s,
|
||||
// wider range, ≤ 2 targets — blocked first — at 135 %/150 % and pulled to her front); module HOK-Y (淡金坠饰):
|
||||
// a pull towards herself of an enemy farther than 2.5 tiles is one force level stronger
|
||||
chess_char_4_12_a: (bb, chess, def) => {
|
||||
const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def);
|
||||
const tb = def.traitBb || {};
|
||||
const R = 1.5; // [ASSUMED] tornado radius (no blackboard key)
|
||||
// S3 radii (no blackboard key) — PRTS 备注: 「龙卷风半径1.5」 (slow, pulses) and 「技能结束时的拖拽为捕网,捕网半径为1,无伤害」
|
||||
// (the skill-end pull; until 0.2.2 it reused the tornado's 1.5 — GitHub #324, PR #329)
|
||||
const R = 1.5, NET_R = 1;
|
||||
const force = num(bb.force, num(bb['attack@force'], 0));
|
||||
const iv = Math.max(0.1, num(bb.interval, 1.5));
|
||||
const g = grid(def.skill?.rangeGrid);
|
||||
@@ -76,7 +79,7 @@ export default withDefaults({
|
||||
const T = unit.mem.tornado;
|
||||
unit.mem.tornado = null;
|
||||
if (!T || reason === 'death' || !unit.alive) return;
|
||||
for (const e of battle.foesInRadius(T.x, T.y, R)) pullSelf(battle, unit, e, force);
|
||||
for (const e of battle.foesInRadius(T.x, T.y, NET_R)) pullSelf(battle, unit, e, force); // the 捕网
|
||||
battle.fx('pull', { x: T.x, y: T.y, id: unit.id });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md.
|
||||
|
||||
import {
|
||||
num, on, talent, batPct, mods, inFaction, isOp, selectedId, lazySkills, skillRange, crowdAspd,
|
||||
num, on, talent, batPct, mods, inFaction, isOp, selectedId, lazySkills, skillRange,
|
||||
} from '../shared/tier5.js';
|
||||
|
||||
const isLaterano = (u) => inFaction(u, 'lateranoShip', ['laterano']);
|
||||
@@ -100,7 +100,9 @@ export default {
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
crowdAspd(battle, unit, 'excu2:module', num(tm.attack_speed), num(tm['trigger_cnt[equip]']));
|
||||
// REA-Y "攻击范围内存在2名及以上敌人时攻击速度+12": the ENGINE owns this trait line now
|
||||
// (server/sim/content/traitMods.js, applied from battle/players.js _setupUnit for every operator) — a second
|
||||
// implementation here would count the same 12 points twice.
|
||||
if (sid === 'skchr_excu2_3') { // S3: +attack@atk ATK per bullet spent
|
||||
battle.on('ammoUsed', (c) => {
|
||||
if (c.unit !== unit || !unit.skill?.active) return;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { mitigate } from '../../../damage.js';
|
||||
import {
|
||||
RING1, num, on, talent, talentRec, traitBb, skillGrid, mods, dist, lazySkills, instantKind, crowdAspd,
|
||||
RING1, num, on, talent, talentRec, skillGrid, mods, dist, lazySkills, instantKind,
|
||||
} from '../shared/tier5.js';
|
||||
|
||||
/** 隐德来希 S3 "每次攻击对心烛至少造成35%攻击力的伤害". */
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
// for atk_scale × ATK phys every `interval` s — ground enemies only unless the carrier has taken off (PRTS 备注).
|
||||
// Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
|
||||
chess_char_5_06_a: (bb, chess, def) => {
|
||||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||||
const t0 = talent(chess, 0), t1 = talent(chess, 1);
|
||||
const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1);
|
||||
const nCandles = Math.max(1, num(bb['attack@max_target'], 3));
|
||||
const candleKey = talentRec(chess, 2)?.bbStr?.take_extra_enemy_key ?? CANDLE_KEY;
|
||||
@@ -140,7 +140,9 @@ export default {
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
crowdAspd(battle, unit, 'etlchi:module', num(tb.attack_speed), num(tb.cnt));
|
||||
// REA-Y "攻击范围内存在2名及以上敌人时攻击速度+12": the ENGINE owns this trait line now
|
||||
// (server/sim/content/traitMods.js, applied from battle/players.js _setupUnit for every operator) — a second
|
||||
// implementation here would count the same 12 points twice.
|
||||
const mine = (e) => e && e.mem && e.mem.candleOwner === unit;
|
||||
// "心烛只受隐德来希攻击的影响": other sources' element gauges never fill a candle either
|
||||
battle.on('elementHit', (c) => { if (mine(c.target) && c.source !== unit) c.dmg.cancel = true; }, { owner: unit });
|
||||
|
||||
@@ -10,7 +10,12 @@ import { powi } from '../../../detmath.js';
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// 异客 chess_char_6_05 (链术师) — S3 辉煌裂片; 机理分析; 孤卒
|
||||
|
||||
const STORM_RADIUS = 1.5; // [ASSUMED] storm zone radius (not in data)
|
||||
/** S3 雷暴区域: PRTS 备注 「以其判定中心所在格为中心生成一个x-1的雷暴区域」 — range x-1 (range_table: the diamond of radius 2,
|
||||
* 13 tiles) around the tile of the target's centre (GitHub #322, PR #329). Until 0.2.2 a 1.5-tile circle around the
|
||||
* target's exact position [ASSUMED]. The chain of each strike bounces on its own radius, inside or outside the zone. */
|
||||
const STORM_GRID = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
|
||||
/** The storm fx's ring radius (tiles): the diamond's reach — drawn only, the zone is STORM_GRID. */
|
||||
const STORM_FX_RADIUS = 2;
|
||||
|
||||
function pasngr(bb, chess, def) {
|
||||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||||
@@ -69,16 +74,18 @@ function pasngr(bb, chess, def) {
|
||||
const cands = enemiesIn(battle, unit, keys);
|
||||
if (!cands.length) return;
|
||||
const tgt = cands.reduce((a, b) => (b.hp > a.hp ? b : a));
|
||||
const cx = tgt.x, cy = tgt.y;
|
||||
// 判定中心所在格: the tile of the target's centre (the rounding of body.js posKey); fixed for the storm's 4 s
|
||||
const cx = Math.round(tgt.x), cy = Math.round(tgt.y);
|
||||
const zoneKeys = absoluteRangeKeys(STORM_GRID, cy, cx, 1, 0);
|
||||
const dur = num(bb.duration, 4), iv = Math.max(0.1, num(bb.interval, 0.5)), scale = num(bb.atk_scale, 1);
|
||||
const n = Math.max(1, Math.round(dur / iv));
|
||||
battle.fx('storm', { x: cx, y: cy, id: unit.id, duration: dur, r: STORM_RADIUS });
|
||||
battle.fx('storm', { x: cx, y: cy, id: unit.id, duration: dur, r: STORM_FX_RADIUS });
|
||||
let k = 0;
|
||||
const seq = unit.deploySeq;
|
||||
const h = battle.every(iv, () => {
|
||||
if (!live(unit) || unit.deploySeq !== seq) { h.cancel(); return; } // the storm ends when she leaves the field
|
||||
if (++k >= n) h.cancel();
|
||||
const zone = battle.foesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY));
|
||||
const zone = enemiesIn(battle, unit, zoneKeys); // every enemy she can select whose body is on the 13 tiles
|
||||
const e = battle.rng.pick(zone);
|
||||
if (e) strike(battle, unit, e, scale);
|
||||
}, { owner: unit });
|
||||
|
||||
@@ -96,13 +96,6 @@ const skillRange = (chess, def, extra = null) => {
|
||||
const g = skillGrid(chess, def);
|
||||
return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined;
|
||||
};
|
||||
/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */
|
||||
function crowdAspd(battle, unit, key, aspd, cnt) {
|
||||
if (!aspd || !(cnt > 0)) return;
|
||||
whileOn(battle, unit, 0.2, () => {
|
||||
if (battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).length >= cnt) battle.addBuff(unit, { key, duration: 0.3, mods: { aspd } });
|
||||
});
|
||||
}
|
||||
/** "攻击范围内存在元素损伤爆发的敌人时,技力自然恢复速度+X/秒" (modules PRI-Y). */
|
||||
function burstSpUp(battle, unit, key, sp) {
|
||||
if (!(sp > 0)) return;
|
||||
@@ -208,6 +201,6 @@ function enemiesInGrid(battle, unit, grid, n = 0, { ignoreStealth = false } = {}
|
||||
|
||||
export {
|
||||
HALF_HP, NEVER, AURA_IV, AURA_DUR, RING1, num, on, inRange, talent, talentRec, traitBb, skillGrid, maxCharges, batPct,
|
||||
mods, dist, inFaction, isAbyssal, isOp, selectedId, lazySkills, instantKind, skillRange, crowdAspd, burstSpUp,
|
||||
mods, dist, inFaction, isAbyssal, isOp, selectedId, lazySkills, instantKind, skillRange, burstSpUp,
|
||||
leaderOf, elementHit, whileOn, spAura, burstDamageUp, lowHpHealUp, moduleRangeUp, permBuff, enemiesInGrid,
|
||||
};
|
||||
@@ -27,7 +27,8 @@
|
||||
// 6_04 浊心斯卡蒂 S3 HP drain is non-lethal (like the geek drain); 鼓舞 = flat ATK added after the target's multipliers;
|
||||
// 海嗣 is inert (no attack) and only extends her aura (and her DEFAULT trigger); it expires after its
|
||||
// duration and is re-summoned after its redeploy time when DP ≥ its cost (auto-redeploy emulation).
|
||||
// 6_05 异客 storm zone radius 1.5 tiles (not in data); strikes chain like her trait (4 targets, trait falloff);
|
||||
// 6_05 异客 storm zone = range x-1 (the 13-tile diamond) around the tile of the target's centre (PRTS S3 备注);
|
||||
// strikes chain like her trait (4 targets, trait falloff) on their own bounce radius;
|
||||
// the storm stops when she leaves the field.
|
||||
// 6_06 佩佩 splash stun via `damaged` (isSplash) hook; module ×1.15 when ≥3 enemies in the splash area.
|
||||
// 6_07 维娜 "attack enemies blocked by allies in talent range" = those enemies' tiles are added to her range;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// server/sim/content/traitMods.js — trait (特性) modifiers the ENGINE consumes for every operator (DESIGN §5, §16).
|
||||
//
|
||||
// WHY THIS FILE EXISTS. Several modules add a line to the operator's TRAIT, and the official tables split that line in
|
||||
// two: `battle_equip_table` `overrideTraitDataBundle.candidates[].blackboard` carries the NUMBER
|
||||
// (`{key:'attack_speed', value:12}`) while the CONDITION exists only in the candidate's sentence
|
||||
// (`additionalDescription` / `overrideDescripton` → data/chess.json `trait.moduleDesc` / `trait.desc`). build-data.mjs
|
||||
// (`applyModuleTraitParts`) folds that blackboard into `trait.bb` unconditionally, so the number reached
|
||||
// `resolveProfile` (professions.js — it spreads `def.traitBb` into the profile) and stopped there: no sim file read
|
||||
// `profile.attack_speed` (every other reader of an `attack_speed` blackboard — bonds, devices, enemies — maps it to
|
||||
// `mods.aspd` itself), and a module reading 「攻击范围内存在2名及以上敌人时攻击速度+12」 therefore did nothing at all.
|
||||
//
|
||||
// THE RULE (engine-level, no operator-id whitelist). Any operator whose resolved def carries `attack_speed` on a
|
||||
// trait-level blackboard AND whose own trait text states a condition this file can evaluate gets that stat while — and
|
||||
// only while — the condition holds. The condition is read from the clause the official sentence puts BEFORE 「攻击速度」.
|
||||
//
|
||||
// THIS FILE EVALUATES EXACTLY ONE CONDITION SHAPE, on purpose:
|
||||
// 「攻击范围内存在N名及以上敌人时」 — the REA-Y module line (圣约送葬人 chess_char_5_01_b +12, 隐德来希
|
||||
// chess_char_5_06_b +12). It is the shape the engine was missing, and its state is a pure function of the battlefield
|
||||
// (the engine's own targeting), so it needs nothing from a kit.
|
||||
// A condition this file does not evaluate installs NOTHING — never a permanently-on stat. `traitAttackSpeedRule(def)`
|
||||
// reports what it found (`clause`, `value`, `condition`, `reason`) so callers and tests can see the decision.
|
||||
//
|
||||
// WHY NOT MORE — the other trait attack-speed lines already have a hand-written implementation in the owning kit, and
|
||||
// adding a generic rule for them would apply the same stat TWICE. Each is listed here with where it lives (one kit file
|
||||
// per operator since 0.2.0, `kits/ops/<chessId>-<codename>.js`):
|
||||
// 被击倒时不撤退…攻击速度+30(单次部署只触发1次) 斯卡蒂 chess_char_3_05_b (DRE-Y) → ops/chess_char_3_05-skadi.js
|
||||
// 耀骑士临光 6_17_b (DRE-Y) 同理 → ops/chess_char_6_17-nearl2.js
|
||||
// 未阻挡敌人时攻击速度+8 史尔特尔 5_07_b (AFT-X) → ops/chess_char_5_07-surtr.js
|
||||
// 未阻挡敌人时攻击速度+8 维娜·维多利亚 6_07_b (AFT-X) → ops/chess_char_6_07-siege2.js
|
||||
// 不阻挡敌人时…攻击速度+10 号角 5_08_b (FOR-Y) → ops/chess_char_5_08-horn.js
|
||||
// 能够阻挡一个敌人,生命值高于50%时攻击速度+10 山 5_17_b (FGT-Y) → ops/chess_char_5_17-f12yin.js
|
||||
// 范围内存在地面敌人时攻击速度+8 空弦 3_21_b (MAR-Y) → shared/tier3.js groundAspd
|
||||
// 能天使 3_01_b (MAR-Y) 同理 → ops/chess_char_3_01-angel.js
|
||||
// 攻击范围内存在受到元素损伤的友方单位时攻击速度+8 纯烬艾雅法拉 6_20_b (WDM-Y) → ops/chess_char_6_20-agoat2.js
|
||||
// 拥有已储存的攻击能量时,攻击速度+30 黑键 / 维伊 (自选, MSC-Y) → ops/op-ebnhlz.js / ops/op-veen.js
|
||||
// Generalising one of those means deleting the kit implementation in the same change; until then this file refuses them
|
||||
// (`condition: null`), which is also what keeps them bit-for-bit unchanged.
|
||||
//
|
||||
// Two more boundaries found by scanning every record × every module choice (the scan is pinned by
|
||||
// test/sim/trait_attack_speed.test.js):
|
||||
// * 仇白 (chess_char_6_15_b) LOR-Y states the same 「攻击范围内存在2名及以上敌人时攻击速度+12」, but its build-data
|
||||
// candidate targets a NAMED talent, so the number lands in `talents[i]` and never on a trait-level blackboard — her
|
||||
// kit reads exactly that. This file therefore does not see the line at all, which is the right outcome: applying it
|
||||
// here as well would double it.
|
||||
// * A module that ALREADY wanted its line applied here can carry the number on a hidden talent (index −1) instead
|
||||
// of the trait blackboard — 圣约送葬人 REA-Y does (`uniequip_003_excu2` `talentChanges[0].bb.attack_speed`), while
|
||||
// 隐德来希 REA-Y carries it straight on `trait.bb`. Both routes are read (see `attackSpeedSources`).
|
||||
//
|
||||
// 自选 picks (shared/diy.js) are scanned too: 黑键 / 维伊 MSC-Y 「拥有已储存的攻击能量时,攻击速度+30」 rides a hidden
|
||||
// module talent like 圣约送葬人's, and their 自选 kits own it (ops/op-ebnhlz.js, ops/op-veen.js — refused as 'kit').
|
||||
//
|
||||
// Tests: test/sim/trait_attack_speed.test.js. PR #293 by @LimitlessHPPK (0.2.2); wired from battle/players.js _setupUnit.
|
||||
|
||||
/** Finite number or `d` (blackboard values may be strings). Kept local: this file is imported by the engine itself
|
||||
* (battle/players.js), so it must not pull a kit file into the static import graph — the kits load through guarded
|
||||
* dynamic imports (content/kits/index.js) precisely so a broken kit degrades instead of breaking the server. */
|
||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
|
||||
/** Trait blackboard of a raw chess record (`chess.trait.bb`, the module's lines folded in by build-data.mjs). */
|
||||
const traitBb = (chess) => (chess && typeof chess.trait === 'object' && chess.trait ? chess.trait.bb ?? {} : {});
|
||||
|
||||
/**
|
||||
* The condition shapes this file evaluates, in match order. Each takes the clause before 「攻击速度」 and returns
|
||||
* `(battle, unit) => boolean`, or null when the clause is not that shape.
|
||||
*/
|
||||
const CONDITIONS = [
|
||||
/**
|
||||
* 「攻击范围内存在N名及以上敌人时」 (modules REA-Y). N is read from the SENTENCE and defaults to 2: the official
|
||||
* module's own blackboard does carry the same count (`trigger_cnt[equip]` / `cnt` = 2 on the two REA-Y records), but
|
||||
* the number that decides what the TEXT promises has to come from the text — a blackboard count is a kit-side
|
||||
* implementation detail (records carry `cnt` / `trigger_cnt[equip]` for other talents too). Reading it here instead
|
||||
* would make this file's decision depend on the module's internal layout.
|
||||
* Counted through the engine's own targeting — `battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)`: the unit's
|
||||
* live range keys (a skill that widens the range widens this too) and its own profile, so 隐匿 / 不可选中 / 飞行 follow
|
||||
* the operator's real attack rules. That is the call the two REA-Y kits used before this file took the line over
|
||||
* (shared/tier5.js `crowdAspd`, deleted in the same change so the stat is counted once).
|
||||
*/
|
||||
function crowdInRange(clause) {
|
||||
if (!/攻击范围内(?:存在|有)/.test(clause)) return null;
|
||||
if (!/敌人/.test(clause)) return null; // excludes 「…存在受到元素损伤的友方单位时」
|
||||
const m = clause.match(/存在\s*(\d+)\s*名/);
|
||||
const need = m ? Math.max(1, +m[1]) : 2;
|
||||
return (battle, unit) => battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).length >= need;
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Trait attack-speed conditions a hand-written kit already owns (documented above; refused here, never applied).
|
||||
* Every entry is one that a real record in data/chess.json hits — the closed list is checked against the shipped data
|
||||
* by test/sim/trait_attack_speed.test.js, so a new record that carries one of these lands in 'kit', and anything else
|
||||
* lands in 'unknown' (nothing installed either way; both are inert, the difference is only in the report).
|
||||
*/
|
||||
const KIT_OWNED = [
|
||||
/被击倒时不撤退/, // DRE-Y — 斯卡蒂 3_05_b / 耀骑士临光 6_17_b
|
||||
/未阻挡敌人时/, // AFT-X — 史尔特尔 5_07_b / 维娜·维多利亚 6_07_b
|
||||
/不阻挡敌人时/, // FOR-Y — 号角 5_08_b
|
||||
/生命值高于\s*\d+(?:\.\d+)?\s*%/, // FGT-Y — 山 5_17_b
|
||||
/存在地面敌人/, // MAR-Y — 空弦 3_21_b / 能天使 3_01_b (shared/tier3.js groundAspd)
|
||||
/受到元素损伤的友方单位/, // WDM-Y — 纯烬艾雅法拉 6_20_b
|
||||
/拥有已储存的攻击能量时/, // MSC-Y — 黑键 / 维伊 (自选 picks; ops/op-ebnhlz.js, ops/op-veen.js)
|
||||
];
|
||||
|
||||
/**
|
||||
* Lines that only ever apply in another game mode — 集成战略 (ISW modules, e.g. 水月 ISW-A `attack_speed: 50`). This
|
||||
* mode is 卫戍协议, and the rest of the repo already ignores their mode-gated halves (shared/loadoutRecord.js
|
||||
* `traitRangeExtend` skips 空弦 ISW-A for exactly this reason), so they are refused here too.
|
||||
*/
|
||||
const MODE_SPECIFIC = [/在集成战略中/];
|
||||
|
||||
/** The buff key the engine rule installs (one per operator; `refresh:'keep'` so a check never extends its duration). */
|
||||
export const TRAIT_ASPD_BUFF = 'trait:attack_speed';
|
||||
|
||||
/** Strip the official inline markup (`<@ba.vup>+12</>`, `<$ba.dt.element>元素损伤</>`) from a trait sentence. */
|
||||
export const plainText = (s) => String(s ?? '').replace(/<[^>]*>/g, '');
|
||||
|
||||
/**
|
||||
* The clause a trait sentence puts before its LAST 「攻击速度」 mention — the condition. '' when the sentence has no
|
||||
* 「攻击速度」 at all (e.g. 圣约送葬人's REA-Y record, whose own trait sentence is about the reaper self-heal), or when
|
||||
* the attack-speed part comes first and is unconditional (「攻击速度+18,攻击力+3%」 → clause '', since nothing
|
||||
* precedes it).
|
||||
*/
|
||||
export function conditionClause(text) {
|
||||
const s = plainText(text);
|
||||
const i = s.lastIndexOf('攻击速度');
|
||||
return i <= 0 ? '' : s.slice(0, i).trim();
|
||||
}
|
||||
|
||||
/** The trait/statement texts of `def` a condition may be read from, most specific (the module's added line) first. */
|
||||
function traitClauses(def) {
|
||||
const raw = def?.raw ?? {};
|
||||
const t = raw.trait && typeof raw.trait === 'object' ? raw.trait : {};
|
||||
const out = [];
|
||||
for (const s of [t.moduleDescRaw, t.moduleDesc, t.descRaw, t.desc, typeof raw.trait === 'string' ? raw.trait : null]) {
|
||||
const p = plainText(s).trim();
|
||||
if (p && !out.includes(p)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The blackboards of `def` a trait-level attack-speed line may live on, most authoritative first: the trait blackboard
|
||||
* (the module line build-data folds in — 隐德来希 REA-Y), then the hidden module talents (data index −1) — the route
|
||||
* 圣约送葬人 REA-Y and the MAR-Y / WDM-Y lines above take. A line merged into a NAMED talent is deliberately not read
|
||||
* here (仇白 LOR-Y: her kit owns it).
|
||||
*/
|
||||
function attackSpeedSources(def) {
|
||||
const out = [];
|
||||
const tb = traitBb(def?.raw ?? {});
|
||||
if (Number.isFinite(tb.attack_speed)) out.push(tb);
|
||||
const talents = def?.raw?.talents;
|
||||
if (Array.isArray(talents)) {
|
||||
for (const t of talents) {
|
||||
if (!t || t.index !== -1 || !t.bb || !Number.isFinite(t.bb.attack_speed)) continue;
|
||||
if (!out.includes(t.bb)) out.push(t.bb);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trait attack-speed line of `def` as the engine will apply it.
|
||||
* @param {object} def normalised unit def (sim/simdata.js)
|
||||
* @returns {{value:number, condition:((battle:object, unit:object)=>boolean)|null, clause:string,
|
||||
* source:string|null, reason:string}|null}
|
||||
* null = this def has no trait attack-speed line at all.
|
||||
* `condition !== null` = the engine applies `value` while it holds.
|
||||
* `condition === null` = nothing is installed; `reason` says why: 'kit' (a hand-written kit owns the condition),
|
||||
* 'mode' (the line only applies in 集成战略), 'unknown' (a clause shape this file does not know), 'no-text' (the data
|
||||
* build dropped the sentence).
|
||||
*/
|
||||
export function traitAttackSpeedRule(def) {
|
||||
const sources = attackSpeedSources(def);
|
||||
if (!sources.length) return null;
|
||||
const value = num(sources[0].attack_speed);
|
||||
if (!value) return null;
|
||||
for (const text of traitClauses(def)) {
|
||||
const clause = conditionClause(text);
|
||||
if (!clause) continue;
|
||||
for (const match of CONDITIONS) {
|
||||
const condition = match(clause);
|
||||
if (condition) return { value, condition, clause, source: text, reason: 'ok' };
|
||||
}
|
||||
if (MODE_SPECIFIC.some((re) => re.test(clause))) return { value, condition: null, clause, source: text, reason: 'mode' };
|
||||
if (KIT_OWNED.some((re) => re.test(clause))) return { value, condition: null, clause, source: text, reason: 'kit' };
|
||||
return { value, condition: null, clause, source: text, reason: 'unknown' };
|
||||
}
|
||||
return { value, condition: null, clause: '', source: null, reason: 'no-text' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a def's trait attack-speed line to an operator: the stat is on exactly while its condition holds. Called once
|
||||
* per operator from `BattlePlayers._setupUnit` (after `kit.install`). Evaluated on the engine's `tick` hook (one step =
|
||||
* constants.TICK) so entering / leaving the condition changes the real attack cadence — ai.js `updateAlly` sets
|
||||
* `u.atkCd = u.s.interval`, and `unit.s.interval = bat × 100 / aspd` (units.js stat aggregation). The buff is `refresh:'keep'` — a
|
||||
* re-check can never extend or blank the stat, and the engine drops non-`persist` buffs on death, while the
|
||||
* `deploy` / `battleStart` checks put it back on the next deployment.
|
||||
* @param {object} battle
|
||||
* @param {object} unit
|
||||
* @returns {object|null} the rule it resolved (`condition: null` when it installed nothing), or null without a line
|
||||
*/
|
||||
export function installTraitAttackSpeed(battle, unit) {
|
||||
if (!unit || unit.kind !== 'op' || unit._traitAspdInstalled) return null;
|
||||
let rule = null;
|
||||
try { rule = traitAttackSpeedRule(unit.def); } catch { rule = null; }
|
||||
if (!rule || !rule.condition) return rule;
|
||||
unit._traitAspdInstalled = true;
|
||||
const mods = { aspd: rule.value };
|
||||
const check = () => {
|
||||
const want = !!unit.alive && !!unit.deployed && rule.condition(battle, unit);
|
||||
const has = !!unit.findBuff(TRAIT_ASPD_BUFF);
|
||||
if (want && !has) battle.addBuff(unit, { key: TRAIT_ASPD_BUFF, mods, refresh: 'keep', tags: ['trait'] });
|
||||
else if (!want && has) battle.removeBuff(unit, TRAIT_ASPD_BUFF);
|
||||
};
|
||||
battle.on('tick', check, { owner: unit });
|
||||
battle.on('battleStart', check, { owner: unit });
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) check(); }, { owner: unit });
|
||||
return rule;
|
||||
}
|
||||
+17
-2
@@ -108,6 +108,7 @@ export class SkillRuntime {
|
||||
// the official skill strategies automate the manual 开启: only MANUAL skills wait for the operation cooldown
|
||||
this.manual = String(d.skillType ?? 'MANUAL').toUpperCase() === 'MANUAL';
|
||||
this.opReadyAt = -Infinity; // no automatic cast before this battle time (AUTO_OP_COOLDOWN)
|
||||
this.nextCastAt = -Infinity; // instant / charges with no attack of its own: no cast from the tick before (GitHub #298)
|
||||
if (this.kind === 'passive') this.baseSpCost = 0;
|
||||
this._spCostMul = 1; // content may set spCostMul (e.g. 绝技 ×0.7) — see the accessor below
|
||||
this.sp = 0;
|
||||
@@ -269,6 +270,7 @@ export class SkillRuntime {
|
||||
this._trigKeys = null;
|
||||
this._trigSet = null;
|
||||
this.opReadyAt = -Infinity; // (Battle._deploy starts the operation cooldown of the battle-start deployment)
|
||||
this.nextCastAt = -Infinity;
|
||||
if (this.noSkill) return;
|
||||
if (this.kind === 'passive') {
|
||||
this._startPassive();
|
||||
@@ -389,6 +391,9 @@ export class SkillRuntime {
|
||||
if (this.active && this.isTimed) return;
|
||||
// a cast "next attack" still waits for its attack: another charge now would be spent on the same attack
|
||||
if (this.pending || this._opCooling()) return;
|
||||
// an instant / charge skill with no attack of its own is cast at most once per attack interval of its unit
|
||||
// (activate sets nextCastAt; GitHub #298): the casts checked here wait for it — the unit's attacks are not touched
|
||||
if (!this.isTimed && !this.spec.attack && this.battle.time < this.nextCastAt - 1e-9) return;
|
||||
if (TICK_RULES.has(this.rule)) {
|
||||
if (this._tickRuleSatisfied()) this.activate(this.rule);
|
||||
} else if (this.rule !== 'TAKE_DAMAGE' && this.rule !== 'NEVER') {
|
||||
@@ -493,7 +498,8 @@ export class SkillRuntime {
|
||||
if (this.pending || this._opCooling()) return false;
|
||||
if (!this._defaultCondition()) return false;
|
||||
if (this.triggerAllies && !this._allyTriggerSatisfied()) return false;
|
||||
return this.activate('DEFAULT');
|
||||
this._beforeAttack = true; // the attack follows this cast in the same check (ai.js updateAlly): attacks pace it
|
||||
try { return this.activate('DEFAULT'); } finally { this._beforeAttack = false; }
|
||||
}
|
||||
|
||||
/** TAKE_DAMAGE trigger + INCREASE_WHEN_TAKEN_DAMAGE SP. */
|
||||
@@ -541,7 +547,16 @@ export class SkillRuntime {
|
||||
// bullets added in skillStart (拉特兰's ×(1.05 + 0.015 × layers), 逃犯引渡手续, talents): the bar's full mark
|
||||
// (community report #35: the extra bullets sat above a full bar until fewer than the base count were left)
|
||||
if (this.active && this.ammoLeft > this.ammoMax) this.ammoMax = this.ammoLeft;
|
||||
if (!this.isTimed && !this.pending) this.end('instant');
|
||||
if (!this.isTimed && !this.pending) {
|
||||
this.end('instant');
|
||||
// an instant / charge skill with no attack of its own (no spec.attack: a throw, a heal, a buff, DP …) waits one
|
||||
// attack interval of its unit before it is cast again from the tick (GitHub #298: 引星棘刺 S1 度算浪波, an SP_FULL
|
||||
// cast, was recast every tick once 迅捷's refund refilled its bar at once, whatever the attack speed) — on the skill
|
||||
// only: the cast is no attack, the unit's attacks / heals keep their own rhythm. [ASSUMED] one attack interval: PRTS
|
||||
// prints no 前后摇 for such casts. A cast made right before an attack (DEFAULT) is paced by those attacks already;
|
||||
// a cast onEnd opened again owns its own gap.
|
||||
if (!this.active && !this.spec.attack && !this._beforeAttack && u.alive && u.s.interval > 0) this.nextCastAt = b.time + u.s.interval;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,12 @@ const PRIORITY_FNS = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort candidate enemies for an attacker (in place) and return them.
|
||||
* Sort candidate enemies for an attacker (in place) and return them: the enemies it blocks first (the user's rule after
|
||||
* playtest #6, "阻挡了就一定要能打到", for every blocker — DESIGN §20.3), then the special priority, taunt, the remaining
|
||||
* route, the spawn order. One exception, the owner's decision of 2026-10-08 (GitHub #220 by TsangAsuna, #205): the 速射手 air priority
|
||||
* (trait 优先攻击空中单位 — priority 'fly', a skill's too) comes before its own blocked enemy, so a 速狙 on a melee tile
|
||||
* blocking a ground enemy shoots the flyer in its range (PRTS 索敌的概念 「阻挡(近战限定)→特殊优先级→…」); every other
|
||||
* priority keeps the blocked enemy first.
|
||||
* @param {object} attacker ally unit
|
||||
* @param {object[]} cands enemies
|
||||
* @param {string|null} priority profile/skill priority key
|
||||
@@ -201,6 +206,7 @@ const PRIORITY_FNS = {
|
||||
export function sortEnemyTargets(battle, attacker, cands, priority) {
|
||||
if (cands.length <= 1) return cands;
|
||||
const pf = priority ? (PRIORITY_FNS[priority] || (priority === 'nearest' || priority === 'farthest' ? null : null)) : null;
|
||||
const airFirst = priority === 'fly';
|
||||
const ax = attacker.x, ay = attacker.y;
|
||||
const keyed = cands.map((e) => ({
|
||||
e,
|
||||
@@ -210,7 +216,7 @@ export function sortEnemyTargets(battle, attacker, cands, priority) {
|
||||
d: battle.remainingDistance(e),
|
||||
s: e.spawnSeq,
|
||||
}));
|
||||
keyed.sort((a, b) => a.b - b.b || a.p - b.p || a.t - b.t || a.d - b.d || a.s - b.s);
|
||||
keyed.sort((a, b) => (airFirst ? a.p - b.p : 0) || a.b - b.b || a.p - b.p || a.t - b.t || a.d - b.d || a.s - b.s);
|
||||
for (let i = 0; i < keyed.length; i++) cands[i] = keyed[i].e;
|
||||
return cands;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user