feedback1e WP: review round 3 — 烛煌 S3 last bolt keeps the burst bonus, splash fx; prep overlay and wheel show the deployed range

- 烛煌 S3: the burn-burst bonus is gated on the attack's own isSkill (captured when the bolt was fired), not on the live
  skill, so the last bullet (it lands after end('ammo')) and bolts in flight when the skill ends early still carry it.
- 烛煌 S3: every landed bolt emits fx 'splash' (r 1.7, burn) at the main target, so the splash shows on screen.
- shared/loadoutRecord.js attackRangeGrid: the range a unit is deployed with — a selected passive range skill
  (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1; not 空弦 ISW-A's 集成战略-only one) —
  so the board overlay, the deploy wheel and the card agree with the prep live range. extendedGrid moves there
  (re-exported by targeting.js).
- runner.js header: unitStatsEntry carries the ally's live range.
This commit is contained in:
sganggs committed 2026-10-03 06:25:12 +08:00
1 parent 5963a40d2d
commit a657f809f1
11 files changed
+247 -52

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
// test/render/feedback1e-splash.test.js — 烛煌 S3 众恶的焚场 "攻击变为群体攻击" is one target + a 1.7 splash (PRTS 备注
// "攻击溅射半径1.7"; community report E1 after 0.1.0). The screen rings splash attacks by sub-profession (fx.js
// SPLASH_SUBS) and 本源术师 is not one, so the kit emits fx 'splash' {x, y, id: main target, src, r: 1.7, element: 'burn'}
// for every landed bolt (test/sim/feedback1e-skillrange.test.js): drawn as a burn-coloured blast of that radius at the
// main target. Against the headless fake PIXI (test/render/fakepixi.js), like fxflame.test.js.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const cam = presetCamera('normal', { width: 1600, height: 900 });
const unit = (id, x, y) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {} });
test('烛煌 S3 splash fx: a burn blast of radius 1.7 on the main target (anchored to it), at its spot when it is gone', () => {
const blaze = unit(1, 3, 10), foe = unit(20, 7, 10);
const map = new Map([[blaze.id, blaze], [foe.id, foe]]);
const ctx = fakeViewCtx(fake.P);
const fx = new FX.FxSystem({
P: fake.P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => 2, loadLevel: () => 0, subProfOf: () => 'primcaster', view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
const calls = [];
const real = fx.explosion.bind(fx);
fx.explosion = (x, y, z, r, col, o) => { calls.push({ x, y, r, col }); return real(x, y, z, r, col, o); };
assert.equal(FX.fxSpec('splash', { element: 'burn' }).a, 'blast');
fx.simFx('splash', foe.x, foe.y, { x: foe.x, y: foe.y, id: foe.id, src: blaze.id, r: 1.7, element: 'burn' });
fx.simFx('splash', 9, 11, { x: 9, y: 11, src: blaze.id, r: 1.7, element: 'burn' }); // the target died meanwhile
assert.equal(calls.length, 2);
assert.deepEqual(calls.map((c) => [c.x, c.y, c.r]), [[7, 10, 1.7], [9, 11, 1.7]], 'on the target, then on its spot');
for (const c of calls) assert.equal(c.col, 0xff7a33, 'burn colour');
const n = fx.counts.particles;
assert.ok(n > 0, 'fire and sparks');
for (let t = 0; t < 2; t += 1 / 120) fx.update(1 / 120);
assert.ok(fx.counts.particles < n, 'and they burn out');
});
+58 -2
View File
@@ -113,6 +113,32 @@ test('烛煌 S3 "攻击变为群体攻击" = one target + a 1.7 splash (PRTS 备
for (const list of b.byAttack.values()) assert.equal(list.length, 1, 'one enemy per attack (the old group attack hit both)');
});
test('烛煌 S3: every landed bolt shows its 1.7 splash (fx \'splash\' r 1.7 at the main target) — the last one too', () => {
// review of round 2: the splash drew no ring (fx.js rings by subProfession, 本源术师 is not one) — it looked single-target
const h = blazeArena([{ key: 'enemy_cast', pos: [11, 3] }, { key: 'enemy_main', pos: [10, 7] }]);
const u = h.unit(BLAZE);
h.step();
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5));
const fx = () => h.eventsOf('fx').filter((e) => e[1] === 'splash' && e[4]?.src === u.id);
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack && c.dmg.isSkill && !c.dmg.isSplash);
const m0 = fx().length, h0 = hits().length;
u.skill.ammoLeft = 3;
assert.ok(h.runUntil(() => !u.skill.active, 10));
h.run(1.5); // the last bolt lands after end('ammo')
const got = fx().slice(m0), main = hits().slice(h0);
assert.ok(main.length >= 3, `S3 hits (${main.length})`);
assert.equal(got.length, main.length, 'one splash per landed S3 bolt');
for (const e of got) {
assert.equal(e[4].r, 1.7);
assert.equal(e[4].element, 'burn');
assert.ok(main.some((c) => Math.hypot(c.target.x - e[2], c.target.y - e[3]) < 1e-6), 'at the main target');
}
h.run(3);
assert.equal(fx().length - m0, got.length, 'normal attacks after the skill draw none');
done(h);
});
test('烛煌 S3: the +60 % ATK elemental damage hits every enemy of the attack in a 灼燃损伤 burst — the splashed ones too', () => {
const h = blazeArena([{ key: 'enemy_cast', pos: [11, 3] }, { key: 'enemy_main', pos: [10, 7] }, { key: 'enemy_out', pos: [10, 8] }]);
const u = h.unit(BLAZE);
@@ -158,6 +184,34 @@ test('烛煌 S3: the burst bonus is dealt BEFORE the attack\'s damage (PRTS 备
done(h);
});
test('烛煌 S3: the LAST bolt still deals the burst bonus — it lands after end(\'ammo\'), from what she fired with', () => {
// her bolts are projectiles: the skill ends when the last bullet is fired, the damage lands later (review of round 2)
const h = blazeArena([{ key: 'enemy_cast', pos: [11, 3] }, { key: 'enemy_main', pos: [10, 7] }]);
const u = h.unit(BLAZE);
h.step();
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5));
for (const k of ['enemy_cast', 'enemy_main']) h.b.applyStatus(h.enemy(k), 'burnBurst', { duration: 30 }); // whichever she aims at
u.skill.ammoLeft = 1;
const mark = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => !u.skill.active, 5), 'the last bullet ends the skill');
const fired = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => h.hooksOf('damaged').slice(fired).some((c) => c.source === u && c.dmg.isAttack && c.dmg.isSkill), 5),
'the last bolt lands after the skill ended');
const isBonus = (c) => (c.dmg.tags || []).includes('blazeBurn');
const ev = h.hooksOf('damaged').slice(mark).filter((c) => c.source === u && (isBonus(c) || (c.dmg.isAttack && c.dmg.isSkill)));
const hitAt = ev.findLastIndex((c) => c.dmg.isAttack);
assert.ok(hitAt > 0 && isBonus(ev[hitAt - 1]) && ev[hitAt - 1].target === ev[hitAt].target, 'the last bolt\'s bonus comes first');
assert.equal(ev.filter(isBonus).length, ev.filter((c) => c.dmg.isAttack).length, 'one bonus per S3 hit on the bursting enemy');
assert.equal(u.skill.active, false);
// the next (normal) attacks after the skill never carry it
h.run(4);
const after = h.hooksOf('damaged').slice(mark).filter((c) => c.source === u);
assert.ok(after.some((c) => c.dmg.isAttack && !c.dmg.isSkill), 'normal attacks follow');
assert.equal(after.filter(isBonus).length, after.filter((c) => c.dmg.isAttack && c.dmg.isSkill).length, 'no bonus on a normal attack');
done(h);
});
test('烛煌 S3: a burn burst refills 2 ammo but never above the skill\'s ammo (PRTS 备注 "补充后的弹药数量无法超过上限")', () => {
for (const id of [BLAZE, 'chess_char_5_03_b']) {
const h = blazeArena([{ key: 'enemy_main', pos: [10, 5] }, { key: 'enemy_b', pos: [10, 6] }, { key: 'enemy_c', pos: [9, 5] }], { id });
@@ -263,8 +317,10 @@ test('audit: every selectable attack-range change attacks with the official grid
h.step();
const tag = `${c.chessId} S${s.index + 1} ${s.name} ${mid ?? 'default'}`;
assert.equal(u.s.baseRangeExtend, permExt, `${tag}: permanent 攻击距离`);
const grid = s.rangeGrid && !ext ? s.rangeGrid : attackRangeGrid(rec);
const want = keysOf(grid, u, (NO_EXTEND.has(s.skillId) ? 0 : permExt) + ext);
// a skill grid takes the 攻击距离 unless it ignores it; the record's attack range already carries it (attackRangeGrid)
const own = !!(s.rangeGrid && !ext);
const grid = own ? s.rangeGrid : attackRangeGrid(rec);
const want = keysOf(grid, u, own ? (NO_EXTEND.has(s.skillId) ? 0 : permExt) : ext);
if (permExt) extended++;
if (s.skillType !== 'PASSIVE' && !/被动效果:攻击范围扩大/.test(s.desc)) {
assert.ok(u.skill.activate('test', { free: true }), `${tag}: cast`);
+48
View File
@@ -142,3 +142,51 @@ test('rangeGridStyle: the attack ranges of the records keep the .14rem cells; on
assert.ok(!plain({ cols: 7, rows: 1 }) && !plain({ cols: 1, rows: 8 }));
assert.match(rangeGridStyle({ cols: 3, rows: 9 }), /calc\(\(7 \* \.14rem \+ 12px\) \/ 9\)/, 'a tall grid fits the 7 rows');
});
test('prep: the record\'s attack range (card without a live entry, board overlay, deploy wheel) = the unit\'s range at deployment — every loadout', async () => {
// review of round 2: the prep card drew the live entry (信仰搅拌机 SPT-Y "攻击距离+1", 引星棘刺 S3 "被动效果:攻击范围扩大")
// while the board overlay and the deploy wheel of the same unit drew the record's grid without them
const { getDefaultSource } = await import('../../server/sim/simdata.js');
const { resolveRecordLoadout, loadoutRecord, attackRangeGrid, traitRangeExtend } = await import('../../shared/loadoutRecord.js');
const { previewGrid } = await import('../../public/js/ui/facing.js');
const C = getDefaultSource().raw.chess;
const key = (g) => (g || []).map(([r, c]) => `${r},${c}`).sort().join(' ');
let runs = 0;
const wider = new Set();
for (const c of Object.values(C)) {
if (!(c.visible || (c.isGolden && C[c.baseId]?.visible))) continue;
const skills = (c.skills || []).length ? c.skills.map((s) => s.index) : [null];
const mods = [null, ...(Array.isArray(c.modules) && c.modules.length ? ['none', ...c.modules.map((m) => m.uniEquipId)] : [])];
for (const si of skills) for (const mid of mods) {
const lo = { ...(si != null ? { skillIndex: si } : {}), ...(mid ? { moduleId: mid } : {}) };
const rec = loadoutRecord(c, resolveRecordLoadout(c, lo));
const h = makeBattle({ units: [{ chessId: c.chessId, row: 10, col: 5, dir: 'RIGHT', ...lo }], autoFinish: false, timeLimit: 5 });
h.step();
const u = h.unit(c.chessId);
const live = key(unitStatsEntry(u, u._s).range);
const tag = `${c.chessId} ${c.name} S${si == null ? '-' : si + 1} ${mid ?? 'default'}`;
assert.equal(key(attackRangeGrid(rec)), live, `${tag}: record range = range at deployment`);
const wheel = previewGrid({ getChess: (id) => C[id] || null, chessRecord: (r) => loadoutRecord(r, resolveRecordLoadout(r, lo)) }, { kind: 'chess', id: c.chessId });
assert.equal(key(wheel), live, `${tag}: overlay / wheel = range at deployment`);
if (traitRangeExtend(rec) || /被动效果:攻击范围扩大/.test(rec.skill?.desc ?? '')) { assert.notEqual(live, key(rec.rangeGrid), tag); wider.add(c.name); }
runs++;
}
}
assert.ok(runs > 1000, `loadouts (${runs})`);
assert.deepEqual([...wider].sort(), ['信仰搅拌机', '引星棘刺'].sort(), 'the permanent 攻击距离 (SPT-Y) and the passive range skill (S3) of the mode');
// 空弦 ISW-A "在集成战略中,…攻击距离+1": none in this mode
const arch = C.chess_char_3_21_b;
const isw = loadoutRecord(arch, resolveRecordLoadout(arch, { moduleId: 'uniequip_004_archet' }));
assert.equal(isw.trait.bb.ability_range_forward_extend, 1);
assert.equal(traitRangeExtend(isw), 0);
assert.equal(key(attackRangeGrid(isw)), key(arch.rangeGrid));
// the two by hand: SPT-Y 2-2 + 1 (every skill — S3's 3-13 replaces it only while it runs); 引星棘刺 S3 3-9
const rmx = C.chess_char_4_01_b;
for (const skillIndex of [0, 1, 2]) {
assert.equal(attackRangeGrid(loadoutRecord(rmx, resolveRecordLoadout(rmx, { skillIndex, moduleId: 'uniequip_003_rmixer' }))).length, rmx.rangeGrid.length + 1);
assert.equal(key(attackRangeGrid(loadoutRecord(rmx, resolveRecordLoadout(rmx, { skillIndex, moduleId: 'uniequip_002_rmixer' })))), key(rmx.rangeGrid));
}
const thorn = C.chess_char_5_15_a;
assert.equal(key(attackRangeGrid(loadoutRecord(thorn, resolveRecordLoadout(thorn, { skillIndex: 2 })))), key(thorn.skills[2].rangeGrid));
assert.equal(key(attackRangeGrid(loadoutRecord(thorn, resolveRecordLoadout(thorn, { skillIndex: 0 })))), key(thorn.rangeGrid));
});
+3 -2
View File
@@ -334,9 +334,10 @@ describe('§16: chessLoadout (m.private.loadout → skill / module shown)', () =
const h = makeBattle({ units: [{ chessId: c.chessId, row: 10, col: 3, moduleId }], enemies: [], autoFinish: false, timeLimit: 5 });
h.step();
const u = h.unit(c.chessId);
assert.equal(key(g), key(u.rangeGrid), `${c.chessId} ${moduleId}: card range = battle range`);
// the range it starts with: its grid + a module's permanent 攻击距离 (信仰搅拌机 SPT-Y; community report E1 review)
assert.equal(key(g), key(u.liveRangeGrid), `${c.chessId} ${moduleId}: card range = battle range`);
const wheel = previewGrid({ getChess: get2, chessRecord: (r) => chessLoadout(r, lo, get2).record }, { kind: 'chess', id: c.chessId });
assert.equal(key(wheel), key(u.rangeGrid), `${c.chessId} ${moduleId}: wheel preview = battle range`);
assert.equal(key(wheel), key(u.liveRangeGrid), `${c.chessId} ${moduleId}: wheel preview = battle range`);
if (key(g) !== key(c.rangeGrid)) changed++;
}
}