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
@@ -66,6 +66,36 @@ test('#1 the user\'s case: ground-only operators never hit a 近地悬浮 enemy
|
||||
}
|
||||
});
|
||||
|
||||
test('#220 (the owner\'s decision of 2026-10-08): a 速射手 blocking a ground enemy shoots a flyer in its range; a 神射手 keeps hitting the enemy it blocks', () => {
|
||||
const defs = { enemies: {
|
||||
e_walker: enemyRec({ key: 'e_walker', hp: 1e8, atk: 0, def: 800, speed: 1 }),
|
||||
e_soft: enemyRec({ key: 'e_soft', hp: 1e8, atk: 0, def: 0, speed: 0 }),
|
||||
e_bat: enemyRec({ key: 'e_bat', hp: 1e8, atk: 0, def: 0, speed: 0, motion: 'FLY' }),
|
||||
} };
|
||||
const shots = (chessId, other) => {
|
||||
const h = makeBattle({ seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true, defs, units: [{ chessId, row: 10, col: 4 }] });
|
||||
h.step();
|
||||
const u = h.unit(chessId);
|
||||
h.b.addBuff(u, { key: 'test:mute', flags: { silence: true } });
|
||||
const walker = h.spawn('e_walker', { pos: [10, 7], routeIndex: 0, route: { motion: 'WALK', start: [10, 7], end: [10, 0], checkpoints: [] } });
|
||||
assert.ok(h.runUntil(() => walker.blockedBy === u, 10), `${u.name} on a melee tile blocks the walker`);
|
||||
const o = h.spawn(other, { pos: [10, 6], routeIndex: 0, route: { motion: other === 'e_bat' ? 'FLY' : 'WALK', start: [10, 6], end: [10, 6], checkpoints: [] } });
|
||||
h.step();
|
||||
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(o), `${other} in range`);
|
||||
const n0 = h.hooksOf('damaged').length;
|
||||
h.run(4);
|
||||
const hits = (t) => h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.target === t).length;
|
||||
checkInvariants(h.b);
|
||||
return { blocked: hits(walker), other: hits(o) };
|
||||
};
|
||||
const exu = shots('chess_char_3_01_a', 'e_bat'); // 能天使 (速射手: 优先攻击空中单位)
|
||||
assert.ok(exu.other > 0 && exu.blocked === 0, `速射手: the flyer over her own blocked enemy (${JSON.stringify(exu)})`);
|
||||
for (const other of ['e_soft', 'e_bat']) {
|
||||
const far = shots('chess_char_4_20_a', other); // 远牙 (神射手: 优先攻击防御力最低)
|
||||
assert.ok(far.blocked > 0 && far.other === 0, `神射手: still the enemy it blocks, not ${other} (${JSON.stringify(far)})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#1 ground-only splash spares a hovering enemy next to its ground target (迷迭香 + aftershocks)', () => {
|
||||
const h = makeBattle({ units: [{ chessId: 'chess_char_6_12_a', row: 10, col: 3 }], seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true, defs: SYN });
|
||||
h.step();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
|
||||
|
||||
function setup() {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_test: enemyRec({ key: 'enemy_test', hp: 1e6, atk: 100, def: 1000, speed: 0 }) } },
|
||||
enemies: [{ key: 'enemy_test', pos: [11, 8] }], content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
return { h, e: h.enemy('enemy_test') };
|
||||
}
|
||||
|
||||
const orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
|
||||
const approx = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-6, `${actual} ≈ ${expected}`);
|
||||
|
||||
for (const plain of [false, true]) {
|
||||
for (const order of orders) {
|
||||
test(`${plain ? 'applyStrongest' : 'fragile'}: three effects expire in strength order after application order ${order}`, () => {
|
||||
const { h, e } = setup();
|
||||
const effects = [{ value: 0.8, duration: 2 }, { value: 0.6, duration: 5 }, { value: 0.3, duration: 10 }];
|
||||
const key = plain ? 'test:strongest' : 'fragile';
|
||||
for (const i of order) {
|
||||
if (plain) h.b.applyStrongest(e, key, { ...effects[i], mods: (v) => ({ dmgTakenMul: 1 + v }) });
|
||||
else h.b.applyStatus(e, key, effects[i]);
|
||||
}
|
||||
for (const [elapsed, multiplier, damage] of [[0, 1.8, 180], [2.1, 1.6, 160], [3, 1.3, 130], [5, 1, 100]]) {
|
||||
if (elapsed) h.run(elapsed);
|
||||
approx(e.s.dmgTakenMul, multiplier);
|
||||
assert.equal(h.b.dealDamage(null, e, { amount: 100, type: 'true' }), damage);
|
||||
assert.equal(e.buffs.filter((b) => b.key === key).length, multiplier === 1 ? 0 : 1);
|
||||
}
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('strongest statuses: a weaker effect that expires under the winner never resumes', () => {
|
||||
const { h, e } = setup();
|
||||
h.b.applyStatus(e, 'fragile', { value: 0.6, duration: 3 });
|
||||
h.b.applyStatus(e, 'fragile', { value: 0.3, duration: 10 });
|
||||
h.b.applyStatus(e, 'fragile', { value: 0.8, duration: 5 });
|
||||
h.run(5.1);
|
||||
approx(e.s.dmgTakenMul, 1.3);
|
||||
h.run(5);
|
||||
assert.equal(e.findBuff('fragile'), null);
|
||||
});
|
||||
|
||||
test('strongest statuses: equal-strength refresh skips expired fallbacks but keeps the later one', () => {
|
||||
const { h, e } = setup();
|
||||
for (const [value, duration] of [[0.8, 2], [0.6, 5], [0.3, 10]]) h.b.applyStatus(e, 'fragile', { value, duration });
|
||||
h.run(1);
|
||||
h.b.applyStatus(e, 'fragile', { value: 0.8, duration: 6 });
|
||||
h.b.applyStatus(e, 'fragile', { value: 0.8, duration: 1 });
|
||||
h.run(5.1);
|
||||
approx(e.s.dmgTakenMul, 1.8);
|
||||
h.run(1);
|
||||
approx(e.s.dmgTakenMul, 1.3);
|
||||
h.run(3);
|
||||
assert.equal(e.findBuff('fragile'), null);
|
||||
});
|
||||
|
||||
test('strongest statuses: refreshing a waiting effect preserves the weaker later fallback', () => {
|
||||
const { h, e } = setup();
|
||||
for (const [value, duration] of [[0.8, 2], [0.6, 5], [0.3, 10]]) h.b.applyStatus(e, 'fragile', { value, duration });
|
||||
h.run(1);
|
||||
h.b.applyStatus(e, 'fragile', { value: 0.6, duration: 6 });
|
||||
h.run(4.1);
|
||||
approx(e.s.dmgTakenMul, 1.6);
|
||||
h.run(2);
|
||||
approx(e.s.dmgTakenMul, 1.3);
|
||||
h.run(3);
|
||||
assert.equal(e.findBuff('fragile'), null);
|
||||
});
|
||||
|
||||
test('strongest statuses: stackAs controls every fallback without changing its actual effect', () => {
|
||||
const { h, e } = setup();
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.1, stackAs: 0.9, duration: 2 });
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.2, stackAs: 0.8, duration: 5 });
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.3, duration: 10 });
|
||||
approx(e.s.atk, 90);
|
||||
h.run(2.1);
|
||||
approx(e.s.atk, 80);
|
||||
h.run(3);
|
||||
approx(e.s.atk, 70);
|
||||
h.run(5);
|
||||
approx(e.s.atk, 100);
|
||||
});
|
||||
|
||||
test('strongest statuses: equal-priority waiting effects keep the longest-lived application value', () => {
|
||||
const { h, e } = setup();
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.9, duration: 2 });
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.1, stackAs: 0.8, duration: 5 });
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.3, duration: 10 });
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.2, stackAs: 0.8, duration: 7 });
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.4, stackAs: 0.8, duration: 6 });
|
||||
h.b.applyStatus(e, 'weaken', { value: 0.5, stackAs: 0.8, duration: 7 });
|
||||
h.run(2.1);
|
||||
approx(e.s.atk, 80);
|
||||
h.run(3);
|
||||
approx(e.s.atk, 80);
|
||||
h.run(2);
|
||||
approx(e.s.atk, 70);
|
||||
h.run(3);
|
||||
approx(e.s.atk, 100);
|
||||
});
|
||||
|
||||
for (const plain of [false, true]) {
|
||||
test(`${plain ? 'removeBuff' : 'removeStatus'} discards every waiting strongest effect`, () => {
|
||||
const { h, e } = setup();
|
||||
const key = plain ? 'test:strongest' : 'fragile';
|
||||
for (const [value, duration] of [[0.8, 2], [0.6, 5], [0.3, 10]]) {
|
||||
if (plain) h.b.applyStrongest(e, key, { value, duration, mods: (v) => ({ dmgTakenMul: 1 + v }) });
|
||||
else h.b.applyStatus(e, key, { value, duration });
|
||||
}
|
||||
assert.equal(plain ? h.b.removeBuff(e, key) : h.b.removeStatus(e, key), 1);
|
||||
h.b.applyStatus(e, 'fragile', { value: 0.1, duration: 1 });
|
||||
h.run(2.1);
|
||||
approx(e.s.dmgTakenMul, 1);
|
||||
h.run(8);
|
||||
approx(e.s.dmgTakenMul, 1);
|
||||
assert.equal(e.findBuff(key), null);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
}
|
||||
@@ -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