diff --git a/docs/SIM.md b/docs/SIM.md index 73566069..1268428d 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -299,9 +299,10 @@ Guarantees content can rely on (pinned by `test/sim/robustness.test.js`): horizontal sign ±1 for sprites only), hp, alive, deployed, removed, hidden, base {…}, buffs[], rangeKeys / rangeKeySet (current range, absolute tile keys `r × 21 + c`), baseRangeKeys (initial range, §7.1), liveRangeGrid (the 攻击范围 the detail card shows, through `shared/protocol.js unitStatsEntry` `range`: the relative grid -behind rangeKeys — a running skill's range, rangeExtend grown on (`targeting.js extendedGrid`; the grid itself when -nothing extends it), no extra keys — or the unit's own range while a skill's grid only selects targets, -`targeting.showOwnRange`), +behind rangeKeys — a running skill's range, rangeExtend grown on (`extendedGrid`, shared/loadoutRecord.js, re-exported +by targeting.js; the grid itself when nothing extends it), no extra keys — or the unit's own range while a skill's grid +only selects targets, `targeting.showOwnRange`; at deployment it equals the record's `attackRangeGrid`, the range the +board overlay / deploy wheel preview), extraRangeKeys (content extra targets, `battle.setExtraRange`), blocking[] (allies), blockedBy (enemies), motion ('WALK'|'FLY' enemies), profile, skill (SkillRuntime), kit, items (itemIds), lpr/mods/tag/bounty/sourcePlayerId (enemies), stats {dmg,kills,heal,taken,attacks}, mem {} and trait {} (free scratch space), persist {redeployMul, …}.` diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index 58f2a0fc..1e68ed4d 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -41,9 +41,11 @@ // battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles } // battleRunner.unitStats(unitId, fieldId?) → the live stats of a unit of the battle on screen (shared/protocol.js // unitStatsEntry: current HP, effective max HP / ATK / DEF / RES / interval / block / move -// speed next to its base) | null — the detail card reads it a few times a second (user -// playtest #4 item 7). Read-only: it takes the stats the sim computed last (`unit._s`) and -// never makes the unit recompute them, so looking never changes the battle's floats. +// speed next to its base; an ally also its live attack range `range` — unit.liveRangeGrid, +// facing RIGHT, never a kit's target-selection grid) | null — the detail card reads it a few +// times a second (user playtest #4 item 7; the range mini-map, community report E1 after +// 0.1.0). Read-only: it takes the stats the sim computed last (`unit._s`) and the range grid it +// keeps, and never makes the unit recompute them, so looking never changes the battle's floats. // battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null // battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId }] that player's operators in the battle on // screen (a teammate's bond popup: the members in play, DESIGN §20.15) | [] diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 2c8684c0..188dbf1c 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -61,8 +61,9 @@ export function rangeGridStyle(box) { /** * The grid the card's 攻击范围 shows: the live entry's `range` (shared/protocol.js unitStatsEntry — what the unit attacks - * with now: a running skill's range, rangeExtend included), else the loadout record's attack range (an elite's module - * grid, shared/loadoutRecord.js attackRangeGrid), else the record's own. + * with now: a running skill's range, rangeExtend included), else the loadout record's attack range at deployment + * (shared/loadoutRecord.js attackRangeGrid: an elite's module grid, a passive range skill, the 特性's 攻击距离 — the same + * tiles as the board overlay and the deploy wheel), else the record's own. * @param {any} live unitStatsEntry (+ src) or null @param {any} rec loadout-resolved record @param {any} chess * @returns {number[][]|null} */ diff --git a/public/js/ui/facing.js b/public/js/ui/facing.js index 10b84edb..58c1c014 100644 --- a/public/js/ui/facing.js +++ b/public/js/ui/facing.js @@ -132,9 +132,11 @@ const isObj = (v) => !!v && typeof v === 'object'; export const pieceDir = (piece) => normDir(isObj(piece) ? piece.dir : null); /** - * Range grid previewed by the wheel / on a selected unit: the operator's own grid (heal range for medics — the data - * grid is the one the unit uses; an elite's equipped "攻击范围扩大" module grid, DESIGN §16 — `chessRecord` resolves - * the player's loadout), a summon's grid, an Art's grid (画卷 `1-1`). Null when there is nothing to show. + * Range grid previewed by the wheel / on a selected unit: the operator's range at deployment (heal range for medics — + * the data grid is the one the unit uses; an elite's equipped "攻击范围扩大" module grid, DESIGN §16, a selected + * "被动效果:攻击范围扩大" skill, a module's 攻击距离 — shared/loadoutRecord.js attackRangeGrid, the prep card's range; + * `chessRecord` resolves the player's loadout), a summon's grid, an Art's grid (画卷 `1-1`). Null when there is nothing + * to show. * @param {{ getChess:(id:string)=>any, getToken:(id:string)=>any, getItem:(id:string)=>any, chessRecord?:(rec:any)=>any }} lookups */ export function previewGrid(lookups, piece) { diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index c64dd7b5..b8cd2dce 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -565,7 +565,7 @@ const KITS = { // after 0.1.0), +attack@atk_scale ATK elemental damage to every enemy of the attack in a burn burst (main and splash), // dealt BEFORE the attack's own damage ("于攻击造成伤害前判定元素爆发并造成元素伤害": a 'hit' hook — so a hit that kills // or starts the burst keeps / does not get it); loses 3 % max HP/s; any burn burst on the field refills ammo_recover - // bullets, never above the skill's ammo ("补充后的弹药数量无法超过上限"). + // bullets, never above the skill's ammo ("补充后的弹药数量无法超过上限"). Each landed bolt shows its splash (fx 'splash' r 1.7). // T1 熔点引爆: burn burst anywhere → 350 % ATK elemental damage to it + heal 12 % max HP. T2 绝处重燃: downed instead of // dying (6000 shield, no attack, no heal, 3 %/s regen) → revives at full HP and stuns nearby enemies. // Module (elite): ×damage_scale vs enemies in an element burst. @@ -631,7 +631,14 @@ const KITS = { kind: 'ammo', ammo: num(bb['attack@trigger_time'], 18), mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, - attack: { splashRadius: BLAZE_S3_SPLASH }, + attack: { + splashRadius: BLAZE_S3_SPLASH, + // the fire of each landed bolt (the screen rings splash attacks by sub-profession; 本源术师 is not one): once + // per attack at the main target / its spot — from the profile it was fired with, so the last bolt too + onHit({ battle, unit, target, x, y }) { + battle.fx('splash', { x, y, ...(target && target.alive ? { id: target.id } : {}), src: unit.id, r: BLAZE_S3_SPLASH, element: 'burn' }); + }, + }, onStart({ unit }) { unit.mem.blazeAcc = 0; }, onTick({ battle, unit, dt }) { if (!(lose > 0)) return; @@ -682,10 +689,12 @@ const KITS = { burstSpUp(battle, unit, 'blaze2:module', num(tm.sp_recovery_per_sec)); if (sid === 'skchr_blaze2_2') whileOn(battle, unit, AURA_IV, () => burnTiles(battle, unit)); if (sid && sid !== 'skchr_blaze2_3') return; - // S3: each hit of her skill attack (main and splash) on an enemy in a burn burst deals the bonus first (PRTS 备注) + // S3: each hit of her skill attack (main and splash) on an enemy in a burn burst deals the bonus first (PRTS 备注). + // Gated on the attack's own isSkill (captured when the bolt was fired), not on the live skill: her bolts land after + // the last bullet's end('ammo') / an early end (downed), and those still carry it. Only S3 runs here (sid gate). battle.on('hit', (c) => { - const d = c.dmg, t = c.target, sk = unit.skill; - if (c.source !== unit || !d.isAttack || !d.isSkill || d.cancel || !sk?.active || sk.kind !== 'ammo') return; + const d = c.dmg, t = c.target; + if (c.source !== unit || !d.isAttack || !d.isSkill || d.cancel) return; if (t.side === 'enemy' && t.alive && t.findBuff('burnBurst')) elementHit(battle, unit, t, unit.s.atk * num(bb['attack@atk_scale']), 'blazeBurn', 'burn'); }, { owner: unit }); const maxAmmo = num(bb['attack@trigger_time'], 18); diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 323032e0..3b847f37 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -43,29 +43,9 @@ export function absoluteRangeKeys(grid, r, c, dir, extend = 0) { return keys; } -/** - * A range grid (`[dRow, dCol]`, facing RIGHT) grown by `extend` (rangeExtend, DESIGN §3): every row gains the whole - * tiles 1 … ⌊extend⌋ beyond its far (+dCol) end — the relative form of what absoluteRangeKeys builds, deduplicated, junk - * entries dropped. Battle._refreshRange keeps it as `unit.liveRangeGrid` (the detail card's live 攻击范围) when an extend - * applies (else the grid itself). - * @param {Array<[number, number]>|null|undefined} grid - * @param {number} [extend] - * @returns {Array<[number, number]>} - */ -export function extendedGrid(grid, extend = 0) { - const out = []; - const seen = new Set(); - const add = (dr, dc) => { const k = `${dr},${dc}`; if (!seen.has(k)) { seen.add(k); out.push([dr, dc]); } }; - if (!Array.isArray(grid)) return out; - const cells = grid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1])); - for (const [dr, dc] of cells) add(dr, dc); - if (extend > 0 && Number.isFinite(extend)) { - const maxByRow = new Map(); - for (const [dr, dc] of cells) maxByRow.set(dr, Math.max(maxByRow.get(dr) ?? -Infinity, dc)); - for (const [dr, mx] of maxByRow) for (let k = 1; k <= Math.min(extend, COLS); k++) add(dr, mx + k); - } - return out; -} +// extendedGrid(grid, extend): the relative form of absoluteRangeKeys' rangeExtend — one implementation with the card's +// record range (shared/loadoutRecord.js attackRangeGrid), re-exported here for the battle (Battle._refreshRange). +export { extendedGrid } from '../../shared/loadoutRecord.js'; /** Tile key of a unit's current position. */ export function tileKeyOf(u) { diff --git a/shared/loadoutRecord.js b/shared/loadoutRecord.js index cb387ea7..0a40383d 100644 --- a/shared/loadoutRecord.js +++ b/shared/loadoutRecord.js @@ -1,8 +1,12 @@ // shared/loadoutRecord.js — operator loadouts (DESIGN §16, DATA.md §2.2): a data/chess.json record as the selected // skill / module make it. Pure ESM shared by the simulation (server/sim/simdata.js re-exports it: getChess(id, loadout) // builds unit defs from it) and the client UI (the detail card shows the stats / 特性 / talents the unit fights with — -// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait). One implementation, so -// the card and the battle never disagree. (Which choices a player may make: shared/protocol.js loadoutOptions.) +// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait; attackRangeGrid = the range +// it is deployed with, also the board overlay and the deploy wheel — extendedGrid is the battle's own 攻击距离 growth, +// re-exported by server/sim/targeting.js). One implementation, so the card and the battle never disagree. (Which +// choices a player may make: shared/protocol.js loadoutOptions.) + +import { GEO } from './constants.js'; /** * Resolve a loadout against a chess record. @@ -99,19 +103,66 @@ export function loadoutRecord(rec, lo) { } /** - * The attack range a (loadout-resolved) chess record fights with: an elite whose equipped module reads "攻击范围扩大" - * uses that module's own grid — its range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + the - * centre tile [0,3] — as the kits do (tier4 moduleRangeGrid, tier5 moduleRangeUp); anything else its `rangeGrid`. + * The attack range a (loadout-resolved) chess record fights with from its deployment — the detail card without a live + * entry, the board's range overlay and the deploy wheel (DESIGN §16), the same tiles the battle unit starts with (prep + * m.unitStats `range`): the selected skill's grid when it reads "被动效果:攻击范围扩大" (引星棘刺 S3 3-9: her own range + * while she carries it, tier5 kit); else an elite whose equipped module reads "攻击范围扩大" uses that module's own grid + * — its range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + the centre tile [0,3] — as the + * kits do (tier4 moduleRangeGrid, tier5 moduleRangeUp); anything else its `rangeGrid`. Then grown by the 特性's + * permanent 攻击距离 (traitRangeExtend: 信仰搅拌机 SPT-Y "攻击距离+1"). A running skill's range is the live entry's. * @param {object|null} rec loadoutRecord(…) output (or a data/chess.json record: its default module) * @returns {number[][]|null} */ export function attackRangeGrid(rec) { if (!rec || typeof rec !== 'object') return null; + let g = Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null; + const sk = rec.skill; const m = rec.module; - if (rec.isGolden && m && m.active && m.id && /攻击范围扩大/.test(String(rec.trait?.moduleDesc ?? ''))) { + if (sk && Array.isArray(sk.rangeGrid) && sk.rangeGrid.length && /被动效果:攻击范围扩大/.test(String(sk.desc ?? ''))) { + g = sk.rangeGrid; + } else if (rec.isGolden && m && m.active && m.id && /攻击范围扩大/.test(String(rec.trait?.moduleDesc ?? ''))) { const mod = (Array.isArray(rec.modules) ? rec.modules : []).find((x) => x && x.uniEquipId === m.id); - const g = (mod?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid; - if (g) return g; + const mg = (mod?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid; + if (mg) g = mg; } - return Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null; + const ext = traitRangeExtend(rec); + return g && ext ? extendedGrid(g, ext) : g; +} + +/** + * The permanent 攻击距离 (ability_range_forward_extend) a record's 特性 grants — a module's, e.g. 信仰搅拌机 SPT-Y + * "攻击距离+1" (tier4 rangeUp: a persistent rangeExtend buff, s.baseRangeExtend); 0 for one that works "在集成战略中" only + * (空弦 ISW-A). The other 攻击距离 of the mode are skills' (their running range). + * @param {object|null} rec loadoutRecord(…) output + */ +export function traitRangeExtend(rec) { + const t = rec && typeof rec === 'object' ? rec.trait : null; + if (!t || typeof t !== 'object' || /集成战略/.test(String(t.moduleDesc ?? ''))) return 0; + const n = Math.floor(Number(t.bb?.ability_range_forward_extend) || 0); + return n > 0 ? n : 0; +} + +/** + * A range grid (`[dRow, dCol]`, facing RIGHT) grown by `extend` (rangeExtend / 攻击距离, DESIGN §3): every row gains + * the whole tiles 1 … ⌊extend⌋ beyond its far (+dCol) end — the relative form of what server/sim/targeting.js + * absoluteRangeKeys builds, deduplicated, junk entries dropped. One implementation for the battle (re-exported by + * targeting.js: Battle._refreshRange keeps it as `unit.liveRangeGrid`, the card's live 攻击范围, when an extend applies) + * and the record's attackRangeGrid. + * @param {Array<[number, number]>|null|undefined} grid + * @param {number} [extend] + * @returns {Array<[number, number]>} + */ +export function extendedGrid(grid, extend = 0) { + const out = []; + const seen = new Set(); + const add = (dr, dc) => { const k = `${dr},${dc}`; if (!seen.has(k)) { seen.add(k); out.push([dr, dc]); } }; + if (!Array.isArray(grid)) return out; + const cells = grid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1])); + for (const [dr, dc] of cells) add(dr, dc); + if (extend > 0 && Number.isFinite(extend)) { + const maxByRow = new Map(); + for (const [dr, dc] of cells) maxByRow.set(dr, Math.max(maxByRow.get(dr) ?? -Infinity, dc)); + for (const [dr, mx] of maxByRow) for (let k = 1; k <= Math.min(extend, GEO.COLS); k++) add(dr, mx + k); + } + return out; } diff --git a/test/render/feedback1e-splash.test.js b/test/render/feedback1e-splash.test.js new file mode 100644 index 00000000..7a232ae4 --- /dev/null +++ b/test/render/feedback1e-splash.test.js @@ -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'); +}); diff --git a/test/sim/feedback1e-skillrange.test.js b/test/sim/feedback1e-skillrange.test.js index 177bd7b4..7c3f9786 100644 --- a/test/sim/feedback1e-skillrange.test.js +++ b/test/sim/feedback1e-skillrange.test.js @@ -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`); diff --git a/test/ui/feedback1e-range.test.js b/test/ui/feedback1e-range.test.js index 2f45cad4..853ca880 100644 --- a/test/ui/feedback1e-range.test.js +++ b/test/ui/feedback1e-range.test.js @@ -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)); +}); diff --git a/test/ui/playtest2.test.js b/test/ui/playtest2.test.js index 31cd68a6..e3d9bc9c 100644 --- a/test/ui/playtest2.test.js +++ b/test/ui/playtest2.test.js @@ -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++; } }