Files
LimitlessHPPK 5a50f7b5fa feedback7 R22-GHSIM: the module trait line 「攻击范围内存在N名及以上敌人时攻击速度+X」 (REA-Y: 圣约送葬人, 隐德来希) is applied by the engine instead of the two kits — on and off on the tick the count changes; a scan of every chess, 补位 and 自选 loadout shows no other operator gains it (PR #293 by @LimitlessHPPK); golden: unchanged
The contributor's code (server/sim/content/traitMods.js, the battle/players.js _setupUnit wiring after kit.install, the
crowdAspd removal from shared/tier5.js and the two kits, test/sim/trait_attack_speed.test.js), applied from PR #293, so
they are the author of this commit. Maintainer additions: the coverage lock also scans the 补位 stand-ins (266) and every
legal 自选 combination (pool operator × slot form × skill × non-other-mode module) — none gains the rule; 黑键 / 维伊
MSC-Y 「拥有已储存的攻击能量时,攻击速度+30」 (their 自选 kits own it) is listed as kit-owned instead of 'unknown'; the
comment's history note named a commit this repository does not have, and its line-number references are now names;
docs/ARCHITECTURE.md and kits/README.md name the file.

Official: battle_equip_table uniequip_003_excu2 (display trait + hidden talent attack_speed 12, trigger_cnt[equip] 2) and
uniequip_003_etlchi (trait attack_speed 12, cnt 2), both 「攻击范围内存在2名及以上敌人时攻击速度+12」; 仇白's
uniequip_003_qiubai carries the same sentence on her named talent, which her kit keeps reading (not seen by the engine).

Before: shared/tier5.js crowdAspd polled every 0.2 s and gave a 0.3 s buff — measured +12 0.167 s after the second enemy
arrived and 0.267 s after it died (scratchpad repro); now one tick each. Golden: unchanged (no sampled snapshot moves).
2026-10-08 18:56:12 +08:00

219 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}