diff --git a/docs/SIM.md b/docs/SIM.md index 92b6fd5b..0f0c23cc 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -348,6 +348,11 @@ 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 攻击范围 the detail card shows, through `shared/protocol.js unitStatsEntry` `range`: the relative grid +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, …}.` @@ -417,7 +422,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 @@ -768,7 +775,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/css/screens/game-panels.css b/public/css/screens/game-panels.css index 8aac25bf..d8c3d787 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/battle/runner.js b/public/js/battle/runner.js index a4900632..7730af77 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -46,9 +46,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, items? }] that player's operators in the // battle on screen with their equipment (a teammate's bond popup: the members in play, DESIGN diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index f86166b4..b17410cf 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -21,7 +21,10 @@ // 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); +// 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,8 +45,40 @@ 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; +/** + * 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 + * 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} + */ +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 = []; @@ -53,7 +88,7 @@ export function RangeGrid({ grid, class: cls }) { cells.push(html``); } } - return html`
${cells}
`; + return html`
${cells}
`; } function Stat({ k, v, sub, tone = null, title }) { @@ -293,7 +328,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/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/Battle.js b/server/sim/Battle.js index e690bd30..5051819d 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'; @@ -1594,15 +1594,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. 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); @@ -1611,6 +1617,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; + 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 2441c2bf..c4df5e74 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -167,7 +167,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: @@ -198,7 +202,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))); @@ -234,7 +238,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; @@ -282,7 +287,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 d58751c2..17c5bcd5 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,13 @@ 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), + // 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 ("补充后的弹药数量无法超过上限"). 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. @@ -624,9 +632,11 @@ 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 }) { - if (target && target.alive && target.findBuff('burnBurst')) elementHit(battle, unit, target, unit.s.atk * num(bb['attack@atk_scale']), 'blazeBurn', 'burn'); + 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; }, @@ -679,8 +689,20 @@ 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)); + // 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; + 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); + 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/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 8abccb84..4fcd79a1 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -2759,11 +2759,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 96113838..3b847f37 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -43,6 +43,10 @@ export function absoluteRangeKeys(grid, r, c, dir, extend = 0) { return keys; } +// 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) { const r = Math.round(u.y), c = Math.round(u.x); 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/shared/protocol.js b/shared/protocol.js index 1e3f1313..943cec8a 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -193,16 +193,23 @@ 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, 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, * 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 +218,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/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 new file mode 100644 index 00000000..7c3f9786 --- /dev/null +++ b/test/sim/feedback1e-skillrange.test.js @@ -0,0 +1,475 @@ +// 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. +// 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'; +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'; +import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../../shared/loadoutRecord.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: 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); + 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: 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: 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 }); + 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 = /攻击范围(?:与溅射范围)?(?:改变|扩大|缩小|缩短)|攻击距离(?:\+|加长|缩短)|攻击范围改为/; +/** 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'], + ['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 — with every module', () => { + const list = rangeSkills(); + 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 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(); + const tag = `${c.chessId} S${s.index + 1} ${s.name} ${mid ?? 'default'}`; + assert.equal(u.s.baseRangeExtend, permExt, `${tag}: permanent 攻击距离`); + // 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`); + 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.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 "技能就绪,且即将进行普通攻击")', () => { + // 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})`); +}); + +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 new file mode 100644 index 00000000..853ca880 --- /dev/null +++ b/test/ui/feedback1e-range.test.js @@ -0,0 +1,192 @@ +// 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 — 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'; +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, 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) { + 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\)/); +}); + +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'); +}); + +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++; } }