From 8b0e672bb857c097f83e429d848b96984518763d Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 05:03:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feedback1e=20WP:=20live=20attack=20range=20?= =?UTF-8?q?on=20the=20battle=20card;=20=E7=83=9B=E7=85=8C=20S3=20splash=20?= =?UTF-8?q?1.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community report "干员烛煌开启3技能时候攻击范围不会变". The sim already attacked with S3's 4-11 while it ran (real matches, and every attack-range change of the mode); what stayed the same was the battle detail card: its 攻击范围 mini-map kept the base 3-1 grid while the live stats beside it moved. - Battle._refreshRange keeps the relative grid behind rangeKeys as unit.liveRangeGrid (targeting.js extendedGrid: rangeExtend grown on); shared/protocol.js unitStatsEntry carries it as `range`; the detail card draws it (cardRangeGrid) in battle and in the prep preview, and names a whole-field range (纯烬艾雅法拉 S3) 全场 instead of drawing it. - 烛煌 S3 "攻击变为群体攻击" is one target + a 1.7 splash (PRTS 技能3 备注 "攻击溅射半径1.7"), not every enemy inside 4-11; the burst bonus hits every enemy of the attack; a burn burst's +2 ammo stops at the skill's ammo ("补充后的弹药数量无法超过上限"). - Tests: test/sim/feedback1e-skillrange.test.js (S3 range, splash, ammo cap, live grid, the audit of every attack-range change, a real spec), test/ui/feedback1e-range.test.js (the card). --- docs/SIM.md | 2 + public/css/screens/game-panels.css | 1 + public/js/ui/detailPanel.js | 22 +- server/sim/Battle.js | 10 +- server/sim/content/kits/tier5.js | 31 ++- server/sim/targeting.js | 23 ++ shared/protocol.js | 13 +- test/content/kits_t5.test.js | 4 +- test/sim/feedback1e-skillrange.test.js | 281 +++++++++++++++++++++++++ test/ui/feedback1e-range.test.js | 104 +++++++++ 10 files changed, 471 insertions(+), 20 deletions(-) create mode 100644 test/sim/feedback1e-skillrange.test.js create mode 100644 test/ui/feedback1e-range.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..e4869e91 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -298,6 +298,8 @@ Guarantees content can rely on (pinned by `test/sim/robustness.test.js`): (board tile: auto-redeploys land there), dir ('UP'|'RIGHT'|'DOWN'|'LEFT'; getters `fwd` = forward vector, `facing` = 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 relative grid behind rangeKeys — a running skill's range, rangeExtend grown on, `targeting.js +extendedGrid`; no extra keys — read by the detail card through `shared/protocol.js unitStatsEntry` `range`), 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/css/screens/game-panels.css b/public/css/screens/game-panels.css index 394a7d5f..ce270969 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -129,6 +129,7 @@ .rgrid i { width: var(--rg); height: var(--rg); border: 1px solid rgba(255, 255, 255, .06); } .rgrid i.on { background: rgba(246, 163, 41, .25); border-color: var(--amber); } .rgrid i.self { background: var(--mint-500); border-color: var(--mint-400); } +.rgrid-all { font-size: .16rem; font-weight: 700; color: var(--amber); padding: .02rem .06rem; border: 1px solid var(--amber); background: rgba(246, 163, 41, .25); } .dsec { display: flex; flex-direction: column; gap: .08rem; } .dsec__title { display: flex; align-items: baseline; gap: .1rem; font-size: .17rem; font-weight: 700; color: var(--mint-400); padding-bottom: .04rem; border-bottom: 1px solid var(--line); } .dtext { font-size: .15rem; line-height: 1.65; color: var(--text-md); } diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 9b2ae5de..d26cec49 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -17,7 +17,9 @@ // getter re-read 4× a second: current HP, max HP, ATK, DEF, RES, attack interval, block), in prep the stats the own // board's units start their next battle with (m.unitStats: equipment, bonds / layers, 特质, band and 机变 effects). Each // value is coloured against the unit's base like the official card — green when it helps (higher, or a shorter attack -// interval) with the difference beside it, red when it hurts — and a 实时 / 开战时 tag says which it is. +// interval) with the difference beside it, red when it hurts — and a 实时 / 开战时 tag says which it is. The 攻击范围 +// mini-map follows the live entry's `range` too (cardRangeGrid: the grid the unit attacks with now — a running skill's +// range such as 烛煌 S3's 4-11, rangeExtend included; community report E1 after 0.1.0, it used to stay the base grid). import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js'; import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; @@ -38,8 +40,24 @@ const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SER const RANK = { NORMAL: '普通', ELITE: '精英', BOSS: '领袖' }; const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', none: '无' }; +/** A range grid of at least this many tiles covers the field (纯烬艾雅法拉 S3 "攻击范围扩大至整个战场"): named, not drawn. */ +export const FIELD_WIDE_CELLS = 400; + +/** + * 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. + * @param {any} live unitStatsEntry (+ src) or null @param {any} rec loadout-resolved record @param {any} chess + * @returns {number[][]|null} + */ +export function cardRangeGrid(live, rec, chess) { + if (live && Array.isArray(live.range) && live.range.length) return live.range; + return attackRangeGrid(rec) || chess?.rangeGrid || null; +} + /** Mini range map. */ export function RangeGrid({ grid, class: cls }) { + if (Array.isArray(grid) && grid.length >= FIELD_WIDE_CELLS) return html`全场`; const box = rangeGridBox(grid); if (!box.cells.size) return html`—`; const cells = []; @@ -268,7 +286,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond <${Stat} k="部署费用" v=${s.cost ?? '—'} /> <${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} /> -
攻击范围<${RangeGrid} grid=${attackRangeGrid(fr) || c.rangeGrid} />
+
攻击范围<${RangeGrid} grid=${cardRangeGrid(live, fr, c)} />
`; blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 68187bb4..65fe14b3 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -28,7 +28,7 @@ import { Grid } from './grid.js'; import { Unit } from './units.js'; import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js'; import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView, leaderHitCancelled } from './damage.js'; -import { absoluteRangeKeys, canTargetEnemy } from './targeting.js'; +import { absoluteRangeKeys, canTargetEnemy, extendedGrid } from './targeting.js'; import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js'; import { normDir, mirrorDir, localOrder, localBefore } from './dir.js'; import { ProjectileSystem } from './projectiles.js'; @@ -1592,8 +1592,11 @@ export class Battle { * Recompute a unit's absolute range tile keys: `rangeKeys` / `rangeKeySet` = current range (skill range override + * rangeExtend) ∪ `unit.extraRangeKeys` (content extra targets — setExtraRange); `baseRangeKeys` = the INITIAL range * of the DEFAULT skill trigger: the unit's own grid + its permanent rangeExtend (`s.baseRangeExtend`: persistent, - * never-expiring buffs — talents, modules, bonds), no skill range, no temporary extend, no extra keys. Rebuilt on - * deploy / relocate / skill range switches and whenever either extend changes (rangeChanged). + * never-expiring buffs — talents, modules, bonds), no skill range, no temporary extend, no extra keys; + * `liveRangeGrid` = the relative grid (facing RIGHT) behind `rangeKeys` without the extra keys (targeting.js + * extendedGrid) — the detail card's live 攻击范围 (shared/protocol.js unitStatsEntry `range`; community report E1 + * after 0.1.0: the card kept the base grid while 烛煌 S3 attacked with 4-11). Rebuilt on deploy / relocate / skill + * range switches and whenever either extend changes (rangeChanged). */ _refreshRange(u) { const tg = u.skill && u.skill.active ? u.skill.spec.targeting : null; @@ -1607,6 +1610,7 @@ export class Battle { if (extra) for (const k of extra) if (!set.has(k)) { set.add(k); keys.push(k); } u.rangeKeys = keys; u.rangeKeySet = set; + u.liveRangeGrid = extendedGrid(grid, ext); if (u.kind !== 'device') u.baseRangeKeys = absoluteRangeKeys(u.rangeGrid || [[0, 0]], u.tileR, u.tileC, u.dir, u.s.baseRangeExtend); if (u.skill) u.skill._trigKeys = null; // CUSTOM_RANGE trigger grid is relative to the tile } diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index d766208e..b41d65df 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -12,10 +12,11 @@ // × elementalTakenMul = 元素脆弱), with `element` set for the client colour. // - Mechanics missing from the chess text follow the PRTS 备注 of the base operator (verified 2026-09-28): 号角 S3 overload // is the second half of the 24 s duration; 圣约送葬人's extra attack consumes no ammo; 夕 S1 splash 1.7; 烛煌 revive stun -// radius 1.7; 寒檀 icicles splash 1.5 and cycle left row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit -// at 1.15 (30°/s, hit radius 0.4); 铃兰 T2 is an aura (sluggish enemies in range are 脆弱 while sluggish); 缇缇's chain -// sleep picks the highest-aggro enemy within 1.5; 乌尔比安 lands on the anchor tile > the tile beyond > his own tile; -// 引星棘刺 throws at the farthest forward tile when no enemy is in range; 夕 T2 summons only on a deployable target tile. +// radius 1.7, S3 splash 1.7 and its refilled ammo capped at the skill's ammo; 寒檀 icicles splash 1.5 and cycle left +// row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit at 1.15 (30°/s, hit radius 0.4); 铃兰 T2 is an +// aura (sluggish enemies in range are 脆弱 while sluggish); 缇缇's chain sleep picks the highest-aggro enemy within +// 1.5; 乌尔比安 lands on the anchor tile > the tile beyond > his own tile; 引星棘刺 throws at the farthest forward tile +// when no enemy is in range; 夕 T2 summons only on a deployable target tile. // - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field. // SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins. // - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an @@ -48,6 +49,8 @@ const CANDLE_MIN_ATK = 0.35; const CANDLE_KEY = 'enemy_5601_entlec'; /** 烛煌 绝处重燃 "使附近的敌人晕眩" radius (tiles). PRTS 备注: 1.7. */ const NEARBY_RADIUS = 1.7; +/** 烛煌 S3 "攻击变为群体攻击": splash radius around the target (tiles). PRTS 技能3 备注 "攻击溅射半径1.7". */ +const BLAZE_S3_SPLASH = 1.7; /** 玛恩纳 游侠 "周围存在3名及以上敌人" radius (8-neighbourhood). */ const AROUND_RADIUS = 1.5; /** 安洁莉娜 S3 失重: weight (massLevel) reduction while weightless. PRTS: "重量下降一个等级". */ @@ -556,8 +559,12 @@ const KITS = { }, // --------------------------------------------------------------------------------------------------------------- - // 烛煌 — S3 众恶的焚场 (ammo): skill range, ATK +, BAT −1.3 s, hits every enemy in range, +attack@atk_scale ATK - // elemental damage vs targets in a burn burst; loses 3 % max HP/s; any burn burst on the field refills ammo. + // 烛煌 — S3 众恶的焚场 (ammo): skill range 4-11 (the targets; the DEFAULT cast still needs an enemy in 3-1), ATK +, + // BAT −1.3 s, "攻击变为群体攻击" = one target + a BLAZE_S3_SPLASH (1.7) splash around it (PRTS 备注 "攻击溅射半径1.7", + // 中点判定 — so the fire reaches past the diamond; it used to hit every enemy inside it instead, community report E1 + // after 0.1.0), +attack@atk_scale ATK elemental damage to every enemy of the attack in a burn burst (main and splash; + // after the hit [ASSUMED] — the 备注 checks it before); loses 3 % max HP/s; any burn burst on the field refills + // ammo_recover bullets, never above the skill's ammo ("补充后的弹药数量无法超过上限"). // 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. @@ -624,8 +631,8 @@ const KITS = { mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }), targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, attack: { - allInRange: true, - onHit({ battle, unit, target }) { + splashRadius: BLAZE_S3_SPLASH, + onEachHit({ battle, unit, target }) { if (target && target.alive && target.findBuff('burnBurst')) elementHit(battle, unit, target, unit.s.atk * num(bb['attack@atk_scale']), 'blazeBurn', 'burn'); }, }, @@ -679,8 +686,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; - battle.on('elementBurst', (c) => { // S3: burn bursts refill ammo - if (c.element === 'burn' && unit.skill?.active && unit.skill.kind === 'ammo' && on(unit)) unit.skill.addAmmo(num(bb.ammo_recover)); + const maxAmmo = num(bb['attack@trigger_time'], 18); + battle.on('elementBurst', (c) => { // S3: burn bursts refill ammo, up to the skill's ammo + const sk = unit.skill; + if (c.element !== 'burn' || !sk?.active || sk.kind !== 'ammo' || !on(unit)) return; + const n = Math.min(num(bb.ammo_recover), maxAmmo - sk.ammoLeft); + if (n > 0) sk.addAmmo(n); }, { owner: unit }); }, }; diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 96113838..d966a5d5 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -43,6 +43,29 @@ 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 攻击范围). + * @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; +} + /** Tile key of a unit's current position. */ export function tileKeyOf(u) { const r = Math.round(u.y), c = Math.round(u.x); diff --git a/shared/protocol.js b/shared/protocol.js index 912d9034..d4f2eb05 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -193,16 +193,22 @@ const statView = (x) => ({ * or the last ones the sim computed) next to its own `unit.base` (no buffs) — max HP, ATK, DEF, RES, attack interval * (s), block, move speed — rounded for display (the sim keeps floats), plus the current HP. The shape of the * `m.unitStats` units (Match.unitStats: what the board's units start their next battle with) and of the browser - * runner's live battle stats (public/js/battle/runner.js unitStats). - * @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any } | null} u + * runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid + * (`[dRow, dCol]`, facing RIGHT) it attacks with now — a running skill's range, rangeExtend included (the sim's + * `unit.liveRangeGrid`, Battle._refreshRange; community report E1 after 0.1.0: 烛煌 S3's 4-11 never reached the card). + * @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any } | null} u * @param {any} [s] aggregated stats (missing ⇒ the base) * @returns {{ id: number|null, uid: number|null, defId: string|null, hp: number, alive: boolean, maxHp: number, atk: number, * def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number, - * base: { maxHp: number, atk: number, def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number } }} + * base: { maxHp: number, atk: number, def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number }, + * range?: Array<[number, number]> }} */ export function unitStatsEntry(u, s = null) { const base = u && u.base && typeof u.base === 'object' ? u.base : {}; const cur = s && typeof s === 'object' ? s : base; + const range = u?.side !== 'enemy' && Array.isArray(u?.liveRangeGrid) + ? u.liveRangeGrid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1])).map((p) => [p[0], p[1]]) + : null; return { id: Number.isInteger(u?.id) ? u.id : null, uid: Number.isInteger(u?.uid) ? u.uid : null, @@ -211,6 +217,7 @@ export function unitStatsEntry(u, s = null) { alive: u?.alive !== false, ...statView(cur), base: statView(base), + ...(range ? { range } : {}), }; } diff --git a/test/content/kits_t5.test.js b/test/content/kits_t5.test.js index 4fb6590d..538d88d7 100644 --- a/test/content/kits_t5.test.js +++ b/test/content/kits_t5.test.js @@ -125,7 +125,7 @@ test('缇缇 T2 勇气的报偿: Sargon/Minos ops above 50 % HP get +20 ASPD', ( }); // ------------------------------------------------------------------------------------------------------------------ -test('烛煌 S3: hits every enemy in the skill range, BAT −1.3 s, burn bursts refill ammo; T1 熔点引爆 350 % + heal; T2 downed → revive', () => { +test('烛煌 S3: its target and the enemies within the 1.7 splash, BAT −1.3 s, burn bursts refill ammo; T1 熔点引爆 350 % + heal; T2 downed → revive', () => { const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_5_03_a', row: 10, col: 3 }], @@ -142,7 +142,7 @@ test('烛煌 S3: hits every enemy in the skill range, BAT −1.3 s, burn bursts approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-6, 'BAT 1.6 − 1.3 s'); h.run(1); const hitIds = new Set(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isSkill && c.dmg.isAttack).map((c) => c.target.id)); - assert.equal(hitIds.size, 3, 'group attack'); + assert.equal(hitIds.size, 3, 'group attack: the three stand within 1.7 of each other (PRTS 备注 "攻击溅射半径1.7")'); // a burn burst anywhere: +ammo, 熔点引爆 elemental damage and heal const e = h.b.enemies[0]; u.hp = 100; diff --git a/test/sim/feedback1e-skillrange.test.js b/test/sim/feedback1e-skillrange.test.js new file mode 100644 index 00000000..a81217dd --- /dev/null +++ b/test/sim/feedback1e-skillrange.test.js @@ -0,0 +1,281 @@ +// test/sim/feedback1e-skillrange.test.js — community report after 0.1.0 (batch 5, E1): "干员烛煌开启3技能时候攻击范围不会变". +// Measured: the sim already attacks with the skill range (烛煌 S3 4-11 while it runs — real matches, every range-changing +// skill of the mode below); what did not change was (1) the battle detail card's 攻击范围, which kept the base grid while +// the live stats beside it moved (shared/protocol.js unitStatsEntry `range` = Battle._refreshRange's grid), and +// (2) S3's "攻击变为群体攻击" hit every enemy INSIDE the 4-11 diamond instead of the official one target + a 1.7 splash +// (PRTS 烛煌 技能3 备注 "攻击溅射半径1.7"), so the fire never reached past the diamond. Same 备注: "补充后的弹药数量 +// 无法超过上限" — a burn burst's +2 ammo stops at the skill's ammo. +// Audit: every selectable skill whose text changes the attack range (攻击范围改变 / 扩大 / 缩小 / 缩短, 攻击距离+N) uses the +// official grid (skill rangeId, or the base grid grown by ability_range_forward_extend) while it runs, and the DEFAULT +// trigger still reads the initial range (PRTS 卫戍协议/帮助 "技能就绪,且即将进行普通攻击"). + +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 { absoluteRangeKeys, extendedGrid } from '../../server/sim/targeting.js'; +import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js'; +import { createRng } from '../../server/sim/rng.js'; +import { COLS } from '../../server/sim/constants.js'; +import { unitStatsEntry } from '../../shared/protocol.js'; + +const ds = getDefaultSource(); +const C = ds.raw.chess; +const BLAZE = 'chess_char_5_03_a'; +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o }); +const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); }; +const keysOf = (grid, u, ext = 0) => new Set(absoluteRangeKeys(grid, u.tileR, u.tileC, u.dir, ext)); +const sameSet = (a, b) => a.size === b.size && [...a].every((k) => b.has(k)); +const tileKey = (e) => Math.round(e.y) * COLS + Math.round(e.x); +const s3Grid = () => C[BLAZE].skills[2].rangeGrid; +const baseGrid = () => C[BLAZE].rangeGrid; +const blazeArena = (enemies, o = {}) => makeBattle({ + defs: { enemies: Object.fromEntries(enemies.map((e) => [e.key, dummy(e.key, e.o)])) }, + units: [{ chessId: o.id ?? BLAZE, row: 10, col: 3, dir: 'RIGHT', skillIndex: 2 }], + enemies: enemies.map((e) => ({ key: e.key, pos: e.pos })), + hooks: ['damaged', 'attack', 'skillStart', 'ammoUsed'], captureNoisy: true, autoFinish: false, timeLimit: 120, seed: 5, +}); + +test('data: 烛煌 S3 众恶的焚场 is the default skill (index 2), skill range 4-11 vs the attack range 3-1, 18 / 21 ammo', () => { + for (const id of [BLAZE, 'chess_char_5_03_b']) { + const c = C[id]; + assert.equal(c.skill.skillId, 'skchr_blaze2_3'); + assert.equal(c.skill.index, 2); + assert.equal(c.rangeId, '3-1'); + assert.equal(c.skill.rangeId, '4-11'); + assert.equal(c.skill.trigger.rule, 'DEFAULT', 'an attack-range change keeps the basic strategy (§20.2)'); + } + assert.equal(C[BLAZE].skill.bb['attack@trigger_time'], 18); + assert.equal(C.chess_char_5_03_b.skill.bb['attack@trigger_time'], 21); +}); + +test('烛煌 S3 attacks with the skill range: an enemy only in 4-11 is hit while it runs, an enemy only in 3-1 is not; the cast needs an enemy in 3-1', () => { + // (10,7) = [0,4]: S3 only; (11,3) = [1,0]: base range only + const h = blazeArena([{ key: 'enemy_far', pos: [10, 7] }]); + const u = h.unit(BLAZE); + h.step(); + u.skill.gainSp(1000); + h.run(5); + assert.equal(h.hooksOf('skillStart').length, 0, 'no enemy in the initial range: no cast (DEFAULT, "即将进行普通攻击")'); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0, 'nothing in reach of 3-1'); + const near = h.spawn('enemy_far', { pos: [11, 3] }); + assert.ok(h.runUntil(() => u.skill.active, 5), 'an enemy in 3-1: she casts on her next attack'); + assert.ok(sameSet(new Set(u.rangeKeys), keysOf(s3Grid(), u)), 'the range keys are 4-11'); + const mark = h.hooksOf('damaged').length; + h.run(2); + const hit = new Set(h.hooksOf('damaged').slice(mark).filter((c) => c.source === u && c.dmg.isAttack).map((c) => c.target)); + assert.ok(hit.has(h.enemy('enemy_far')), 'the S3-only tile is attacked'); + assert.ok(!hit.has(near), '[1,0] is not part of 4-11'); + done(h); +}); + +test('烛煌 S3 "攻击变为群体攻击" = one target + a 1.7 splash (PRTS 备注 "攻击溅射半径1.7"): it reaches past the diamond, never all of it at once', () => { + /** S3 running on an arena: (11,3) = [1,0] (base range only) makes her cast; returns her attacks and hits after that. */ + const s3Hits = (enemies) => { + const h = blazeArena([{ key: 'enemy_cast', pos: [11, 3] }, ...enemies]); + const u = h.unit(BLAZE); + h.step(); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 5), 'cast'); + const mark = h.hooksOf('attack').length; + const dmark = h.hooksOf('damaged').length; + h.run(3); + const attacks = h.hooksOf('attack').slice(mark).filter((c) => c.attacker === u); + const hits = h.hooksOf('damaged').slice(dmark).filter((c) => c.source === u && c.dmg.isAttack); + assert.ok(attacks.length >= 5, `several S3 attacks (${attacks.length})`); + const s3 = keysOf(s3Grid(), u); + for (const a of attacks) { + assert.equal(a.targets.length, 1, 'one target per attack'); + assert.ok(s3.has(tileKey(a.targets[0])), 'the target stands in 4-11'); + } + // every hit of an attack is its target or within 1.7 of it (中点判定) + const byAttack = new Map(); + for (const c of hits) { if (!byAttack.has(c.dmg.attackId)) byAttack.set(c.dmg.attackId, []); byAttack.get(c.dmg.attackId).push(c); } + for (const list of byAttack.values()) { + const main = list.find((c) => !c.dmg.isSplash); + assert.ok(main, 'a main hit'); + for (const c of list) assert.ok(Math.hypot(c.target.x - main.target.x, c.target.y - main.target.y) <= 1.7 + 1e-9, 'splash within 1.7'); + } + done(h); + return { h, hits, byAttack }; + }; + // (10,7) = [0,4] in 4-11; (10,8) = [0,5] outside it, 1 tile away: splashed + const a = s3Hits([{ key: 'enemy_main', pos: [10, 7] }, { key: 'enemy_out', pos: [10, 8] }]); + assert.ok(a.hits.some((c) => c.target === a.h.enemy('enemy_out') && c.dmg.isSplash), 'the splash reaches an enemy outside 4-11'); + assert.ok(!a.hits.some((c) => c.target === a.h.enemy('enemy_cast')), 'the base-only enemy is out of S3'); + // (10,7) = [0,4] and (12,5) = [2,2] both in 4-11 but 2.83 apart: one of them per attack, never both + const b = s3Hits([{ key: 'enemy_main', pos: [10, 7] }, { key: 'enemy_far', pos: [12, 5] }]); + for (const list of b.byAttack.values()) assert.equal(list.length, 1, 'one enemy per attack (the old group attack hit both)'); +}); + +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); + h.step(); + const out = h.enemy('enemy_out'); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 5)); + h.b.applyStatus(out, 'burnBurst', { duration: 10 }); + assert.ok(out.findBuff('burnBurst')); + const mark = h.hooksOf('damaged').length; + h.run(1.5); + const bonus = h.hooksOf('damaged').slice(mark).filter((c) => c.source === u && (c.dmg.tags || []).includes('blazeBurn')); + assert.ok(bonus.some((c) => c.target === out), 'the splashed burning enemy takes the bonus'); + for (const c of bonus.filter((x) => x.target === out)) assert.ok(Math.abs(c.dmg.amount - u.s.atk * u.def.skill.bb['attack@atk_scale']) < 1e-6); + assert.ok(!bonus.some((c) => c.target === h.enemy('enemy_main')), 'not on an enemy outside a burst'); + 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 }); + const u = h.unit(id); + const max = u.def.skill.bb['attack@trigger_time']; + h.step(); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 5)); + const first = u.skill.ammoLeft; // the casting attack already fired one bullet + assert.equal(first, max - 1); + h.b.dealDamage(null, h.enemy('enemy_main'), { type: 'element', element: 'burn', amount: 1000 }); + assert.equal(u.skill.ammoLeft, max, `${id}: +1 only — ${max} is the cap (it was ${first + 2})`); + h.b.dealDamage(null, h.enemy('enemy_b'), { type: 'element', element: 'burn', amount: 1000 }); + assert.equal(u.skill.ammoLeft, max, 'a full magazine stays full'); + h.runUntil(() => u.skill.ammoLeft <= max - 3, 5); + const l2 = u.skill.ammoLeft; + h.b.dealDamage(null, h.enemy('enemy_c'), { type: 'element', element: 'burn', amount: 1000 }); + assert.equal(u.skill.ammoLeft, l2 + u.def.skill.bb.ammo_recover, 'room for both: +2'); + done(h); + } +}); + +test('the live range grid (Battle._refreshRange → unit.liveRangeGrid, unitStatsEntry.range): 3-1, then 4-11 while S3 runs, then 3-1 again', () => { + const h = blazeArena([{ key: 'enemy_main', pos: [10, 5] }]); + const u = h.unit(BLAZE); + h.step(); + const rel = (g) => new Set(g.map(([r, c]) => `${r},${c}`)); + assert.ok(sameSet(rel(unitStatsEntry(u, u.s).range), rel(baseGrid())), 'before: the attack range'); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 5)); + const during = unitStatsEntry(u, u.s).range; + assert.ok(sameSet(rel(during), rel(s3Grid())), 'during S3: the skill range'); + assert.ok(sameSet(keysOf(during, u), new Set(u.rangeKeys)), 'exactly the tiles the sim attacks'); + u.skill.stop(); + assert.ok(sameSet(rel(unitStatsEntry(u, u.s).range), rel(baseGrid())), 'after: the attack range again'); + // an enemy has no range on its card + assert.equal(unitStatsEntry(h.enemy('enemy_main')).range, undefined); + done(h); +}); + +test('extendedGrid: the relative form of absoluteRangeKeys\' rangeExtend, for every facing (random grids)', () => { + const rng = createRng(77); + for (let n = 0; n < 200; n++) { + const g = []; + const cells = 1 + Math.floor(rng() * 8); + for (let i = 0; i < cells; i++) g.push([Math.floor(rng() * 7) - 3, Math.floor(rng() * 6) - 1]); + const ext = [0, 0.25, 1, 1.5, 2, 3][n % 6]; + for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) { + const want = new Set(absoluteRangeKeys(g, 10, 10, dir, ext)); + const got = new Set(absoluteRangeKeys(extendedGrid(g, ext), 10, 10, dir, 0)); + assert.ok(sameSet(got, want), `${JSON.stringify(g)} +${ext} ${dir}`); + } + } + assert.deepEqual(extendedGrid([[0, 0], [0, 1], [1, 0]], 1).map(String).sort(), ['0,0', '0,1', '0,2', '1,0', '1,1']); + assert.deepEqual(extendedGrid(null, 2), []); +}); + +// ---- audit: every attack-range change of the mode ----------------------------------------------------------------- + +/** "攻击范围改变 / 扩大 / 缩小 / 缩短" ("攻击范围与溅射范围扩大"), "攻击距离+N", "攻击范围改为…" — an attack-range change (plain text; not a 技能范围). */ +const RANGE_TEXT = /攻击范围(?:与溅射范围)?(?:改变|扩大|缩小|缩短)|攻击距离\+|攻击范围改为/; +/** Text-matched skills whose range the audit table checks by hand (their kit draws the range itself). */ +const BY_HAND = new Map([ + ['skchr_lionhd_2', 'instant: the burst hits the 3-3 skill range at its cast (kit onStart); no lasting range'], + ['skchr_fartth_3', '前方无限长的直线 (CUSTOM_RANGE kit line)'], + ['skchr_agoat2_3', '整个战场 (WHOLE_FIELD)'], +]); + +function rangeSkills() { + const out = []; + for (const c of Object.values(C)) { + if (!(c.visible || (c.isGolden && C[c.baseId]?.visible))) continue; + for (const s of c.skills || []) { + const ext = s.bb?.ability_range_forward_extend ?? s.bb?.['attack@ability_range_forward_extend'] ?? null; + const text = RANGE_TEXT.test(s.desc || ''); + if (!text && !(ext >= 1)) continue; + out.push({ c, s, ext: ext >= 1 ? Math.floor(ext) : 0, text }); + } + } + return out; +} + +test('audit: every selectable attack-range change attacks with the official grid while it runs and shows it as the live range', () => { + const list = rangeSkills(); + assert.ok(list.length >= 90, `normal + elite records (${list.length})`); + const seen = new Set(); + for (const { c, s, ext } of list) { + if (BY_HAND.has(s.skillId)) { seen.add(s.skillId); continue; } + const h = makeBattle({ units: [{ chessId: c.chessId, row: 10, col: 5, dir: 'UP', skillIndex: s.index }], autoFinish: false, timeLimit: 30 }); + const u = h.unit(c.chessId); + h.step(); + const permExt = u.s.rangeExtend; + const grid = s.rangeGrid && !ext ? s.rangeGrid : c.rangeGrid; + const want = keysOf(grid, u, permExt + ext); + if (s.skillType !== 'PASSIVE' && !/被动效果:攻击范围扩大/.test(s.desc)) { + assert.ok(u.skill.activate('test', { free: true }), `${c.chessId} ${s.name}: cast`); + h.step(); + assert.ok(u.skill.active, `${c.chessId} ${s.name}: running`); + } + assert.ok(sameSet(new Set(u.rangeKeys), want), `${c.chessId} S${s.index + 1} ${s.name}: the attack tiles`); + const live = unitStatsEntry(u, u._s).range; + assert.ok(sameSet(keysOf(live, u), want), `${c.chessId} S${s.index + 1} ${s.name}: the live range on the card`); + done(h); + } + assert.deepEqual([...seen].sort(), [...BY_HAND.keys()].sort(), 'the hand-checked skills are still in the data'); +}); + +test('audit: the DEFAULT trigger of an attack-range change reads the INITIAL range (PRTS "技能就绪,且即将进行普通攻击")', () => { + // an enemy on [0,4]: inside 4-11, outside 3-1 + for (const id of [BLAZE, 'chess_char_5_03_b']) { + const h = blazeArena([{ key: 'enemy_far', pos: [10, 7] }], { id }); + const u = h.unit(id); + h.step(); + u.skill.gainSp(1000); + h.run(4); + assert.equal(h.hooksOf('skillStart').length, 0, `${id}: no cast`); + done(h); + } +}); + +test('real product path: a BattleSpec (S3 by loadout) on act2 m01 with a real wave — 烛煌 picks her targets on 4-11 and splashes past it', () => { + // the round-5 template with its placeholder enemies as they stand (ground and air, both gates) + const tpl = ds.getWave('act1autochess_05'); + const spawns = tpl.spawns.map((s) => ({ time: s.time, enemyKey: s.key, routeIndex: s.routeIndex, count: s.count, interval: s.interval })); + const spec = buildBattleSpec({ + battleId: 'fb1e', fieldId: 'n:p1', kind: 'normal', seed: 4242, modeId: 'mode_multi_hard', round: 5, stageId: 'act2autochess_m01', + timeLimit: 110, players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [], units: [ + { uid: 1, kind: 'chess', chessId: BLAZE, row: 11, col: 6, dir: 'RIGHT', skillIndex: 2 }, + { uid: 2, kind: 'chess', chessId: 'chess_char_3_12_a', row: 10, col: 8, dir: 'RIGHT' }, + { uid: 3, kind: 'chess', chessId: 'chess_char_1_10_a', row: 11, col: 8, dir: 'RIGHT' }, + ] }], spawns, routes: tpl.routes, flags: {}, waveId: 'act1autochess_05', + }); + const b = createBattleFromSpec(spec, ds, { recordEvents: false, quiet: true }); + const u = b.allyUnits.find((x) => x.uid === 1); + assert.equal(u.def.skill.id, 'skchr_blaze2_3'); + let picks = 0, offGrid = 0, splashOut = 0, casts = 0; + b.on('skillStart', (c) => { if (c.unit === u) casts++; }); + b.on('attack', (c) => { + if (c.attacker !== u || !u.skill.active) return; + const s3 = keysOf(s3Grid(), u); + for (const t of c.targets) { picks++; if (!s3.has(tileKey(t)) && t.blockedBy !== u) offGrid++; } + }); + b.on('damaged', (c) => { + if (c.source !== u || !u.skill.active || !c.dmg.isSplash) return; + if (!keysOf(s3Grid(), u).has(tileKey(c.target))) splashOut++; + }); + for (let i = 0; i < 110 * 30 && !b.finished; i++) b.step(); + assert.equal(b.errors.length, 0); + assert.ok(casts >= 1, `S3 cast (${casts})`); + assert.ok(picks > 10, `S3 attacks (${picks})`); + assert.equal(offGrid, 0, 'every S3 target stands on 4-11'); + assert.ok(splashOut > 0, `splash victims outside 4-11 (${splashOut})`); +}); diff --git a/test/ui/feedback1e-range.test.js b/test/ui/feedback1e-range.test.js new file mode 100644 index 00000000..814145d4 --- /dev/null +++ b/test/ui/feedback1e-range.test.js @@ -0,0 +1,104 @@ +// Community report after 0.1.0 (batch 5, E1): "干员烛煌开启3技能时候攻击范围不会变". The sim attacked with S3's 4-11 +// (test/sim/feedback1e-skillrange.test.js); the battle detail card did not — its 攻击范围 mini-map kept the base 3-1 grid +// while the live stats beside it showed S3's ATK and interval. The card now draws the live entry's `range` +// (shared/protocol.js unitStatsEntry: the grid the unit attacks with now) — in battle from the browser's own sim, in prep +// from the start-of-battle preview — and names a whole-field range instead of drawing it. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { unitStatsEntry } from '../../shared/protocol.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +// the browser data store reads the real data files from disk (the card renders below) +globalThis.fetch = async (url) => { + const name = String(url).split('/').pop(); + try { + const body = readFileSync(path.join(ROOT, 'data', name), 'utf8'); + return { ok: true, status: 200, json: async () => JSON.parse(body) }; + } catch { + return { ok: false, status: 404, json: async () => ({}) }; + } +}; + +const { ChessDetail, RangeGrid, cardRangeGrid, FIELD_WIDE_CELLS } = await import('../../public/js/ui/detailPanel.js'); +const { data } = await import('../../public/js/data.js'); + +function* walk(v) { + if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; } + if (!v || typeof v !== 'object') return; + yield v; + if (typeof v.type === 'function' && v.type.name === 'RangeGrid') { yield* walk(v.type(v.props)); return; } + yield* walk(v.props?.children); +} +const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c); +const onCells = (tree) => [...walk(tree)].filter((n) => n.type === 'i' && hasClass(n, 'on')).length; +const BLAZE = 'chess_char_5_03_a'; + +/** 烛煌's live entry from a real battle: before / during her S3. */ +function blazeLive() { + const h = makeBattle({ + defs: { enemies: { enemy_a: enemyRec({ key: 'enemy_a', hp: 1e7, speed: 0 }) } }, + units: [{ chessId: BLAZE, row: 10, col: 3, dir: 'RIGHT', skillIndex: 2 }], enemies: [{ key: 'enemy_a', pos: [10, 4] }], + autoFinish: false, timeLimit: 60, + }); + const u = h.unit(BLAZE); + h.step(); + const before = { ...unitStatsEntry(u, u._s), src: 'battle' }; + u.skill.gainSp(1000); + h.runUntil(() => u.skill.active, 5); + const during = { ...unitStatsEntry(u, u._s), src: 'battle' }; + return { before, during }; +} + +test('the battle card\'s 攻击范围 follows 烛煌 S3: 10 tiles (3-1) before, 13 (4-11) while it runs', async () => { + await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items'); + const c = data.lookup('chess', BLAZE); + const { before, during } = blazeLive(); + assert.equal(before.range.length, 10); + assert.equal(during.range.length, 13); + const stats = (live) => ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null, live }).find((b) => b.key === 'stats'); + assert.equal(onCells(stats(before)), 10, 'the base range'); + assert.equal(onCells(stats(during)), 13, 'the S3 range on the card'); + assert.equal(onCells(stats(null)), 10, 'no live entry: the record\'s attack range'); +}); + +test('cardRangeGrid: the live range first, else the loadout record\'s attack range; a whole-field range is named, not drawn', async () => { + await data.loadAll('chess'); + const c = data.lookup('chess', BLAZE); + assert.deepEqual(cardRangeGrid({ range: [[0, 0], [0, 1]] }, c, c), [[0, 0], [0, 1]]); + assert.equal(cardRangeGrid({ range: [] }, c, c), c.rangeGrid, 'an empty live range is ignored'); + assert.equal(cardRangeGrid(null, c, c), c.rangeGrid); + assert.equal(cardRangeGrid({ atk: 1 }, null, c), c.rangeGrid, 'an entry without range (an older server): the record'); + const whole = []; + for (let dr = -18; dr <= 18; dr++) for (let dc = -20; dc <= 20; dc++) whole.push([dr, dc]); + assert.ok(whole.length >= FIELD_WIDE_CELLS); + const v = RangeGrid({ grid: whole }); + assert.ok(hasClass(v, 'rgrid-all')); + assert.equal(v.props.children, '全场'); + assert.ok(!hasClass(RangeGrid({ grid: c.rangeGrid }), 'rgrid-all')); +}); + +test('a whole-field skill (纯烬艾雅法拉 S3 "攻击范围扩大至整个战场") reaches the card as such', async () => { + await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items'); + const id = 'chess_char_6_20_a'; + const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5, dir: 'RIGHT', skillIndex: 2 }], autoFinish: false, timeLimit: 30 }); + const u = h.unit(id); + h.step(); + assert.ok(u.skill.activate('test', { free: true })); + h.step(); + const live = { ...unitStatsEntry(u, u._s), src: 'battle' }; + assert.ok(live.range.length >= FIELD_WIDE_CELLS); + const stats = ChessDetail({ chess: data.lookup('chess', id), piece: null, editable: false, bonds: [], loadout: null, live }).find((b) => b.key === 'stats'); + assert.ok([...walk(stats)].some((n) => hasClass(n, 'rgrid-all')), '全场'); +}); + +test('the game screen hands the card the live entry it already reads (battle runner / m.unitStats)', () => { + const src = readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8'); + assert.match(src, /grid=\$\{cardRangeGrid\(live, fr, c\)\}/); + const game = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); + assert.match(game, /battleRunner\.unitStats\(uid, fid\)/); +}); From 5963a40d2d95407fdc475f752eac82b0dde4ad89 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 05:46:04 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feedback1e=20WP:=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20=E4=BF=A1=E4=BB=B0=E6=90=85=E6=8B=8C=E6=9C=BA=20S3?= =?UTF-8?q?=20ignores=20=E6=94=BB=E5=87=BB=E8=B7=9D=E7=A6=BB,=20bounded=20?= =?UTF-8?q?range=20mini-map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 信仰搅拌机 S3 退休前布道 attacks / counters on 3-13 exactly: SPT-Y's 攻击距离+1 does not reach it (PRTS 技能3 备注 "此技能的攻击范围不受“攻击距离”属性影响"; PRTS 修正 of the module text "(部分技能不受此影响)"). New SkillSpec flag targeting.noRangeExtend, read by Battle._refreshRange; S1 / S2 keep the +1. - The detail card's 攻击范围 draws a grid larger than its box (远牙 S3's 21-tile line) with smaller cells in the box's own space (rangeGridStyle, RANGE_FIT 6 columns × 7 rows), so the live stats beside it stay readable. - 荒芜拉普兰德 S1's whole-field grid only selects targets (no rangeId, no 攻击范围 text): targeting.showOwnRange keeps her 3-1 on the card. - 烛煌 S3's bonus on burning enemies is dealt before the attack's damage (备注 "于攻击造成伤害前判定元素爆发并造成元素伤害"), main and splash. - liveRangeGrid is the grid itself when nothing extends it (no copy per refresh). - Audit test: 攻击距离加长 / 缩短 (深巡 S2, 山 S2) and every module of the loadout; real BattleSpec for 信仰搅拌机 SPT-Y. --- docs/SIM.md | 14 +- public/js/ui/detailPanel.js | 20 ++- server/sim/Battle.js | 23 ++-- server/sim/content/kits/tier4.js | 13 +- server/sim/content/kits/tier5.js | 20 +-- server/sim/content/kits/tier6.js | 5 +- server/sim/skills.js | 9 +- server/sim/targeting.js | 3 +- shared/protocol.js | 5 +- test/sim/feedback1e-skillrange.test.js | 178 ++++++++++++++++++++++--- test/ui/feedback1e-range.test.js | 44 +++++- 11 files changed, 276 insertions(+), 58 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index e4869e91..73566069 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -298,8 +298,10 @@ Guarantees content can rely on (pinned by `test/sim/robustness.test.js`): (board tile: auto-redeploys land there), dir ('UP'|'RIGHT'|'DOWN'|'LEFT'; getters `fwd` = forward vector, `facing` = 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 relative grid behind rangeKeys — a running skill's range, rangeExtend grown on, `targeting.js -extendedGrid`; no extra keys — read by the detail card through `shared/protocol.js unitStatsEntry` `range`), +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`), 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, …}.` @@ -369,7 +371,9 @@ defIgnoreFlat defIgnorePct resIgnoreFlat resIgnorePct dodgePhys dodgeArts spReco hpRegenRatio spCostFlat moveFlat massFlat` (重量 levels: 失重 = `massFlat: −1`; never edit `base.massLevel`); multiplicative: `atkMul defMul hpMul resMul moveMul dmgDealtMul dmgTakenMul physTakenMul artsTakenMul trueTakenMul elemTakenMul elementalTakenMul healingDealtMul healingTakenMul spRecoveryMul redeployMul atkScaleMul physDealtMul artsDealtMul`. -A `rangeExtend` on a `persist` never-expiring buff is **permanent**: it also widens the initial range (§7.1). +A `rangeExtend` on a `persist` never-expiring buff is **permanent**: it also widens the initial range (§7.1). It widens +a running skill's range too, unless that skill's range ignores 攻击距离 (`targeting.noRangeExtend`; PRTS 数值范围 "根据配置 +不同,任何范围都可以受/不受该属性影响" — 信仰搅拌机 S3 "此技能的攻击范围不受“攻击距离”属性影响"). **Flags:** `stun freeze sleep silence disarm stealth invulnerable unblockable levitate fear cold reveal bind noHeal untargetable blockFly noMove noSp burstLock hidden attract float noDisplace isolated camou` (`float` = 近地悬浮 (an air unit, `Unit.isFlying`), `noDisplace` = 失衡免疫 (`displace()` moves nothing), `isolated` = 孤立 ("无法被同阵营选中": no ally @@ -698,7 +702,9 @@ Element conventions of the kits (user playtest #5 #3; official term dictionary: heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer) mods: { …mod keys }, // buff while active (instant: only during the pending attack) flags: { …flags }, - targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly }, + targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly, + noRangeExtend /* the skill's range ignores the unit's 攻击距离 (rangeExtend): 信仰搅拌机 S3, PRTS 备注 */, + showOwnRange /* rangeGrid only selects targets, no official range change: the card keeps the unit's own range */ }, attack: { dmgType, atkScale, healScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul, chain: {count, falloff, radius, sluggish}, heal: {mode, count, falloff, hpAtMost /* targets at or below this HP ratio only */}, onHitStatus: {key, duration, value}, onHit(ctx), // once per attack (main target; target may be null for a splash landing) diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index d26cec49..2c8684c0 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -19,7 +19,8 @@ // value is coloured against the unit's base like the official card — green when it helps (higher, or a shorter attack // interval) with the difference beside it, red when it hurts — and a 实时 / 开战时 tag says which it is. The 攻击范围 // mini-map follows the live entry's `range` too (cardRangeGrid: the grid the unit attacks with now — a running skill's -// range such as 烛煌 S3's 4-11, rangeExtend included; community report E1 after 0.1.0, it used to stay the base grid). +// range such as 烛煌 S3's 4-11, rangeExtend included; community report E1 after 0.1.0, it used to stay the base grid); +// a grid larger than the box (RANGE_FIT) draws smaller cells (rangeGridStyle), a whole-field one reads 全场. import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js'; import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; @@ -42,6 +43,21 @@ const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', no /** A range grid of at least this many tiles covers the field (纯烬艾雅法拉 S3 "攻击范围扩大至整个战场"): named, not drawn. */ export const FIELD_WIDE_CELLS = 400; +/** + * Columns × rows of the mini-map's own cells (.14rem, 2px apart) the card's range box holds without growing: the widest + * record attack range (灰毫's 6 columns) and the tallest live one (银灰 S3's 7 rows). A larger grid — a live skill range + * such as 远牙 S3's line to the field's edge (21 tiles) — draws smaller cells, edge to edge, in that space, so the box keeps + * its size and the stats beside it stay readable. + */ +export const RANGE_FIT = Object.freeze({ cols: 6, rows: 7 }); + +/** Inline style of the mini-map (rangeGridBox `box`): its columns, and smaller cells when the grid exceeds RANGE_FIT. */ +export function rangeGridStyle(box) { + const cols = `grid-template-columns:repeat(${box.cols}, var(--rg))`; + if (box.cols <= RANGE_FIT.cols && box.rows <= RANGE_FIT.rows) return cols; + const fit = (n, k) => `calc((${k} * .14rem + ${(k - 1) * 2}px) / ${n})`; + return `${cols};gap:0;--rg:max(1px, min(.14rem, ${fit(box.cols, RANGE_FIT.cols)}, ${fit(box.rows, RANGE_FIT.rows)}))`; +} /** * The grid the card's 攻击范围 shows: the live entry's `range` (shared/protocol.js unitStatsEntry — what the unit attacks @@ -67,7 +83,7 @@ export function RangeGrid({ grid, class: cls }) { cells.push(html``); } } - return html`
${cells}
`; + return html`
${cells}
`; } function Stat({ k, v, sub, tone = null, title }) { diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 65fe14b3..64525235 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1590,18 +1590,21 @@ export class Battle { /** * Recompute a unit's absolute range tile keys: `rangeKeys` / `rangeKeySet` = current range (skill range override + - * rangeExtend) ∪ `unit.extraRangeKeys` (content extra targets — setExtraRange); `baseRangeKeys` = the INITIAL range - * of the DEFAULT skill trigger: the unit's own grid + its permanent rangeExtend (`s.baseRangeExtend`: persistent, - * never-expiring buffs — talents, modules, bonds), no skill range, no temporary extend, no extra keys; - * `liveRangeGrid` = the relative grid (facing RIGHT) behind `rangeKeys` without the extra keys (targeting.js - * extendedGrid) — the detail card's live 攻击范围 (shared/protocol.js unitStatsEntry `range`; community report E1 - * after 0.1.0: the card kept the base grid while 烛煌 S3 attacked with 4-11). Rebuilt on deploy / relocate / skill - * range switches and whenever either extend changes (rangeChanged). + * rangeExtend — the unit's own 攻击距离 unless the running skill's range ignores it, `targeting.noRangeExtend`: 信仰搅拌机 + * S3, PRTS 备注 "此技能的攻击范围不受'攻击距离'属性影响" — plus the skill's own `targeting.rangeExtend`) ∪ + * `unit.extraRangeKeys` (content extra targets — setExtraRange); `baseRangeKeys` = the INITIAL range of the DEFAULT + * skill trigger: the unit's own grid + its permanent rangeExtend (`s.baseRangeExtend`: persistent, never-expiring + * buffs — talents, modules, bonds), no skill range, no temporary extend, no extra keys; `liveRangeGrid` = the + * relative grid (facing RIGHT) of the 攻击范围 the detail card shows (shared/protocol.js unitStatsEntry `range`; + * community report E1 after 0.1.0: the card kept the base grid while 烛煌 S3 attacked with 4-11): the grid behind + * `rangeKeys` without the extra keys (targeting.js extendedGrid; the grid itself when nothing extends it), or the + * unit's own range while a skill's grid only selects targets (`targeting.showOwnRange`: 荒芜拉普兰德 S1, no official + * range change). Rebuilt on deploy / relocate / skill range switches and whenever either extend changes (rangeChanged). */ _refreshRange(u) { const tg = u.skill && u.skill.active ? u.skill.spec.targeting : null; const grid = (tg && tg.rangeGrid) || u.rangeGrid || [[0, 0]]; - const ext = u.s.rangeExtend + ((tg && tg.rangeExtend) || 0); + const ext = (tg && tg.noRangeExtend ? 0 : u.s.rangeExtend) + ((tg && tg.rangeExtend) || 0); u._rangeExtend = u.s.rangeExtend; u._baseExtend = u.s.baseRangeExtend; const keys = absoluteRangeKeys(grid, u.tileR, u.tileC, u.dir, ext); @@ -1610,7 +1613,9 @@ export class Battle { if (extra) for (const k of extra) if (!set.has(k)) { set.add(k); keys.push(k); } u.rangeKeys = keys; u.rangeKeySet = set; - u.liveRangeGrid = extendedGrid(grid, ext); + const own = !!(tg && tg.showOwnRange); + const lg = own ? u.rangeGrid || [[0, 0]] : grid, le = own ? u.s.rangeExtend : ext; + u.liveRangeGrid = le > 0 ? extendedGrid(lg, le) : lg; if (u.kind !== 'device') u.baseRangeKeys = absoluteRangeKeys(u.rangeGrid || [[0, 0]], u.tileR, u.tileC, u.dir, u.s.baseRangeExtend); if (u.skill) u.skill._trigKeys = null; // CUSTOM_RANGE trigger grid is relative to the tile } diff --git a/server/sim/content/kits/tier4.js b/server/sim/content/kits/tier4.js index a55983ad..54addad4 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -166,7 +166,11 @@ function lonely(battle, unit, diag = false) { } return true; } -/** Permanent forward range extension ("攻击距离+1(不受技能攻击范围变化影响)": every range, skill ranges included). */ +/** + * Permanent forward range extension (攻击距离 +n): every range, skill ranges included — except a skill whose range ignores + * 攻击距离 (`targeting.noRangeExtend`). SPT-Y's "攻击距离+1(不受技能攻击范围变化影响)" reads, as PRTS 修正 it, "(部分技能不受 + * 此影响)" (原因: 语序颠倒、表述模糊) — 信仰搅拌机 S3 备注 "此技能的攻击范围不受'攻击距离'属性影响". + */ const rangeUp = (battle, unit, n = 1) => battle.addBuff(unit, { key: 'module:range', mods: { rangeExtend: n }, persist: true, allowDead: true }); /** * Module "攻击范围扩大" given as a new range (the selected module's data-only talent `rangeGrid`, e.g. SPC-X / RIN-X: @@ -197,7 +201,7 @@ function pullToFront(battle, unit, e, force) { const kits = { // ===== 信仰搅拌机 (shotprotector) S3 退休前布道 — ammo 30 counters; talents 扫射迎宾仪礼 / 架盾送客仪礼; module: reveal // S1 铳骑主考官 (自动触发 ⇒ DEFAULT: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo; - // a fatal hit is blocked for `ammo_cost` bullets); module SPT-Y 老朋友: range +1 + // a fatal hit is blocked for `ammo_cost` bullets); module SPT-Y 老朋友: range +1 (not on S3's range, PRTS 备注) chess_char_4_01_a: (bb, chess, def) => { const t0 = tbb(def, 0), t1 = tbb(def, 1); const counterMax = Math.max(1, Math.floor(num(bb['attack@max_target'], 3))); @@ -233,7 +237,8 @@ const kits = { skill: { kind: 'ammo', ammo: num(bb['attack@trigger_time'], 30), mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) }, - targeting: g ? { rangeGrid: g } : undefined, + // 3-13 exactly: SPT-Y's 攻击距离+1 does not reach it (PRTS 备注; reviewer of community report E1 after 0.1.0) + targeting: g ? { rangeGrid: g, noRangeExtend: true } : undefined, attack: { noAttack: true }, // 停止主动攻击敌人 (ammo is spent by counters) onStart({ battle, unit }) { unit.mem.counterReady = -Infinity; @@ -281,7 +286,7 @@ const kits = { if (sk.ammoLeft <= 0) sk.end('ammo'); }, { owner: unit }); } - // module SPT-Y (elite, 老朋友): 攻击距离+1 (kept whatever range the skill sets) + // module SPT-Y (elite, 老朋友): 攻击距离+1 — S1 / S2 keep it, S3's 3-13 does not (noRangeExtend above) if (num(def.traitBb?.ability_range_forward_extend, 0) > 0) rangeUp(battle, unit, num(def.traitBb.ability_range_forward_extend, 1)); // module SPT-X (elite default): 攻击范围内敌人的隐匿效果失效 if (moduleIs(def, 'uniequip_002_rmixer')) whileDeployed(battle, unit, AURA, () => reveal(battle, enemiesOnRange(battle, unit))); diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index b41d65df..c64dd7b5 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -562,9 +562,10 @@ const KITS = { // 烛煌 — S3 众恶的焚场 (ammo): skill range 4-11 (the targets; the DEFAULT cast still needs an enemy in 3-1), ATK +, // BAT −1.3 s, "攻击变为群体攻击" = one target + a BLAZE_S3_SPLASH (1.7) splash around it (PRTS 备注 "攻击溅射半径1.7", // 中点判定 — so the fire reaches past the diamond; it used to hit every enemy inside it instead, community report E1 - // after 0.1.0), +attack@atk_scale ATK elemental damage to every enemy of the attack in a burn burst (main and splash; - // after the hit [ASSUMED] — the 备注 checks it before); loses 3 % max HP/s; any burn burst on the field refills - // ammo_recover bullets, never above the skill's ammo ("补充后的弹药数量无法超过上限"). + // 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 ("补充后的弹药数量无法超过上限"). // 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. @@ -630,12 +631,7 @@ 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, - onEachHit({ battle, unit, target }) { - if (target && target.alive && target.findBuff('burnBurst')) elementHit(battle, unit, target, unit.s.atk * num(bb['attack@atk_scale']), 'blazeBurn', 'burn'); - }, - }, + attack: { splashRadius: BLAZE_S3_SPLASH }, onStart({ unit }) { unit.mem.blazeAcc = 0; }, onTick({ battle, unit, dt }) { if (!(lose > 0)) return; @@ -686,6 +682,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 备注) + 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; + 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); battle.on('elementBurst', (c) => { // S3: burn bursts refill ammo, up to the skill's ammo const sk = unit.skill; diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 95650c71..8d31bc92 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -2754,11 +2754,12 @@ function whitw2(bb, chess, def) { const skillGridW = skillGridOf(def); const skills = { // S1 慵怠者悲鸣: passive 浮游单元+1 (trait: one more hit per attack); toggled on: ATK +atk and the drones lock a random - // non-moving enemy anywhere on the field (re-locking when it moves or falls; install), else her range + // non-moving enemy anywhere on the field (re-locking when it moves or falls; install), else her range. The whole-field + // grid only selects those targets — no rangeId, no 攻击范围 in the text — so the card keeps her 3-1 (showOwnRange) skchr_whitw2_1: { kind: 'toggle', mods: { atkPct: num(bb.atk) }, - targeting: { rangeGrid: WHOLE_FIELD }, + targeting: { rangeGrid: WHOLE_FIELD, showOwnRange: true }, onStart({ unit }) { unit.mem.lazyLock = null; }, }, // S2 逐猎狂飙: 浮游单元+attack@cnt, skill range, ATK +atk: every drone locks a random enemy of the range until it falls diff --git a/server/sim/skills.js b/server/sim/skills.js index 2b148d29..d2989afd 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -32,7 +32,8 @@ // instant (onStart + optional one-shot attack override for the next attack), charges (= instant with charges), // passive (always on from deployment, no SP), toggle (stays on until death once activated). // SkillSpec fields (all optional): kind, duration, ammo, spCost, initSp, charges, spType, trigger, -// mods, flags, targeting {maxTargets, rangeGrid, priority, allInRange, rangeExtend}, +// mods, flags, targeting {maxTargets, rangeGrid, priority, allInRange, rangeExtend, noRangeExtend (the range ignores +// the unit's 攻击距离), showOwnRange (rangeGrid only selects targets: the detail card keeps the unit's own range)}, // attack {dmgType, atkScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul, onHit, heal…}, // heal (bool: heal-type skill for the trigger rule), onStart(ctx), onEnd(ctx), onHit(ctx), onAttack(ctx), onTick(ctx). // ctx passed to spec callbacks: { battle, unit, skill, bb, target?, dealt?, targets?, dt?, reason? }; onAttack's ctx @@ -43,6 +44,8 @@ import { absoluteRangeKeys, canTargetEnemy } from './targeting.js'; import { AUTO_OP_COOLDOWN, COLS, ROWS } from './constants.js'; const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE', 'SKILL_RANGE', 'GDGLOW_SKILL_2']); +/** True when a SkillSpec `targeting` changes the unit's range while the skill runs (Battle._refreshRange). */ +const changesRange = (tg) => !!(tg && (tg.rangeGrid || tg.rangeExtend || tg.noRangeExtend)); /** Enemies that satisfy a content trigger range (any targetable enemy, flyers included). */ const TRIGGER_PROFILE = Object.freeze({ canHitFly: true }); /** Every tile of the stage (GDGLOW_SKILL_2: the whole field). */ @@ -194,12 +197,12 @@ export class SkillRuntime { if (s.mods || s.flags) { this.battle.addBuff(this.unit, { key: this._buffKey, mods: s.mods || null, flags: s.flags || null, tags: ['skill'] }); } - if (s.targeting && (s.targeting.rangeGrid || s.targeting.rangeExtend)) this.battle._refreshRange(this.unit); + if (changesRange(s.targeting)) this.battle._refreshRange(this.unit); } _removeMods() { this.battle.removeBuff(this.unit, this._buffKey); - if (this.spec.targeting && (this.spec.targeting.rangeGrid || this.spec.targeting.rangeExtend)) this.battle._refreshRange(this.unit); + if (changesRange(this.spec.targeting)) this.battle._refreshRange(this.unit); } /** Add SP (fires the `spGain` hook). Ignored while a duration/ammo/toggle skill runs (its bar shows the skill). */ diff --git a/server/sim/targeting.js b/server/sim/targeting.js index d966a5d5..323032e0 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -46,7 +46,8 @@ export function absoluteRangeKeys(grid, r, c, dir, extend = 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 absoluteRangeKeys builds, deduplicated, junk - * entries dropped. Battle._refreshRange keeps it as `unit.liveRangeGrid` (the detail card's live 攻击范围). + * 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]>} diff --git a/shared/protocol.js b/shared/protocol.js index d4f2eb05..c76e62ba 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -194,8 +194,9 @@ const statView = (x) => ({ * (s), block, move speed — rounded for display (the sim keeps floats), plus the current HP. The shape of the * `m.unitStats` units (Match.unitStats: what the board's units start their next battle with) and of the browser * runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid - * (`[dRow, dCol]`, facing RIGHT) it attacks with now — a running skill's range, rangeExtend included (the sim's - * `unit.liveRangeGrid`, Battle._refreshRange; community report E1 after 0.1.0: 烛煌 S3's 4-11 never reached the card). + * (`[dRow, dCol]`, facing RIGHT) it attacks with now — a running skill's range, rangeExtend included, not a kit's + * target-selection grid (the sim's `unit.liveRangeGrid`, Battle._refreshRange; community report E1 after 0.1.0: 烛煌 + * S3's 4-11 never reached the card). * @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any } | null} u * @param {any} [s] aggregated stats (missing ⇒ the base) * @returns {{ id: number|null, uid: number|null, defId: string|null, hp: number, alive: boolean, maxHp: number, atk: number, diff --git a/test/sim/feedback1e-skillrange.test.js b/test/sim/feedback1e-skillrange.test.js index a81217dd..177bd7b4 100644 --- a/test/sim/feedback1e-skillrange.test.js +++ b/test/sim/feedback1e-skillrange.test.js @@ -5,9 +5,13 @@ // (2) S3's "攻击变为群体攻击" hit every enemy INSIDE the 4-11 diamond instead of the official one target + a 1.7 splash // (PRTS 烛煌 技能3 备注 "攻击溅射半径1.7"), so the fire never reached past the diamond. Same 备注: "补充后的弹药数量 // 无法超过上限" — a burn burst's +2 ammo stops at the skill's ammo. -// Audit: every selectable skill whose text changes the attack range (攻击范围改变 / 扩大 / 缩小 / 缩短, 攻击距离+N) uses the -// official grid (skill rangeId, or the base grid grown by ability_range_forward_extend) while it runs, and the DEFAULT -// trigger still reads the initial range (PRTS 卫戍协议/帮助 "技能就绪,且即将进行普通攻击"). +// The bonus on burning enemies comes BEFORE the attack's damage (备注 "于攻击造成伤害前判定元素爆发并造成元素伤害"). +// Audit: every selectable skill whose text changes the attack range (攻击范围改变 / 扩大 / 缩小 / 缩短, 攻击距离+N / 加长 / +// 缩短) uses the official grid (skill rangeId, or the base grid grown by ability_range_forward_extend) while it runs — +// with every module of the loadout: a module's 攻击距离 widens it too, except where the skill's range ignores 攻击距离 +// (信仰搅拌机 S3 with SPT-Y, PRTS 备注 "此技能的攻击范围不受'攻击距离'属性影响") — and the DEFAULT trigger still reads the +// initial range (PRTS 卫戍协议/帮助 "技能就绪,且即将进行普通攻击"). A skill grid that only selects targets (荒芜拉普兰德 +// S1: no rangeId, no 攻击范围 text) leaves the card on the unit's own range. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -18,6 +22,7 @@ import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js' import { createRng } from '../../server/sim/rng.js'; import { COLS } from '../../server/sim/constants.js'; import { unitStatsEntry } from '../../shared/protocol.js'; +import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../../shared/loadoutRecord.js'; const ds = getDefaultSource(); const C = ds.raw.chess; @@ -126,6 +131,33 @@ test('烛煌 S3: the +60 % ATK elemental damage hits every enemy of the attack i done(h); }); +test('烛煌 S3: the burst bonus is dealt BEFORE the attack\'s damage (PRTS 备注 "于攻击造成伤害前判定元素爆发并造成元素伤害")', () => { + // enemy_low dies to one S3 hit: the bonus still lands (it came first); every bonus precedes its hit on the same enemy + const h = blazeArena([{ key: 'enemy_cast', pos: [11, 3] }, { key: 'enemy_main', pos: [10, 7] }, { key: 'enemy_low', pos: [10, 8], o: { hp: 30 } }]); + const u = h.unit(BLAZE); + h.step(); + const main = h.enemy('enemy_main'), low = h.enemy('enemy_low'); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 5)); + h.b.applyStatus(main, 'burnBurst', { duration: 10 }); + h.b.applyStatus(low, 'burnBurst', { duration: 10 }); + const mark = h.hooksOf('damaged').length; + h.run(1.5); + const ev = h.hooksOf('damaged').slice(mark).filter((c) => c.source === u); + const isBonus = (c) => (c.dmg.tags || []).includes('blazeBurn'); + assert.ok(ev.some((c) => c.target === low && isBonus(c)), 'the enemy the hit kills still takes the bonus'); + assert.ok(!low.alive, 'and dies'); + let pairs = 0; + for (let i = 0; i < ev.length; i++) { + if (!ev[i].dmg.isAttack) continue; + const prev = ev.slice(0, i).reverse().find((c) => c.target === ev[i].target); + assert.ok(prev && isBonus(prev), `the hit on ${ev[i].target.defId} follows its bonus`); + pairs++; + } + assert.ok(pairs >= 2, `bonus → hit pairs (${pairs})`); + 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 }); @@ -185,8 +217,13 @@ test('extendedGrid: the relative form of absoluteRangeKeys\' rangeExtend, for ev // ---- audit: every attack-range change of the mode ----------------------------------------------------------------- -/** "攻击范围改变 / 扩大 / 缩小 / 缩短" ("攻击范围与溅射范围扩大"), "攻击距离+N", "攻击范围改为…" — an attack-range change (plain text; not a 技能范围). */ -const RANGE_TEXT = /攻击范围(?:与溅射范围)?(?:改变|扩大|缩小|缩短)|攻击距离\+|攻击范围改为/; +/** + * "攻击范围改变 / 扩大 / 缩小 / 缩短" ("攻击范围与溅射范围扩大"), "攻击距离+N / 加长 / 缩短", "攻击范围改为…" — an attack-range + * change (plain text; not a 技能范围). + */ +const RANGE_TEXT = /攻击范围(?:与溅射范围)?(?:改变|扩大|缩小|缩短)|攻击距离(?:\+|加长|缩短)|攻击范围改为/; +/** Skills whose range ignores 攻击距离 (a module's ability_range_forward_extend): PRTS 信仰搅拌机 S3 备注. */ +const NO_EXTEND = new Set(['skchr_rmixer_3']); /** Text-matched skills whose range the audit table checks by hand (their kit draws the range itself). */ const BY_HAND = new Map([ ['skchr_lionhd_2', 'instant: the burst hits the 3-3 skill range at its cast (kit onStart); no lasting range'], @@ -208,29 +245,105 @@ function rangeSkills() { return out; } -test('audit: every selectable attack-range change attacks with the official grid while it runs and shows it as the live range', () => { +test('audit: every selectable attack-range change attacks with the official grid while it runs and shows it as the live range — with every module', () => { const list = rangeSkills(); - assert.ok(list.length >= 90, `normal + elite records (${list.length})`); + assert.ok(list.length >= 94, `normal + elite records (${list.length})`); + for (const id of ['skchr_udflow_2', 'skchr_f12yin_2']) assert.ok(list.some((x) => x.s.skillId === id), `${id}: 攻击距离加长 / 缩短`); const seen = new Set(); + let runs = 0, extended = 0; for (const { c, s, ext } of list) { if (BY_HAND.has(s.skillId)) { seen.add(s.skillId); continue; } - const h = makeBattle({ units: [{ chessId: c.chessId, row: 10, col: 5, dir: 'UP', skillIndex: s.index }], autoFinish: false, timeLimit: 30 }); - const u = h.unit(c.chessId); - h.step(); - const permExt = u.s.rangeExtend; - const grid = s.rangeGrid && !ext ? s.rangeGrid : c.rangeGrid; - const want = keysOf(grid, u, permExt + ext); - if (s.skillType !== 'PASSIVE' && !/被动效果:攻击范围扩大/.test(s.desc)) { - assert.ok(u.skill.activate('test', { free: true }), `${c.chessId} ${s.name}: cast`); + const mods = [null, ...(Array.isArray(c.modules) && c.modules.length ? ['none', ...c.modules.map((m) => m.uniEquipId)] : [])]; + for (const mid of mods) { + const rec = loadoutRecord(c, resolveRecordLoadout(c, { skillIndex: s.index, ...(mid ? { moduleId: mid } : {}) })); + // the module's 攻击距离 (official data) — not 空弦 ISW-A's, which works "在集成战略中" only + const permExt = /集成战略/.test(String(rec.trait?.moduleDesc ?? '')) ? 0 : Math.floor(Number(rec.trait?.bb?.ability_range_forward_extend) || 0); + const h = makeBattle({ units: [{ chessId: c.chessId, row: 10, col: 5, dir: 'UP', skillIndex: s.index, ...(mid ? { moduleId: mid } : {}) }], autoFinish: false, timeLimit: 30 }); + const u = h.unit(c.chessId); h.step(); - assert.ok(u.skill.active, `${c.chessId} ${s.name}: running`); + 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); + if (permExt) extended++; + if (s.skillType !== 'PASSIVE' && !/被动效果:攻击范围扩大/.test(s.desc)) { + assert.ok(u.skill.activate('test', { free: true }), `${tag}: cast`); + h.step(); + assert.ok(u.skill.active, `${tag}: running`); + } + assert.ok(sameSet(new Set(u.rangeKeys), want), `${tag}: the attack tiles (${u.rangeKeys.length} vs ${want.size})`); + const live = unitStatsEntry(u, u._s).range; + assert.ok(sameSet(keysOf(live, u), want), `${tag}: the live range on the card`); + done(h); + runs++; } - assert.ok(sameSet(new Set(u.rangeKeys), want), `${c.chessId} S${s.index + 1} ${s.name}: the attack tiles`); - const live = unitStatsEntry(u, u._s).range; - assert.ok(sameSet(keysOf(live, u), want), `${c.chessId} S${s.index + 1} ${s.name}: the live range on the card`); - done(h); } assert.deepEqual([...seen].sort(), [...BY_HAND.keys()].sort(), 'the hand-checked skills are still in the data'); + assert.ok(runs > list.length, `module variants checked (${runs})`); + assert.ok(extended >= 1, 'a module with 攻击距离 is among them (信仰搅拌机 SPT-Y)'); +}); + +test('信仰搅拌机 SPT-Y "攻击距离+1(部分技能不受此影响)": S1 / S2 range 2-2 + 1; S3 退休前布道 exactly 3-13 (PRTS 备注 "此技能的攻击范围不受“攻击距离”属性影响")', () => { + const id = 'chess_char_4_01_b'; + for (const skillIndex of [0, 1, 2]) for (const mid of ['uniequip_002_rmixer', 'uniequip_003_rmixer']) { + const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5, dir: 'RIGHT', skillIndex, moduleId: mid }], autoFinish: false, timeLimit: 30 }); + const u = h.unit(id); + h.step(); + const plus = mid === 'uniequip_003_rmixer' ? 1 : 0; + const tag = `S${skillIndex + 1} ${mid}`; + assert.ok(sameSet(new Set(u.rangeKeys), keysOf(C[id].rangeGrid, u, plus)), `${tag}: before the cast 2-2${plus ? ' + 1' : ''}`); + assert.ok(sameSet(new Set(u.baseRangeKeys), keysOf(C[id].rangeGrid, u, plus)), `${tag}: the initial range`); + assert.ok(u.skill.activate('test', { free: true }), `${tag}: cast`); + h.step(); + const want = skillIndex === 2 ? keysOf(C[id].skills[2].rangeGrid, u, 0) : keysOf(C[id].rangeGrid, u, plus); + assert.equal(u.rangeKeys.length, want.size, `${tag}: ${want.size} tiles while it runs`); + assert.ok(sameSet(new Set(u.rangeKeys), want), `${tag}: the tiles`); + assert.ok(sameSet(keysOf(unitStatsEntry(u, u._s).range, u), want), `${tag}: the card`); + if (skillIndex === 2) { + assert.equal(want.size, 8, '3-13'); + u.skill.stop(); + assert.ok(sameSet(new Set(u.rangeKeys), keysOf(C[id].rangeGrid, u, plus)), `${tag}: after S3 the module's +1 is back`); + } + done(h); + } +}); + +test('S3 counter of 信仰搅拌机 with SPT-Y reaches 3-13 only: an enemy on the +1 tile is not countered', () => { + // facing RIGHT at (10,5): 3-13 reaches [0,3] at most in its row; with +1 it would add [0,4] = (10,9) + const id = 'chess_char_4_01_b'; + const g = C[id].skills[2].rangeGrid; + const far = Math.max(...g.filter(([r]) => r === 0).map(([, c]) => c)) + 1; + const h = makeBattle({ + defs: { enemies: { enemy_far: dummy('enemy_far'), enemy_hit: dummy('enemy_hit', { atk: 50, range: 9, attackInterval: 1 }) } }, + units: [{ chessId: id, row: 10, col: 5, dir: 'RIGHT', skillIndex: 2, moduleId: 'uniequip_003_rmixer' }], + enemies: [{ key: 'enemy_far', pos: [10, 5 + far] }], + hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 60, seed: 3, + }); + const u = h.unit(id); + h.step(); + assert.ok(u.skill.activate('test', { free: true })); + h.step(); + const e = h.enemy('enemy_far'); + assert.ok(!u.rangeKeySet.has(tileKey(e)), 'the +1 tile is outside S3'); + h.b.dealDamage(e, u, { amount: 10, type: 'phys', isAttack: true }); + h.run(0.5); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.target === e).length, 0, 'no counter on the +1 tile'); + done(h); +}); + +test('荒芜拉普兰德 S1 慵怠者悲鸣 (no rangeId, no 攻击范围 text): the drones lock a still enemy anywhere, the card keeps her 3-1', () => { + for (const id of ['chess_char_6_18_a', 'chess_char_6_18_b']) { + const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 }], autoFinish: false, timeLimit: 30 }); + const u = h.unit(id); + h.step(); + assert.ok(u.skill.activate('test', { free: true })); + h.step(); + assert.ok(u.skill.active); + assert.ok(u.rangeKeys.length > 100, 'the sim selects targets field-wide'); + const rel = (g) => new Set(g.map(([r, c]) => `${r},${c}`)); + assert.ok(sameSet(rel(unitStatsEntry(u, u._s).range), rel(C[id].rangeGrid)), `${id}: the card shows 3-1`); + done(h); + } }); test('audit: the DEFAULT trigger of an attack-range change reads the INITIAL range (PRTS "技能就绪,且即将进行普通攻击")', () => { @@ -279,3 +392,28 @@ test('real product path: a BattleSpec (S3 by loadout) on act2 m01 with a real wa assert.equal(offGrid, 0, 'every S3 target stands on 4-11'); assert.ok(splashOut > 0, `splash victims outside 4-11 (${splashOut})`); }); + +test('real product path: 信仰搅拌机 精锐 with SPT-Y and S3 by loadout (BattleSpec, act2 m01, a real wave) — 2-2 + 1 before, 3-13 while S3 runs', () => { + const tpl = ds.getWave('act1autochess_05'); + const spawns = tpl.spawns.map((s) => ({ time: s.time, enemyKey: s.key, routeIndex: s.routeIndex, count: s.count, interval: s.interval })); + const spec = buildBattleSpec({ + battleId: 'fb1e-rmixer', fieldId: 'n:p1', kind: 'normal', seed: 7, modeId: 'mode_multi_hard', round: 5, stageId: 'act2autochess_m01', + timeLimit: 110, players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [], units: [ + { uid: 1, kind: 'chess', chessId: 'chess_char_4_01_b', row: 11, col: 7, dir: 'RIGHT', skillIndex: 2, moduleId: 'uniequip_003_rmixer' }, + ] }], spawns, routes: tpl.routes, flags: {}, waveId: 'act1autochess_05', + }); + const b = createBattleFromSpec(spec, ds, { recordEvents: false, quiet: true }); + const u = b.allyUnits.find((x) => x.uid === 1); + const s3 = new Set(), before = new Set(); + let casts = 0; + b.on('skillStart', (c) => { if (c.unit === u) casts++; }); + for (let i = 0; i < 110 * 30 && !b.finished; i++) { + b.step(); + if (!u.deployed) continue; + (u.skill.active ? s3 : before).add(u.rangeKeys.length); + } + assert.equal(b.errors.length, 0); + assert.ok(casts >= 1, `S3 cast (${casts})`); + assert.deepEqual([...before], [4], 'SPT-Y: 2-2 + 1'); + assert.deepEqual([...s3], [8], 'S3: the 8 tiles of 3-13, no +1'); +}); diff --git a/test/ui/feedback1e-range.test.js b/test/ui/feedback1e-range.test.js index 814145d4..2f45cad4 100644 --- a/test/ui/feedback1e-range.test.js +++ b/test/ui/feedback1e-range.test.js @@ -2,7 +2,9 @@ // (test/sim/feedback1e-skillrange.test.js); the battle detail card did not — its 攻击范围 mini-map kept the base 3-1 grid // while the live stats beside it showed S3's ATK and interval. The card now draws the live entry's `range` // (shared/protocol.js unitStatsEntry: the grid the unit attacks with now) — in battle from the browser's own sim, in prep -// from the start-of-battle preview — and names a whole-field range instead of drawing it. +// from the start-of-battle preview — names a whole-field range instead of drawing it, and draws a grid larger than its +// box (远牙 S3's 21-tile line) with smaller cells so the box keeps its size (review: the line squeezed the live stats +// from 326 px to 80 px). import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -24,7 +26,8 @@ globalThis.fetch = async (url) => { } }; -const { ChessDetail, RangeGrid, cardRangeGrid, FIELD_WIDE_CELLS } = await import('../../public/js/ui/detailPanel.js'); +const { ChessDetail, RangeGrid, cardRangeGrid, rangeGridStyle, FIELD_WIDE_CELLS, RANGE_FIT } = await import('../../public/js/ui/detailPanel.js'); +const { rangeGridBox } = await import('../../public/js/ui/gameLogic.js'); const { data } = await import('../../public/js/data.js'); function* walk(v) { @@ -102,3 +105,40 @@ test('the game screen hands the card the live entry it already reads (battle run const game = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); assert.match(game, /battleRunner\.unitStats\(uid, fid\)/); }); + +test('远牙 S3 光羽箭 (default, "攻击范围改为前方无限长的直线"): the 21-tile line is drawn whole, in the box\'s own space', async () => { + await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items'); + const id = 'chess_char_4_20_a'; + const h = makeBattle({ units: [{ chessId: id, row: 10, col: 0, dir: 'RIGHT', skillIndex: 2 }], autoFinish: false, timeLimit: 30 }); + const u = h.unit(id); + h.step(); + assert.ok(u.skill.activate('test', { free: true })); + h.step(); + const live = { ...unitStatsEntry(u, u._s), src: 'battle' }; + const box = rangeGridBox(live.range); + assert.equal(box.cols, 21); + assert.equal(box.rows, 1); + const stats = ChessDetail({ chess: data.lookup('chess', id), piece: null, editable: false, bonds: [], loadout: null, live }).find((b) => b.key === 'stats'); + const grid = [...walk(stats)].find((n) => hasClass(n, 'rgrid')); + assert.ok(grid, 'drawn, not named'); + assert.equal(onCells(stats), 21, 'the whole line, her own tile included'); + assert.match(grid.props.style, /--rg:max\(1px, min\(\.14rem, calc\(\(6 \* \.14rem \+ 10px\) \/ 21\)/, 'cells shrunk to the 6-column width'); + assert.match(grid.props.style, /gap:0/); +}); + +test('rangeGridStyle: the attack ranges of the records keep the .14rem cells; only a grid beyond RANGE_FIT (6 columns, 7 rows) shrinks them', async () => { + await data.loadAll('chess'); + const plain = (b) => rangeGridStyle(b) === `grid-template-columns:repeat(${b.cols}, var(--rg))`; + assert.deepEqual(RANGE_FIT, { cols: 6, rows: 7 }); + // every attack range a record gives the card (the unit's own, a module's) — the widest 灰毫 / 协律 / 阿罗玛 (6 columns) + let n = 0; + for (const c of data.list('chess')) { + const grids = [c.rangeGrid, ...(c.modules || []).flatMap((m) => (m.talentChanges || []).map((t) => t.rangeGrid))]; + for (const g of grids) if (Array.isArray(g) && g.length) { n++; assert.ok(plain(rangeGridBox(g)), `${c.name}: ${rangeGridBox(g).cols} cols`); } + } + assert.ok(n > 250, `record grids (${n})`); + // the tallest live skill range (银灰 S3 真银斩 3-7: 7 rows × 4 columns) too + assert.ok(plain(rangeGridBox(data.lookup('chess', 'chess_char_4_22_a').skills[2].rangeGrid)), '银灰 S3'); + 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'); +}); From a657f809f1fc5f2d2a509f4ea5c4e6a977112b19 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 06:25:12 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feedback1e=20WP:=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20=E7=83=9B=E7=85=8C=20S3=20last=20bolt=20keeps=20the?= =?UTF-8?q?=20burst=20bonus,=20splash=20fx;=20prep=20overlay=20and=20wheel?= =?UTF-8?q?=20show=20the=20deployed=20range?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 烛煌 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. --- docs/SIM.md | 7 +-- public/js/battle/runner.js | 8 +-- public/js/ui/detailPanel.js | 5 +- public/js/ui/facing.js | 8 +-- server/sim/content/kits/tier5.js | 19 +++++-- server/sim/targeting.js | 26 ++-------- shared/loadoutRecord.js | 69 ++++++++++++++++++++++---- test/render/feedback1e-splash.test.js | 44 ++++++++++++++++ test/sim/feedback1e-skillrange.test.js | 60 +++++++++++++++++++++- test/ui/feedback1e-range.test.js | 48 ++++++++++++++++++ test/ui/playtest2.test.js | 5 +- 11 files changed, 247 insertions(+), 52 deletions(-) create mode 100644 test/render/feedback1e-splash.test.js 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++; } }