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\)/);
+});