feedback1e WP: review fixes — 信仰搅拌机 S3 ignores 攻击距离, bounded range mini-map

- 信仰搅拌机 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.
This commit is contained in:
sganggs committed 2026-10-03 05:46:04 +08:00
1 parent 8b0e672bb8
commit 5963a40d2d
11 files changed
+276 -58

No files matched your search

+10 -4
View File
@@ -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)
+18 -2
View File
@@ -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`<i key=${`${r},${c}`} class=${cx(box.cells.has(tileKey(r, c)) && 'on', self && 'self')}></i>`);
}
}
return html`<div class=${cx('rgrid', cls)} style=${`grid-template-columns:repeat(${box.cols}, var(--rg))`} aria-label="攻击范围">${cells}</div>`;
return html`<div class=${cx('rgrid', cls)} style=${rangeGridStyle(box)} aria-label="攻击范围">${cells}</div>`;
}
function Stat({ k, v, sub, tone = null, title }) {
+14 -9
View File
@@ -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
}
+9 -4
View File
@@ -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)));
+11 -9
View File
@@ -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;
+3 -2
View File
@@ -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
+6 -3
View File
@@ -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). */
+2 -1
View File
@@ -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]>}
+3 -2
View File
@@ -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,
+158 -20
View File
@@ -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');
});
+42 -2
View File
@@ -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');
});