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).
This commit is contained in:
1 parent
3356b340a7
commit
5a50f7b5fa
8 files changed
+658
-14
No files matched your search
@@ -93,7 +93,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
| `server/sim/content/enemies/` | the enemy kits by special type (`invisible.js`, `times.js`, `element.js`, `dot.js`, `reflection.js`, `fly.js`, `special.js`) and `leaders.js`; `server/sim/content/enemies.js` dispatches them, `server/sim/content/bosses.js` scripts the leaders |
|
||||
| `server/sim/content/garrisons/`, `items/`, `bands/` | 特质, equipment and strategies: `battle.js` is the battle side, `meta.js` the prep side (`registerMeta`, META §2) |
|
||||
| `server/sim/content/bonds/` | the 23 bonds: `core.js` the 8 core bonds (both sides), `server/sim/content/bonds/addon/` the 15 add-on bonds (`battle.js`, `meta.js`) |
|
||||
| `server/sim/content/` (the rest) | `tokens.js` (summons), `devices.js` (terrain and stage devices), `generic.js` (the kit built from a skill's data when a chess has none), `choices.js` (机变 cards in battle) |
|
||||
| `server/sim/content/` (the rest) | `tokens.js` (summons), `devices.js` (terrain and stage devices), `generic.js` (the kit built from a skill's data when a chess has none), `choices.js` (机变 cards in battle), `traitMods.js` (the module trait line the engine applies to every operator: 「攻击范围内存在N名及以上敌人时攻击速度+X」) |
|
||||
| `shared/` | imported by the server and the browser: `protocol.js`, `constants.js`, `i18n.js`, `i18nData.js` and `i18nPacks.js` (languages), `packs.js` (the content-pack format), `standIn.js` (补位), `diy.js` (自选编队), `highGround.js`, `loadoutRecord.js` |
|
||||
|
||||
### Client (`public/`)
|
||||
|
||||
@@ -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 {
|
||||
@@ -217,6 +218,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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// test/sim/trait_attack_speed.test.js — the ENGINE-level consumption of a trait's attack-speed rider
|
||||
// (server/sim/content/traitMods.js, wired from server/sim/battle/players.js `_setupUnit` after `kit.install`; PR #293 by
|
||||
// @LimitlessHPPK).
|
||||
//
|
||||
// Background (the def shape is docs/SIM.md `traitBb`; the profile merge is professions.js resolveProfile): build-data.mjs folds
|
||||
// a module's trait blackboard into `trait.bb` unconditionally, so `{key:'attack_speed', value:12}` reached
|
||||
// `resolveProfile` and stopped there — nothing read it, because the CONDITION only exists in the module sentence
|
||||
// (「攻击范围内存在2名及以上敌人时攻击速度+12」). The line was therefore inert as DATA, and the two operators that
|
||||
// carry it implemented it by hand in their kits (shared/tier5.js `crowdAspd`, deleted by this change): the engine now
|
||||
// applies the same line for everyone.
|
||||
//
|
||||
// The tests below pin the four things that make this a fix rather than a special case:
|
||||
// 1. the two REA-Y beneficiaries get +12 ASPD POINTS, and only while the engine's own targeting sees the required
|
||||
// enemies — no bonus with 1 enemy in range, a measurably shorter attack interval from 2, back to base when the
|
||||
// condition is left, and back on when it holds again;
|
||||
// 2. the SAME engine rule serves both, although their numbers live on DIFFERENT blackboards (圣约送葬人 REA-Y:
|
||||
// a hidden module talent, index −1; 隐德来希 REA-Y: `trait.bb` folded in by build-data) — there is no operator-id
|
||||
// whitelist anywhere in the path;
|
||||
// 3. operators with no such line are untouched;
|
||||
// 4. the trait attack-speed lines a hand-written kit already implemented are neither double-counted nor changed
|
||||
// (史尔特尔 / 维娜·维多利亚 / 山 / 空弦 / 斯卡蒂), and the whole shipped data set is closed against silent gaps.
|
||||
//
|
||||
// ASPD is a POINT score, not a percentage: `aspd = clamp(base + Σaspd, 20, 600)` and
|
||||
// `interval = bat × (1 + ΣbatPct) × 100 / aspd` (the units.js header), so +12 means 100 → 112 (interval × 100/112), which is
|
||||
// how every existing module / item / enemy implementation reads `attack_speed` too.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { TICK } from '../../server/sim/constants.js';
|
||||
import { traitAttackSpeedRule, installTraitAttackSpeed, conditionClause, plainText, TRAIT_ASPD_BUFF } from '../../server/sim/content/traitMods.js';
|
||||
import { diySlotIds, diySlot, diyPool, isDiyModule } from '../../shared/diy.js';
|
||||
import { unitForm } from '../../shared/standIn.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const HOOKS = ['attack', 'damaged', 'deploy', 'skillStart', 'skillEnd', 'tick'];
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||||
const DEFS = { enemies: { enemy_dummy: dummy('enemy_dummy') } };
|
||||
/** Battle + one tick (t = 0 spawns exist afterwards). `autoFinish:false`: the battles below outlive their enemies. */
|
||||
const run = (o) => makeBattle({ seed: 7, autoFinish: false, timeLimit: 400, hooks: HOOKS, captureNoisy: true, ...o }).step();
|
||||
/** No skill casts while a window is measured (a silence buff, as the other kit tests do). */
|
||||
const mute = (h, u) => h.b.addBuff(u, { key: 'test:mute', flags: { silence: true } });
|
||||
const approx = (a, b, msg = '', rel = 1e-9) => assert.ok(Math.abs(a - b) <= rel * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||
const median = (a) => { const s = [...a].sort((x, y) => x - y); return s[s.length >> 1]; };
|
||||
/** The enemies the ENGINE counts for `u` (the call traitMods.js itself makes: live range keys + its own profile). */
|
||||
const inRange = (h, u) => h.b.enemiesInKeys(u.rangeKeys, u, u.profile);
|
||||
/** The cadence the engine can actually produce: `atkCd` counts down in whole ticks, so a gap is ⌈interval/TICK⌉ ticks. */
|
||||
const cadence = (interval) => Math.ceil(interval / TICK - 1e-9) * TICK;
|
||||
/** Run `seconds` and return the gaps (s) between `u`'s consecutive ordinary attacks inside that window. */
|
||||
function attackGaps(h, u, seconds) {
|
||||
const t0 = h.b.time;
|
||||
h.run(seconds);
|
||||
const ts = h.hooksOf('attack').filter((c) => c.attacker === u && !c.isSkill && c.t >= t0 - 1e-9).map((c) => c.t);
|
||||
const gaps = [];
|
||||
for (let i = 1; i < ts.length; i++) gaps.push(ts[i] - ts[i - 1]);
|
||||
return gaps;
|
||||
}
|
||||
/** `chessId` on the board with `moduleId`, 1 dummy in range (the placement every REA-Y module test uses). */
|
||||
function board(id, o = {}) {
|
||||
return run({ defs: DEFS, units: [{ chessId: id, row: 10, col: 4, ...o }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] });
|
||||
}
|
||||
/** Spawn dummies on the candidate tiles (skipping occupied ones) until `n` of them stand in `u`'s own range. */
|
||||
function spawnInRange(h, u, n, tiles = [[10, 5], [11, 5], [9, 5], [9, 4], [8, 5]]) {
|
||||
const occupied = new Set(h.enemies().map((e) => `${Math.round(e.y)},${Math.round(e.x)}`));
|
||||
const out = [];
|
||||
for (const pos of tiles) {
|
||||
if (inRange(h, u).length >= n) break;
|
||||
if (occupied.has(pos.join(','))) continue;
|
||||
out.push(h.spawn('enemy_dummy', { pos }));
|
||||
occupied.add(pos.join(','));
|
||||
h.step();
|
||||
}
|
||||
assert.ok(inRange(h, u).length >= n, `${u.defId}: ${n} enemies stand in its range (${inRange(h, u).length})`);
|
||||
return out;
|
||||
}
|
||||
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||||
|
||||
/** The two operators the engine serves; `legacy` = the buff key their deleted hand-written copy used. */
|
||||
const REA_Y = [
|
||||
// 圣约送葬人 chess_char_5_01_b: the module's number rides a HIDDEN module talent (`talentChanges[0].bb.attack_speed`)
|
||||
{ id: 'chess_char_5_01_b', moduleId: 'uniequip_003_excu2', legacy: 'excu2:module', base: 100 },
|
||||
// 隐德来希 chess_char_5_06_b: build-data folds it straight into `trait.bb`
|
||||
{ id: 'chess_char_5_06_b', moduleId: 'uniequip_003_etlchi', legacy: 'etlchi:module', base: 105 },
|
||||
];
|
||||
|
||||
// =================================================================================================================
|
||||
// 1. the two REA-Y beneficiaries: entered, left and re-entered, with the cadence measured
|
||||
|
||||
test('圣约送葬人 / 隐德来希 REA-Y: 1 名敌人 → 正好 base,2 名 → 正好 +12 点(不是 +24)且实测攻击间隔按 ASPD 比缩短,击杀后恢复,重新进范围再加回', () => {
|
||||
for (const { id, moduleId, legacy, base } of REA_Y) {
|
||||
const h = board(id, { moduleId });
|
||||
const u = h.unit(id);
|
||||
const rule = traitAttackSpeedRule(u.def);
|
||||
assert.equal(rule.value, 12, `${id}: the REA-Y line is +12`);
|
||||
assert.equal(rule.reason, 'ok', `${id}: the engine owns it`);
|
||||
assert.equal(rule.clause, '攻击范围内存在2名及以上敌人时', `${id}: read from the module sentence`);
|
||||
assert.equal(u.base.aspd, base, `${id}: base ASPD`);
|
||||
mute(h, u); // a skill that widened the range would move the measured condition under our feet
|
||||
|
||||
// --- one enemy in range: the 「存在2名及以上敌人」 clause does not hold, so nothing is installed
|
||||
h.run(1);
|
||||
assert.equal(inRange(h, u).length, 1, `${id}: exactly one enemy stands in its range`);
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF), null, `${id}: 1 enemy → the module line is NOT installed`);
|
||||
assert.equal(u.findBuff(legacy), null, `${id}: the kit copy of the line is gone (${legacy})`);
|
||||
assert.equal(u.s.aspd, u.base.aspd, `${id}: 1 enemy → exactly the base (NOT base + 12: no kit + engine double count)`);
|
||||
approx(u.s.interval, u.base.bat * 100 / u.base.aspd, `${id}: interval = bat × 100 / aspd`);
|
||||
const g1 = attackGaps(h, u, 8);
|
||||
assert.ok(g1.length >= 4, `${id}: enough attacks to measure (${g1.length})`);
|
||||
approx(median(g1), cadence(u.s.interval), `${id}: baseline cadence matches the interval`, 0.02);
|
||||
|
||||
// --- a second enemy walks in: the stat is on within one tick
|
||||
const interval0 = u.s.interval;
|
||||
const second = h.spawn('enemy_dummy', { pos: [11, 5] });
|
||||
h.step();
|
||||
assert.equal(inRange(h, u).length, 2, `${id}: the second enemy is in range (the engine's own targeting)`);
|
||||
assert.equal(u.s.aspd, u.base.aspd + 12, `${id}: 2 enemies → +12 ASPD POINTS`);
|
||||
assert.notEqual(u.s.aspd, u.base.aspd + 24, `${id}: the engine rule only — the hand-written copy is deleted`);
|
||||
approx(u.s.interval, u.base.bat * 100 / (u.base.aspd + 12), `${id}: the shortened interval`);
|
||||
assert.ok(u.s.interval < interval0 - 1e-9, `${id}: the attack interval really shrank`);
|
||||
const buff = u.findBuff(TRAIT_ASPD_BUFF);
|
||||
assert.ok(buff, `${id}: the engine rule installed the buff`);
|
||||
assert.equal(buff.mods.aspd, 12, `${id}: 12 points`);
|
||||
const g2 = attackGaps(h, u, 8);
|
||||
approx(median(g2), cadence(u.s.interval), `${id}: buffed cadence matches the shortened interval`, 0.02);
|
||||
assert.ok(median(g2) < median(g1) - 0.02, `${id}: measured gaps shrank (${median(g2).toFixed(4)} < ${median(g1).toFixed(4)})`);
|
||||
approx(median(g1) / median(g2), (u.base.aspd + 12) / u.base.aspd, `${id}: the measured cadence ratio is the ASPD ratio`, 0.03);
|
||||
|
||||
// --- the condition is left: the stat goes away again
|
||||
h.b.dealDamage(null, second, { type: 'true', amount: 1e9 });
|
||||
h.step();
|
||||
assert.equal(second.alive, false, `${id}: the second enemy is dead`);
|
||||
assert.equal(u.s.aspd, u.base.aspd, `${id}: back to the baseline`);
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF), null, `${id}: buff removed`);
|
||||
const g3 = attackGaps(h, u, 8);
|
||||
approx(median(g3), cadence(u.s.interval), `${id}: cadence back to the baseline interval`, 0.02);
|
||||
approx(median(g3), median(g1), `${id}: and back to the measured baseline cadence`, 0.03);
|
||||
|
||||
// --- and it comes back on a new enemy
|
||||
h.spawn('enemy_dummy', { pos: [11, 5] });
|
||||
h.step();
|
||||
assert.equal(u.s.aspd, u.base.aspd + 12, `${id}: re-entering the condition re-applies it`);
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF)?.mods.aspd, 12, `${id}: still 12 points`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('两名受益者的取值来源不同(隐藏天赋 / traitBb),引擎两条路都读;默认模组都没有这条词条', () => {
|
||||
// 圣约送葬人 REA-Y: `talentChanges[0]` targets the hidden talent, so the number is NOT on the trait blackboard
|
||||
const excu2 = ds.getChess('chess_char_5_01_b', { moduleId: 'uniequip_003_excu2' });
|
||||
const hidden = (excu2.raw.talents ?? []).filter((t) => t.index === -1);
|
||||
assert.equal(excu2.raw.trait.bb.attack_speed, undefined, '圣约送葬人: the number is not on trait.bb');
|
||||
assert.equal(hidden.length, 1, '圣约送葬人: …it is on the hidden module talent (index −1)');
|
||||
assert.equal(hidden[0].bb.attack_speed, 12, '圣约送葬人: +12 there');
|
||||
const r1 = traitAttackSpeedRule(excu2);
|
||||
assert.equal(r1.value, 12);
|
||||
assert.equal(r1.reason, 'ok');
|
||||
assert.equal(r1.clause, '攻击范围内存在2名及以上敌人时');
|
||||
assert.equal(plainText(r1.source), '攻击范围内存在2名及以上敌人时攻击速度+12');
|
||||
|
||||
// 隐德来希 REA-Y: build-data folded the line into trait.bb, and she has no hidden module talent at all
|
||||
const etlchi = ds.getChess('chess_char_5_06_b', { moduleId: 'uniequip_003_etlchi' });
|
||||
assert.equal(etlchi.raw.trait.bb.attack_speed, 12, '隐德来希: the number is on trait.bb');
|
||||
assert.deepEqual((etlchi.raw.talents ?? []).filter((t) => t.index === -1), [], '隐德来希: no hidden module talent');
|
||||
const r2 = traitAttackSpeedRule(etlchi);
|
||||
assert.equal(r2.value, 12);
|
||||
assert.equal(r2.reason, 'ok');
|
||||
assert.equal(r2.clause, '攻击范围内存在2名及以上敌人时');
|
||||
assert.equal(plainText(r2.source), '攻击范围内存在2名及以上敌人时攻击速度+12');
|
||||
|
||||
// the module has to be EQUIPPED: without it neither carries the line (no permanent rider on the default loadout)
|
||||
assert.equal(traitAttackSpeedRule(ds.getChess('chess_char_5_01_b')), null, '圣约送葬人 默认模组: no such line');
|
||||
assert.equal(traitAttackSpeedRule(ds.getChess('chess_char_5_06_b')), null, '隐德来希 默认模组: no such line');
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// 2. regression — operators without the line
|
||||
|
||||
test('回归: 没有该词条的干员完全不变(3 名敌人也不动)', () => {
|
||||
for (const id of ['chess_char_1_01_a', 'chess_char_5_01_a']) { // 隐现; 圣约送葬人的 NORMAL 棋子(未装备 REA-Y)
|
||||
assert.equal(traitAttackSpeedRule(ds.getChess(id)), null, `${id}: no trait attack-speed line`);
|
||||
const h = board(id);
|
||||
const u = h.unit(id);
|
||||
mute(h, u);
|
||||
h.run(0.5);
|
||||
const as0 = u.s.aspd;
|
||||
spawnInRange(h, u, 3);
|
||||
h.run(0.5);
|
||||
assert.equal(u.s.aspd, as0, `${id}: ASPD unchanged with 3 enemies in range`);
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF), null, `${id}: no engine buff`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('回归: 手写实现的那几条特性词条,引擎显式拒绝接管(防止二次加成)', () => {
|
||||
const cases = [
|
||||
['chess_char_3_05_b', null, 30, 'kit'], // DRE-Y 被击倒时不撤退 +30(kit: ops/chess_char_3_05-skadi.js)
|
||||
['chess_char_6_17_b', 'uniequip_003_nearl2', 30, 'kit'], // DRE-Y 耀骑士临光
|
||||
['chess_char_5_07_b', null, 8, 'kit'], // AFT-X 未阻挡 +8(史尔特尔)
|
||||
['chess_char_6_07_b', null, 8, 'kit'], // AFT-X 维娜·维多利亚
|
||||
['chess_char_5_08_b', 'uniequip_003_horn', 10, 'kit'], // FOR-Y 号角 不阻挡敌人时 +10
|
||||
['chess_char_5_17_b', null, 10, 'kit'], // FGT-Y 生命值高于50% +10(山)
|
||||
['chess_char_3_21_b', null, 8, 'kit'], // MAR-Y 空弦 范围内地面敌人 +8
|
||||
['chess_char_3_01_b', 'uniequip_003_angel', 8, 'kit'], // MAR-Y 能天使
|
||||
['chess_char_6_20_b', 'uniequip_003_agoat2', 8, 'kit'], // WDM-Y 纯烬艾雅法拉
|
||||
['chess_char_4_09_b', 'uniequip_004_mizuki', 50, 'mode'], // ISW-A 水月(集成战略专用,本模式不适用)
|
||||
];
|
||||
for (const [id, moduleId, value, reason] of cases) {
|
||||
const def = ds.getChess(id, moduleId ? { moduleId } : null);
|
||||
const r = traitAttackSpeedRule(def);
|
||||
assert.ok(r, `${id}: carries a trait attack-speed line`);
|
||||
assert.equal(r.value, value, `${id}: value`);
|
||||
assert.equal(r.condition, null, `${id}: the engine installs nothing`);
|
||||
assert.equal(r.reason, reason, `${id}: refused as '${reason}'`);
|
||||
assert.ok(r.clause.length > 0, `${id}: the clause it read is reported (${JSON.stringify(r.clause)})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('回归: 史尔特尔 / 维娜·维多利亚 / 山 / 空弦 / 斯卡蒂 的数值与改动前一致(各自的 kit 路径照旧)', () => {
|
||||
// 史尔特尔 AFT-X: 未阻挡敌人时 +8
|
||||
{
|
||||
const h = run({ defs: DEFS, units: [{ chessId: 'chess_char_5_07_b', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 9] }] });
|
||||
const u = h.unit('chess_char_5_07_b');
|
||||
mute(h, u);
|
||||
const as = u.def.traitBb.attack_speed;
|
||||
assert.equal(as, 8, '史尔特尔: trait.bb 上的 +8');
|
||||
h.run(1);
|
||||
assert.equal(u.blocking.length, 0, '未阻挡');
|
||||
assert.equal(u.s.aspd, u.base.aspd + as, '未阻挡: kit 的 +8');
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF), null, 'engine 未接管');
|
||||
done(h);
|
||||
}
|
||||
// 维娜·维多利亚 AFT-X: 未阻挡敌人时 +8
|
||||
{
|
||||
const h = run({ defs: DEFS, units: [{ chessId: 'chess_char_6_07_b', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 9] }] });
|
||||
const u = h.unit('chess_char_6_07_b');
|
||||
mute(h, u);
|
||||
const as = u.def.traitBb.attack_speed;
|
||||
assert.equal(as, 8, '维娜·维多利亚: trait.bb 上的 +8');
|
||||
h.run(1);
|
||||
assert.equal(u.blocking.length, 0, '未阻挡');
|
||||
assert.equal(u.s.aspd, u.base.aspd + as, '未阻挡: kit 的 +8');
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF), null, 'engine 未接管');
|
||||
done(h);
|
||||
}
|
||||
// 山 FGT-Y: 生命值高于 50% 时 +10
|
||||
{
|
||||
const h = board('chess_char_5_17_b');
|
||||
const u = h.unit('chess_char_5_17_b');
|
||||
mute(h, u);
|
||||
const as = u.def.traitBb.attack_speed;
|
||||
assert.equal(as, 10, '山: trait.bb 上的 +10');
|
||||
h.run(1);
|
||||
assert.ok(u.hpRatio > 0.5, 'full HP');
|
||||
assert.equal(u.s.aspd, u.base.aspd + as, 'HP > 50 %: kit 的 +10');
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF), null, 'engine 未接管');
|
||||
done(h);
|
||||
}
|
||||
// 空弦 MAR-Y: 范围内存在地面敌人时 +8 —— 数值在隐藏天赋上(与圣约送葬人同一个来源),kit(shared/tier3.js groundAspd)负责
|
||||
{
|
||||
const h = board('chess_char_3_21_b');
|
||||
const u = h.unit('chess_char_3_21_b');
|
||||
mute(h, u);
|
||||
const as = (u.def.raw.talents ?? []).find((t) => t.index === -1)?.bb.attack_speed;
|
||||
assert.equal(as, 8, '空弦: MAR-Y 的 +8 在隐藏天赋 (index −1) 上');
|
||||
h.run(1);
|
||||
assert.equal(u.s.aspd, u.base.aspd + as, '地面敌人在范围内: kit 的 +8');
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF), null, 'engine 未接管');
|
||||
done(h);
|
||||
}
|
||||
// 斯卡蒂 DRE-Y: 被击倒时不撤退 + 攻击速度 +30(kit 的 trait:skadi_tide)
|
||||
{
|
||||
const h = run({ defs: DEFS, units: [{ chessId: 'chess_char_3_05_b', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 9] }] });
|
||||
const u = h.unit('chess_char_3_05_b');
|
||||
mute(h, u);
|
||||
const as = u.def.traitBb.attack_speed;
|
||||
assert.equal(as, 30, '斯卡蒂: trait.bb 上的 +30');
|
||||
h.run(0.5);
|
||||
assert.equal(u.s.aspd, u.base.aspd, 'before the lethal blow');
|
||||
h.b.dealDamage(null, u, { type: 'true', amount: 1e9 });
|
||||
h.step();
|
||||
assert.equal(u.alive, true, 'DRE-Y 不撤退');
|
||||
assert.ok(u.findBuff('trait:skadi_tide'), 'kit 的复活 buff');
|
||||
assert.equal(u.s.aspd, u.base.aspd + as, '复活后 +30(只有 kit 这一份)');
|
||||
assert.equal(u.findBuff(TRAIT_ASPD_BUFF), null, 'engine 未接管');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
// =================================================================================================================
|
||||
// 3. the rule itself: what it reads, and that it installs nothing when unsure
|
||||
|
||||
test('规则解析: 条件子句 + 取值被显式报出;读不出条件 / 不认识的形状 → 什么都不装', () => {
|
||||
const def = ds.getChess('chess_char_5_01_b', { moduleId: 'uniequip_003_excu2' });
|
||||
const r = traitAttackSpeedRule(def);
|
||||
assert.equal(r.value, 12);
|
||||
assert.equal(r.reason, 'ok');
|
||||
assert.equal(r.clause, '攻击范围内存在2名及以上敌人时');
|
||||
assert.equal(typeof r.condition, 'function');
|
||||
assert.equal(plainText(r.source), '攻击范围内存在2名及以上敌人时攻击速度+12');
|
||||
assert.equal(conditionClause(r.source), '攻击范围内存在2名及以上敌人时');
|
||||
|
||||
// a line whose 「攻击速度」 comes first is unconditional → no clause → nothing installed (never a permanent stat)
|
||||
const tail = { ...def, raw: { ...def.raw, trait: { desc: '攻击速度+9,攻击力+1%', bb: { attack_speed: 9 } } } };
|
||||
const rt = traitAttackSpeedRule(tail);
|
||||
assert.equal(rt.value, 9);
|
||||
assert.equal(rt.reason, 'no-text', 'no condition clause → refused');
|
||||
assert.equal(rt.clause, '');
|
||||
assert.equal(rt.source, null);
|
||||
assert.equal(rt.condition, null, 'the engine installs nothing');
|
||||
|
||||
// an unknown clause shape
|
||||
const odd = { ...def, raw: { ...def.raw, trait: { desc: '心情好的时候攻击速度+9', bb: { attack_speed: 9 } } } };
|
||||
const ro = traitAttackSpeedRule(odd);
|
||||
assert.equal(ro.value, 9);
|
||||
assert.equal(ro.reason, 'unknown');
|
||||
assert.equal(ro.condition, null, 'an unrecognised condition never becomes a permanent stat');
|
||||
|
||||
// no trait attack-speed line at all
|
||||
assert.equal(traitAttackSpeedRule(ds.getChess('chess_char_1_01_a')), null);
|
||||
assert.equal(traitAttackSpeedRule(null), null);
|
||||
// tokens are never touched
|
||||
assert.equal(installTraitAttackSpeed({}, { kind: 'token' }), null);
|
||||
});
|
||||
|
||||
test('规则解析: 仇白 LOR-Y 同文案但数值写进具名天赋 —— 引擎连这条词条都看不到(她的 kit 负责,绝不二次加成)', () => {
|
||||
const def = ds.getChess('chess_char_6_15_b', { moduleId: 'uniequip_003_qiubai' });
|
||||
assert.equal(def.raw.trait.bb.attack_speed, undefined, 'trait.bb 上没有攻速');
|
||||
assert.deepEqual((def.raw.talents ?? []).filter((t) => t.index === -1), [], '也没有隐藏天赋');
|
||||
assert.equal(def.raw.talents[0].bb.attack_speed, 12, '数值在具名天赋 入隙 (index 0) 上');
|
||||
assert.equal(def.raw.talents[0].bb.cnt, 2, '连条件计数也一起');
|
||||
assert.equal(traitAttackSpeedRule(def), null, '引擎看不到 → 由 kits/ops/chess_char_6_15-qiubai.js 消费');
|
||||
});
|
||||
|
||||
test('覆盖锁定: 全数据 266 个棋子 × 全部模组选择里,被引擎接管的只有那两条 REA-Y,其余全部显式拒绝(unknown = 0)', () => {
|
||||
const installed = [];
|
||||
const refused = [];
|
||||
const reasons = {};
|
||||
let scanned = 0;
|
||||
for (const id of ds.chessIds()) {
|
||||
const rec = ds.rawChess(id);
|
||||
if (!rec) continue;
|
||||
const choices = [null, ...(rec.modules || []).map((m) => ({ moduleId: m.uniEquipId })), { moduleId: 'none' }];
|
||||
for (const lo of choices) {
|
||||
let d;
|
||||
try { d = ds.getChess(id, lo); } catch { continue; }
|
||||
if (!d) continue;
|
||||
scanned++;
|
||||
const r = traitAttackSpeedRule(d);
|
||||
if (!r) continue;
|
||||
const tag = `${id}|${lo?.moduleId ?? 'default'}|${r.value}|${r.reason}`;
|
||||
if (r.condition) installed.push(tag);
|
||||
else { refused.push(tag); reasons[r.reason] = (reasons[r.reason] ?? 0) + 1; }
|
||||
}
|
||||
}
|
||||
assert.ok(scanned > 500, `the whole data set was scanned (${scanned} defs)`);
|
||||
assert.deepEqual(installed.sort(), [
|
||||
'chess_char_5_01_b|uniequip_003_excu2|12|ok', // 圣约送葬人 REA-Y(数值在隐藏天赋上)
|
||||
'chess_char_5_06_b|uniequip_003_etlchi|12|ok', // 隐德来希 REA-Y(数值在 trait.bb 上)
|
||||
]);
|
||||
assert.deepEqual(Object.keys(reasons).sort(), ['kit', 'mode'], 'no silent gap — every refusal carries a known reason');
|
||||
assert.equal(reasons.unknown ?? 0, 0, 'nothing falls through as unknown');
|
||||
assert.ok((reasons.kit ?? 0) >= 13, `every other carrier is refused explicitly (${reasons.kit} kit)`);
|
||||
assert.equal(reasons.mode, 1, '集成战略专用的一条(水月 ISW-A)');
|
||||
assert.ok(refused.length >= 13, `refused lines (${refused.length})`);
|
||||
for (const t of refused) {
|
||||
assert.ok(/\|(kit|mode)$/.test(t), `refused with a known reason: ${t}`);
|
||||
}
|
||||
assert.ok(refused.some((t) => t.startsWith('chess_char_3_05_b|default|30|kit')), '斯卡蒂 DRE-Y is in the list');
|
||||
assert.ok(refused.some((t) => t.startsWith('chess_char_3_21_b|default|8|kit')), '空弦 MAR-Y is in the list');
|
||||
assert.ok(refused.some((t) => t.startsWith('chess_char_4_09_b|uniequip_004_mizuki|50|mode')), '水月 ISW-A is in the list');
|
||||
});
|
||||
|
||||
test('覆盖锁定(补位 / 自选): 每个补位干员和每个自选组合(池内干员 × 两种形态 × 技能 × 可选模组)都不被引擎接管', () => {
|
||||
// 0.2.2 maintainer addition to PR #293: the battle also fields 补位 stand-ins (getChess(id, { standIn: true })) and
|
||||
// 自选 picks (getChess(slot, { diy })) — 6★ operators whose own modules carry trait attack-speed lines. None may gain
|
||||
// the engine rule: their lines are either absent or owned by their kits (黑键 / 维伊 MSC-Y, 赤刃明霄陈 AFT, 重岳 / 贝洛内
|
||||
// FGT-Y, 止颂 DRE-Y → 'kit'); a module of another game mode is not a legal pick (isDiyModule, validateDiyPicks).
|
||||
const applied = [];
|
||||
const reasons = {};
|
||||
const tally = { standIn: 0, diy: 0 };
|
||||
const see = (kind, tag, d) => {
|
||||
tally[kind]++;
|
||||
const r = traitAttackSpeedRule(d);
|
||||
if (!r) return;
|
||||
if (r.condition) applied.push(`${kind} ${tag}`);
|
||||
reasons[r.reason] = (reasons[r.reason] ?? 0) + 1;
|
||||
};
|
||||
for (const id of ds.chessIds()) {
|
||||
let d;
|
||||
try { d = ds.getChess(id, { standIn: true }); } catch { d = null; }
|
||||
if (d) see('standIn', `${id}|${d.charId}`, d);
|
||||
}
|
||||
const backups = ds.rawBackups();
|
||||
for (const slotId of diySlotIds(ds)) {
|
||||
const slot = diySlot(slotId, ds);
|
||||
for (const chessId of [slot.baseId, slot.goldenId]) {
|
||||
const status = ds.rawChess(chessId)?.status;
|
||||
for (const charId of diyPool(slot.tier, { data: ds })) {
|
||||
const form = unitForm(backups, charId, status);
|
||||
if (!form) continue;
|
||||
const skills = (form.skills ?? []).map((sk) => sk.index);
|
||||
for (const skillIndex of skills.length ? skills : [null]) {
|
||||
for (const uniEquipId of [null, ...(form.modules ?? []).filter(isDiyModule).map((m) => m.uniEquipId)]) {
|
||||
let d;
|
||||
try { d = ds.getChess(chessId, { diy: { charId, skillIndex, uniEquipId } }); } catch { d = null; }
|
||||
if (d) see('diy', `${chessId}|${charId}|${skillIndex}|${uniEquipId}`, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(tally.standIn > 100, `every stand-in was scanned (${tally.standIn})`);
|
||||
assert.ok(tally.diy > 1000, `every 自选 combination was scanned (${tally.diy})`);
|
||||
assert.deepEqual(applied, [], 'no stand-in or 自选 pick gains the engine rule');
|
||||
assert.deepEqual(Object.keys(reasons).sort(), ['kit'], 'every line they carry is a kit-owned shape (no unknown, no other mode)');
|
||||
});
|
||||
Reference in new issue
Block a user