feedback7: merge fb7-tick — whole-frame attack intervals and time-SP costs take exactly that many frames, 1 s = 30 not 31 (PR #402 by @Cloudnyco, ported onto 0.2.2: 1e-9 on the ally / enemy attack countdown, the 双眼皮 turret and 寒檀's icicles, gainSp / _normalize; PRTS 作战机制 帧对齐); golden: every family moves (281 of 283, end-result flips in §27.61); union OK

This commit is contained in:
sganggs committed 2026-10-09 01:42:16 +08:00
commit e1b75e1308
21 files changed
+5008 -4776

No files matched your search

+2 -1
View File
@@ -421,8 +421,9 @@ test('2_16 拉普兰德 S1 日晷: toggle (持续时间无限) ATK +atk; blocks
const bb = bbAlt(id);
for (const roll of [true, false]) {
const seen = [];
// 120 hits a minute at bat 0.5, each at the 5 % floor (12.5): 1500 in the 60 s, short of the normal form's 1791 HP
const h = run({
defs: { enemies: { e: dummy('e', { atk: 300, bat: 0.5 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }],
defs: { enemies: { e: dummy('e', { atk: 250, bat: 0.5 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }],
setup: (b) => { b.rng.chance = (p) => { seen.push(p); return roll; }; },
});
const u = h.unit(id);
+7 -3
View File
@@ -78,6 +78,8 @@ test('every tier-6 skill × module choice (normal + elite) survives a real wave
units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index, moduleId, carryState: READY }, { chessId: 'chess_char_1_01_a', row: 9, col: 4 }, { chessId: 'chess_char_2_06_a', row: 11, col: 5 }],
enemies: [{ key: 'enemy_1007_slime', count: 8, interval: 1.5 }, { key: 'enemy_1007_slime', route: 1, count: 8, interval: 1.5, time: 3 }],
});
// (a medic acts on the hurt only, and the slimes may fall before they reach anyone: the team starts at half HP)
if (base.profession === 'MEDIC') { h.step(); for (const a of h.b.allyUnits) a.hp = a.s.maxHp / 2; }
h.runToEnd(90);
done(h);
const u = h.b.allyUnits.find((x) => x.defId === id);
@@ -258,7 +260,7 @@ test('6_03 余 S1 今日做东: taunt +1 while carried; TAKE_DAMAGE cast, HP/DEF
const sid = 'skchr_yu_1', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { hit: enemyRec({ key: 'hit', hp: 1e7, speed: 0, atk: 300, bat: 1 }) } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'hit', pos: [10, 4] }],
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'hit', pos: [10, 4] }], hooks: [...HOOKS, 'elementBurst'],
});
const u = h.unit(id);
usesSkill(u, sid);
@@ -270,8 +272,10 @@ test('6_03 余 S1 今日做东: taunt +1 while carried; TAKE_DAMAGE cast, HP/DEF
approx(skillBuff(u).mods.defPct, bb.def);
const t0 = h.b.time;
h.run(4);
const burns = h.hooksOf('elementHit').filter((c) => c.source === u && c.dmg.element === 'burn' && c.t > t0 && (c.dmg.tags || []).includes('skill'));
const taken = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.isAttack && c.t > t0);
// the burns build a 燃烧 burst on the attacker, which refuses same-element fills while it runs: compare up to it
const end = h.hooksOf('elementBurst').find((c) => c.element === 'burn' && c.t > t0)?.t ?? Infinity;
const burns = h.hooksOf('elementHit').filter((c) => c.source === u && c.dmg.element === 'burn' && c.t > t0 && c.t <= end && (c.dmg.tags || []).includes('skill'));
const taken = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.isAttack && c.t > t0 && c.t <= end);
assert.ok(burns.length >= 1 && burns.length === taken.length, `one burn per attack taken (${burns.length}/${taken.length})`);
approx(burns[0].dmg.amount, u.s.atk * bb.ep_damage_ratio, 'ep_damage_ratio × ATK');
done(h);
+3 -2
View File
@@ -212,8 +212,9 @@ test('S1 坚守阵线 (AUTO, SP_FULL): at full SP with no enemy, 持续时间无
const life = tokenTalent(tier, elite, mod).duration;
assert.ok(h.runUntil(() => u.skill.active, sk.spCost + 1), `${label(f)}: cast at ${sk.spCost} s with no enemy`);
approx(h.b.time, sk.spCost, `${label(f)}: at full SP`, 0.02);
// the first shield expired before (life < cost) and came back at life + 30: it gets the 20 s now
assert.ok(t.alive);
// the first shield expired before (life < cost) and came back at life + 30: it gets the 20 s now — or, T6 (60 + 30 =
// the 90 s cost), on its return a tick after the cast (its timers count from its deployment on the first tick)
assert.ok(h.runUntil(() => t.alive, 0.1), `${label(f)}: the shield is on the field`);
h.step();
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def + 0.18), `${label(f)}: DEF (+18 % T1)`);
+3 -1
View File
@@ -207,7 +207,9 @@ test('S2 驱凶辟邪: passive ASPD +15 / +20; cast on his attack, the target is
approx(h.b.time - t0, sk.bb.paper_duration, `T${tier}: after 5 s`, 0.05);
const dmg = h.hooksOf('damaged').slice(n0);
const burst = dmg.filter((c) => c.source === u && c.dmg.tags.includes('lmlee:burst'));
const hits = h.hooksOf('damaged').filter((c) => c.target === t && c.source === u && !c.dmg.tags.includes('lmlee:burst') && h.b.time >= t0).length;
// his hits on it before the burst (one of his hits in the burst's own tick lands after it and is not counted)
const all = h.hooksOf('damaged');
const hits = all.slice(0, all.indexOf(burst[0])).filter((c) => c.target === t && c.source === u && !c.dmg.tags.includes('lmlee:burst')).length;
assert.deepEqual(burst.map((c) => c.target).sort((a, b) => a.id - b.id), [t, near].sort((a, b) => a.id - b.id), `T${tier}: radius ${MARK_RADIUS}, the flyer too, not 2 tiles away`);
assert.ok(!burst.some((c) => c.target === far));
for (const c of burst) {
+2 -1
View File
@@ -248,9 +248,10 @@ test('S3 延异视阈: enemy shots on his range fly at 5 % of their speed (back
for (const g of h.enemies()) h.b.applyStatus(g, 'stun', { duration: 99, source: null }); // no new shot
const held = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy').length;
assert.ok(held >= 1, `${held} shots in the air as it ends`);
const taken = u.stats.taken;
h.runUntil(() => !u.skill.active, 2);
h.step();
// (until the end a held shot still reaches him at its 5 % speed — one a second from the gun 2 tiles away)
const taken = u.stats.taken;
const left = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy');
if (!disarm) {
assert.equal(left.length, 0, 'cleared');
+3 -2
View File
@@ -124,10 +124,11 @@ test('S1 旋刃 (切换: on until she leaves; data DEFAULT): range −1 (the far
const b = h.spawn('enemy_dummy', { pos: [11, 5] }), c = h.spawn('enemy_dummy', { pos: [9, 6] });
const far = h.spawn('enemy_dummy', { pos: [12, 5] }); // 2 from A, 1 from B: reachable from B only
h.run(0.1);
const n0 = atkHits(h, u).length;
const before0 = atkHits(h, u), n0 = before0.length;
h.run(4);
const after = atkHits(h, u).slice(n0);
const id = after[0].dmg.attackId;
// the first attack thrown after that (one in flight then has already hit A before the others came)
const id = after.find((x) => !before0.some((y) => y.dmg.attackId === x.dmg.attackId)).dmg.attackId;
const chain = after.filter((x) => x.dmg.attackId === id);
assert.equal(chain.length, 4, `T${tier}: the hit + 3 bounces`);
assert.equal(chain[0].target, lone, `T${tier}: her target`);