diff --git a/docs/SIM.md b/docs/SIM.md index 6ea4997b..a5d8a5ef 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -259,15 +259,17 @@ D1 audit): radius 1.2, a tick every 0.5 s, one tick per unit per 0.5 s however m enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all wait for the real death, the only one with a `die` event. Every 重生 (`reborn()`, `husk()`, `statue()`) clears what operators put on the enemy — the buffs with an ally source and source-less catalogue statuses (PRTS 特殊机制 §重生 -"清空自身身上除白名单外所有Buff"; `rebirthCleanse`) — and keeps [ASSUMED: the whitelist] its `persist` talents, what it or +"清空自身身上除白名单外所有Buff"; `rebirthCleanse`), restarts its skills from their initial cooldown when the 重生 ends +("重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"; `rebirthCooldowns`, since 0.1.1) — and keeps [ASSUMED: the +whitelist] its `persist` talents, what it or another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (terrain, airflow, element burst locks): a 逐火 knocked out while feared is no unblockable ember. 重生 / form changes: `reborn()` (first knock-out → second form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable, immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that -walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted by operators (the -engine's radius area damage — `enemiesInRadius`: profession splash, skill circles — still reaches an unblocked one, a -known deviation: officially an AoE judges only the enemies it can select, PRTS 作战机制 §AOE伤害判定); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk +walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted or struck by +operators (radius area damage too: `Battle.foesInRadius`, PRTS 作战机制 §AOE伤害判定 — until 0.1.1 it reached an unblocked +one); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP), 转译基底·α (its original form cancels every damage instance, and an HP loss stops at 1 HP; the 4th physical / arts instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the @@ -482,7 +484,7 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba | `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – | | `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 | | `disarm` | no normal attacks | – | -| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's b.snap stealth bit is set only while its 隐匿 is on (not blocked, not revealed: drawn solid otherwise); radius splash (`enemiesInRadius`) ignores it [known deviation, PRTS 作战机制 §AOE伤害判定] | – | +| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's b.snap stealth bit is set only while its 隐匿 is on (not blocked, not revealed: drawn solid otherwise); an operator's radius area damage (`foesInRadius`) skips it too (PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 the splash still hit it) | – | | `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – | | `invulnerable` | ignores damage | – | | `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – | @@ -587,7 +589,8 @@ and 扎罗's 溶血骇惧; "受到N真实伤害" over time is damage (PRTS 伤 `'dot'` / `'periodic'` (player report D1). The 源石溶剂 drain also ticks on every 敌人类我方单位 of the field (炎佑, a partner's too: PRTS 备注 "全场范围内的所有敌人类我方单位也会获得此装备的…效果…无视目标可选性"), credited to nobody, once per second however many carriers (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"), while a carrier is on the field -[ASSUMED]. On a leader +[ASSUMED]. 奥术法阵 has the same 备注 for its rider: while a carrier is on the field, every damage instance of such a unit +silences its target for the item's 5 s ("造成伤害时使目标失去特殊能力5秒"; since 0.1.1). On a leader in a boss / hidden battle a loss of ≥ `BOSS_HIT_LIMIT` (a part's 传递, a drone's death) is cancelled like a hit. Every HP-damage kind meets the limit (phys / arts / true / 元素伤害 incl. element bursts, DoT ticks); element 损伤 (the gauge, `type: 'element'`) removes no HP and never does. @@ -651,7 +654,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged | `isReservedTile(r, c)` | true when a living unit stands there or it is the home tile of an ally piece that has not deployed yet / waits to redeploy — summon tile pickers must skip these (`findTacticalPoint` does) | | `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` | | `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too | -| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position) | +| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable, no unblocked unrevealed 隐匿 enemy, PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) | | `enemiesInKeys(keys, attacker, profile)`, `blockedTargets(unit, profile)` | targetable enemies whose body is on the tiles (a huge one listed once); the enemies a unit blocks — always selectable by it, a ranged operator on a melee tile included (§1.2 Blocking) | | `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | | | `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss); a gain adds at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`: the client's `AddBondCount` min(L + n, 999)) on the live copy — or, without one, on the battle's own gains — and returns what it added (0 at the cap: no hook, no event) | @@ -994,13 +997,13 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | fastshot | FLY first; module `atk_scale` vs FLY | | closerange, underminer, primcaster, corecaster, ritualist, summoner, counsellor, pioneer, fearless, fighter, protector, guardian, primprotector, executor, duelist | plain profile (numbers from data; skills/talents via kits). underminer module: weaken 10 % ATK 2 s on hit | | longrange | lowest DEF first | -| aoesniper / splashcaster | splash 1.1 tiles around the struck target at full damage (PRTS 溅射半径一览: 扩散术师 1.1; Arknights Terra Wiki, Splash Caster). [OPEN] the same table gives 炮手 1.0 (none in the pool) and 格雷伊 1.0 | +| aoesniper / splashcaster | splash 1.1 tiles around the struck target at full damage (PRTS 溅射半径一览: 扩散术师 1.1; Arknights Terra Wiki, Splash Caster); 格雷伊 1.0 (the same table's 特殊 row; TUNE.splashcaster, 1.1 until 0.1.1). The table's 炮手 1.0 has no chess in the pool | | blastcaster | `rangeAoe`: every selectable enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (primary: PRTS 作战机制 §AOE伤害判定 names 伊芙利特's 炎爆 a 锁定攻击范围 AoE, and 炎爆 is her next-attack skill "下次攻击造成…" (PRTS 伊芙利特 S2), so a 轰击术师 normal attack; secondary: Terra Wiki, Blast Caster; supporting: PRTS 溅射半径一览 documents no splash radius for it; community report E3). A stealthed enemy is not struck unless it is revealed or blocked (a 锁定范围 AoE cannot hit a 隐匿 unit, PRTS 作战机制) | -| bombarder | ground-only splash 1.0 + aftershock(s) at 50 % ATK (bb append_atk_scale / times). [OPEN] PRTS 溅射半径一览 gives 投掷手 0.9 | +| bombarder | ground-only splash 0.9 (PRTS 溅射半径一览: 投掷手 0.9; 1.0 until 0.1.1) + aftershock(s) at 50 % ATK (bb append_atk_scale / times); 迷迭香's S2 末梢阻断 1.5 (the same table) | | hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty | | loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits) | | reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction | -| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.8-tile jumps, sluggish on each hit. [OPEN] PRTS 溅射半径一览 gives 链术师 1.7 | +| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit | | funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) | | mystic | stores up to 3 (bb times) attacks while idle, fires them all at once | | phalanx | no attack & DEF +200 %, RES +20 (bb) while the skill is off; while on, `rangeAoe`: each attack strikes every selectable enemy on its range at once (blocked enemies included; a stealthed one only when revealed or blocked), the same damage near and far, instant (`'beam'`) — "群体法术伤害" (secondary: Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; supporting: PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill several — the same 锁定攻击范围 shape as the 轰击术师, and PRTS 溅射半径一览 documents no splash radius for it (it omits the 撼地者 too, so this is not proof); no primary source states a target cap; community report E3: it used to be one bolt + a 1.1 splash). 卡涅利安's charged S1 keeps the skill-off trait, 不攻击 included (kit `canAttack`, PRTS 备注) | diff --git a/docs/research/02-bonds.md b/docs/research/02-bonds.md index f1c4a151..7acea6b8 100644 --- a/docs/research/02-bonds.md +++ b/docs/research/02-bonds.md @@ -385,7 +385,7 @@ Members (11 chess, 10 in current shop pool; by tier in shop: {'1': 1, '2': 1, '3 - Caps: 3 revives per battle - How layers are gained: Devour (tier per devoured unit, every battle); 幽灵鲨 被击倒 +3; 归溟幽灵鲨 被击倒/替身切换 +5 阿戈尔 +5 不屈; 斯卡蒂 each 2 kills +1 阿戈尔/坚守/突袭; 海霓 first kill (enemy or ally) +3 阿戈尔/奥术; 机变 "斯卡蒂的盟誓" +8. Strategy 克莱门莎: an 阿戈尔 knocked out adds layers = its tier. - How it plays: Put cheap/high-tier fodder in front of 阿戈尔 carries: they steal base ATK and block, gain layers, and (5) revive. Tier-6 fodder gives +6 layers per battle. -- [ASSUMED] 5000 physical 流失 ignores DEF and shields +- 5000 physical 流失: less the target's DEF (PRTS 作战机制: a 物理/法术流失 "会受到目标当前防御力/法术抗性影响而相应衰减"; DEF-free [ASSUMED] until 0.1.1), no shields, dodge or damage multipliers (a 流失) - [ASSUMED] marked allied units are the player's own operators (devour hits allies) - Bond item (with 变形同构体 grants this bond): 阿戈尔重刃 `chess_item_3_07_e` (2 gold: 攻击力+40%,攻击速度-10) - Garrisons that explicitly add layers to this bond: `garrison_38`, `garrison_40`, `garrison_46`, `garrison_131` (see section 4; plus the generic ones) diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 77835dd7..bd4850a5 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1567,6 +1567,26 @@ export class Battle { return out; } + /** + * The enemies within `r` (as enemiesInRadius) an ally-side area effect can select — PRTS 作战机制 §AOE伤害判定 "AOE的判定是 + * 对攻击范围内的每个可以被选中的敌人进行判定", 隐匿 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标": no + * untargetable enemy and no 隐匿 one unless revealed or blocked (tile selectors — enemiesInKeys / canTargetEnemy — already + * skip them). Flying and asleep enemies stay the caller's choice. Enemy-side effects on other enemies (auras, heals) and + * physical collisions keep enemiesInRadius. Player report #8 after 0.1.0 (the 逐火 余烬): until 0.1.1 profession splash + * and skill circles still reached an unblocked 隐匿 enemy. + */ + foesInRadius(x, y, r, centre = false) { + const out = this.enemiesInRadius(x, y, r, centre); + let n = 0; + for (const e of out) { + const f = e.s.flags; + if (f.untargetable || (f.stealth && !f.reveal && !e.blockedBy)) continue; + out[n++] = e; + } + out.length = n; + return out; + } + alliesInRadius(x, y, r, ownerId = null, { includeDevices = false } = {}) { const out = []; const r2 = r * r + 1e-9; diff --git a/server/sim/ai.js b/server/sim/ai.js index 4917e326..5ff91ed2 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -24,7 +24,7 @@ // flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted); // both re-plan the route when released (恐惧 outranks 诱导). -import { ATTACK_PAUSE, ALLY_COLLIDER_RADIUS, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js'; +import { ATTACK_PAUSE, ALLY_COLLIDER_RADIUS, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS, CHAIN_RADIUS } from './constants.js'; import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js'; import { reduceElement } from './damage.js'; import { straightClear } from './grid.js'; @@ -235,7 +235,7 @@ export function resolveHit(b, u, prof, target, info, x, y) { if (prof.splashRadius > 0) { const r = prof.splashRadius; const sc = prof.splashScale ?? 1; - for (const e of b.enemiesInRadius(x, y, r, true)) { + for (const e of b.foesInRadius(x, y, r, true)) { if (e === target) continue; if (prof.groundOnly && e.isFlying) continue; if (!prof.canHitFly && e.isFlying && !prof.splashHitsFly) continue; @@ -252,7 +252,7 @@ export function resolveHit(b, u, prof, target, info, x, y) { const n = Math.max(1, prof.chain.count || 3); for (let k = 1; k < n; k++) { let best = null, bd = Infinity; - for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || 1.8)) { + for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || CHAIN_RADIUS)) { if (hit.has(e.id) || !canTargetEnemy(u, e, prof)) continue; const d = Math.hypot(e.x - prev.x, e.y - prev.y); if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; } diff --git a/server/sim/constants.js b/server/sim/constants.js index 6bdaed6f..23ca5506 100644 --- a/server/sim/constants.js +++ b/server/sim/constants.js @@ -216,3 +216,6 @@ export const BOSS_POOL_MIN_HP = 1; * the base attributes) stay multipliers. */ export const DIRECT_BONUS_STACKING = 'add'; + +/** 链术师 jump radius (PRTS 溅射半径一览, 特性: "链术师 … 1.7"; 1.8 until 0.1.1). */ +export const CHAIN_RADIUS = 1.7; diff --git a/server/sim/content/bonds/core.js b/server/sim/content/bonds/core.js index 129ca684..65416bea 100644 --- a/server/sim/content/bonds/core.js +++ b/server/sim/content/bonds/core.js @@ -36,6 +36,7 @@ // death 10), layerGain −100 (after 魔王-style modifiers). import * as S from '../support/index.js'; +import { mitigate } from '../../damage.js'; import { spawnYanyou } from '../tokens.js'; import { kjeragColdWind } from '../devices.js'; import * as items from '../items.js'; @@ -335,8 +336,10 @@ function installLaterano(battle, pid, bb, members) { * members the tiles in front of * those (chain); never themselves, a unit already marked by them or a unit that marked them. The marker gains the base * ATK (atkFlat) and block count of everything it marked; then each mark makes its target lose damage_value HP as a - * 流失 (battle.loseHp: no DEF, shields, dodge or damage multipliers — research "5000-point physical 流失"; the kill is - * credited to the marker) in marking order. A unit that died cancels its remaining marks (as target and as marker). + * 物理流失 (PRTS 盟约记录: "造成5000点物理流失", 修正 "【吞噬】的物理流失来源为被付与目标自身;单位被【吞噬】击杀时,击杀来源始终为 + * 对应标记的付与来源"; PRTS 作战机制: a 物理流失 "会受到目标当前防御力…影响而相应衰减") — less the target's DEF as a physical hit + * (its own source: no DEF ignore), then battle.loseHp: no shields, dodge or damage multipliers (DEF-free until 0.1.1); the + * kill is credited to the marker — in marking order. A unit that died cancels its remaining marks (as target and as marker). * Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields). * Tokens / devices / empty tiles are never devoured. */ @@ -382,7 +385,7 @@ function devour(battle, pid, bb, members) { for (const [m, t] of marks) { if (!t.alive || !m.alive) continue; S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id }); - if (amount > 0) battle.loseHp(t, amount, { source: m, tags: ['bond:egir:devour'] }); + if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] }); if (!layered.has(t)) { layered.add(t); S.gainLayers(battle, { playerId: pid, bonds: 'egirShip', n: S.tierOf(t), source: m, reason: 'bond' }); @@ -479,7 +482,7 @@ function installKazimierz(battle, pid, bb, members) { battle.every(iv, () => { for (const m of members) { if (!S.onField(m) || !m.blocking || !m.blocking.length) continue; - const hits = battle.enemiesInRadius(m.x, m.y, radius); + const hits = battle.foesInRadius(m.x, m.y, radius); if (!hits.length) continue; const amount = scale * m.s.atk; for (const e of hits) { diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index c358d469..0fa96ea0 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -45,10 +45,10 @@ // gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害"). // DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域), // bleeding (removed by healing), pulsing auras. -// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). Radius area damage -// (Battle.enemiesInRadius: profession splash around a struck target, many skills' circles) still hits -// an unblocked one — a known deviation: officially an AoE only judges the enemies it can select (PRTS -// 作战机制 §AOE伤害判定; ba.invisible vs ba.camou); tile selectors (enemiesInKeys) already skip it. +// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). An operator's radius area +// damage skips an unblocked one too (Battle.foesInRadius: profession splash around a struck target, +// skill circles — PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 it +// still hit it); tile selectors (enemiesInKeys) always skipped it. // REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced // (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on. // SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below. @@ -68,7 +68,7 @@ // hidden tabs (shared/protocol.js fxForm). // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. -import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS } from '../constants.js'; +import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../constants.js'; import { canTargetAlly, sortAllyTargets, aggroCmp } from '../targeting.js'; import { mitigate, periodicDamage } from '../damage.js'; @@ -123,8 +123,11 @@ const GRAB_RADIUS_SCALE = 2.5, GRAB_MAX_PREY = 3, GRAB_DELAY = 0.5; /** 孽罪奇美拉 污染模式 aura (PRTS 天赋 "自身半径1.2范围内的所有单位…每0.5秒受到50真实持续伤害(同类效果取最高)"): radius, * damage period (s). */ const CHIMERA_AURA_RADIUS = 1.2, CHIMERA_AURA_EVERY = 0.5; -/** Bombardments per 自行炮 Cannon cast ("数次") [ASSUMED]. */ -const CANNON_SHOTS = 3; +/** 自行炮 Cannon (PRTS 高准度伦蒂尼姆城防自行炮 技能 "以攻击范围内生命上限最高的我方单位为中心,生成9格的炮击区域,6s内每0.5s对炮击 + * 区域中生命比例最高的我方单位进行攻击…(最多进行10次攻击)"): shot interval (s), shot cap; 3 shots 1 s apart [ASSUMED] until + * 0.1.1. 备注: during the skill animation 失衡免疫 + 晕眩 / 冻结 / 浮空 / 沉睡免疫 — held over the bombardment [ASSUMED length]. */ +const CANNON_EVERY = 0.5, CANNON_SHOTS = 10; +const CANNON_IMMUNE = Object.freeze(['stun', 'freeze', 'cold', 'levitate', 'sleep']); /** 枯朽之种 summoned per BornBugs cast ("数个") [ASSUMED]. */ const BUGS_PER_CAST = 3; /** Radius of 骸骨拷打者's "周围" death sensing when its talent has no Attack.range_radius [ASSUMED]. */ @@ -737,6 +740,14 @@ function freeAllPrisoners(b) { * knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable — as an * ember until the fear ran out. */ +/** + * The end of a 重生 (PRTS 特殊机制 §重生 "重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"): every ability with a + * cooldown starts again from its initial cooldown (until 0.1.1 the countdowns ran on through the 重生). + */ +function rebirthCooldowns(e) { + for (const s of (e.mem.ab && e.mem.ab.list) || []) if (s.cd != null) s.left = num(s.icd, 0); +} + function rebirthCleanse(b, e) { for (const x of e.buffs.slice()) { if (x.persist || (x.source && x.source.side === 'enemy')) continue; @@ -764,6 +775,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } }); if (e.route) e.route.pts = null; e.atkCd = 0; + rebirthCooldowns(e); setForm(b, e, 'form2', 'revive', { kind: 'reborn' }); }; return { @@ -900,7 +912,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } }); if (e.route) e.route.pts = null; setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay }); - if (onHusk) b.after(HUSK_REBIRTH, () => { if (e.alive && a.state === 'husk') onHusk(b, e); }, { owner: e }); + b.after(HUSK_REBIRTH, () => { if (e.alive && a.state === 'husk') { rebirthCooldowns(e); if (onHusk) onHusk(b, e); } }, { owner: e }); b.after(HUSK_REBIRTH + delay, () => revive(b, e, a), { owner: e }); return true; }, @@ -926,6 +938,7 @@ function statue(ab) { a.done = true; a.noAtk = e.profile.noAttack; rebirthCleanse(b, e); // "进行重生。重生瞬间完成" + rebirthCooldowns(e); e.profile.noAttack = true; e.hp = e.s.maxHp; ab2.immune = new Set([...(ab2.immune || []), 'levitate']); @@ -2503,17 +2516,28 @@ export const KITS = Object.freeze({ enemy_1026_aghost: () => [unblockable()], // 幽灵组长 · unblockable enemy_1062_rager_2: (ab) => [{ iv: 1, tick(b, e) { const v = T(ab, 'periodic_damage.damage') ?? 0; if (v > 0) b.dealDamage(null, e, periodicDamage(v)); } }], // 狂暴宿主组长 · "自身每秒受到500无来源真实伤害" (damage, not 流失) enemy_1183_mlasrt: (ab) => [ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), nthAttackPower(nthOf(ab.sk.PowerAttack), (ab.sk.PowerAttack && ab.sk.PowerAttack.bb.atk_scale) || 1)], // 无胄盟清扫小队 · erosion; every 4th attack ×1.5 - enemy_1273_stmgun_2: (ab) => [skill(ab.sk.Cannon, (b, e) => { // 高准度伦蒂尼姆城防自行炮 · locks the highest max-HP unit, bombards the highest HP% around it - const lock = allTargets(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0]; - if (!lock) return; - const x = lock.x, y = lock.y; - b.fx('telegraph', { x, y, r: 1, dur: CANNON_SHOTS, kind: 'cannon' }); - for (let i = 1; i <= CANNON_SHOTS; i++) b.after(i, () => { - if (!e.alive) return; - const t = b.alliesInRadius(x, y, 1).sort((p, q) => q.hpRatio - p.hpRatio)[0]; - if (t) { b.fx('explode', { x: t.x, y: t.y, r: 0.5, kind: 'cannon' }); hurt(b, e, t, e.s.atk * (ab.sk.Cannon.bb.atk_scale ?? 0), 'arts'); } - }, { owner: e }); - }, { cond: (b, e) => allTargets(b, e).length > 0 })], + enemy_1273_stmgun_2: (ab) => { // 高准度伦蒂尼姆城防自行炮 · locks the highest max-HP unit in range, bombards the highest HP% on its 9 tiles + // the allies in its range (the engine's ranged reach: radius + the ally collider) + const inRange = (b, e) => allTargets(b, e).filter((a) => Math.hypot(a.x - e.x, a.y - e.y) <= (e.base.rangeRadius || 0) + ALLY_COLLIDER_RADIUS + 1e-9); + return [skill(ab.sk.Cannon, (b, e) => { + const lock = inRange(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0]; + if (!lock) return; + const r0 = Math.round(lock.y), c0 = Math.round(lock.x); + const dur = CANNON_SHOTS * CANNON_EVERY; + b.fx('telegraph', { x: c0, y: r0, r: 1.5, dur, kind: 'cannon' }); + b.addBuff(e, { key: 'ab:cannon', duration: dur, flags: { noDisplace: true } }); + const ab2 = e.mem.ab; + const had = new Set(ab2.immune || []); + ab2.immune = new Set([...had, ...CANNON_IMMUNE]); + for (let i = 1; i <= CANNON_SHOTS; i++) b.after(i * CANNON_EVERY, () => { + if (!e.alive) return; + if (i === CANNON_SHOTS) ab2.immune = had; + const t = b.allies().filter((a) => canTargetAlly(e, a, true) && Math.abs(Math.round(a.y) - r0) <= 1 && Math.abs(Math.round(a.x) - c0) <= 1) + .sort((p, q) => q.hpRatio - p.hpRatio)[0]; + if (t) { b.fx('explode', { x: t.x, y: t.y, r: 0.5, kind: 'cannon' }); hurt(b, e, t, e.s.atk * (ab.sk.Cannon.bb.atk_scale ?? 0), 'arts'); } + }, { owner: e }); + }, { cond: (b, e) => inRange(b, e).length > 0 })]; + }, // 萨卡兹骸骨拷打者 · heals + ATK stack when a unit within Attack.range_radius is knocked out; leaves 2 血珀 on death — they // stay where it fell and wait for a 唤血祭坛 that this mode has none of (inert, not counted) enemy_1364_spnaxe_2: (ab) => [{ diff --git a/server/sim/content/generic.js b/server/sim/content/generic.js index 05cba9ab..94f610a5 100644 --- a/server/sim/content/generic.js +++ b/server/sim/content/generic.js @@ -368,7 +368,7 @@ function installGeneric(spec) { if (!src || src.side !== 'enemy' || !ctx.dmg || !ctx.dmg.isAttack) return; readyAt = battle.time + c.cooldown; const amount = (c.stat === 'def' ? unit.s.def : unit.s.atk) * c.scale; - const victims = c.around ? battle.enemiesInRadius(unit.x, unit.y, 1.5).filter((e) => !(c.groundOnly && e.isFlying)) : (src.alive ? [src] : []); + const victims = c.around ? battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !(c.groundOnly && e.isFlying)) : (src.alive ? [src] : []); for (const e of victims) { const dealt = amount > 0 ? battle.dealDamage(unit, e, { amount, type: c.type, canDodge: false, isSkill: true, tags: ['counter'] }) : 0; if (c.applyElement) c.applyElement(battle, unit, e, dealt); diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index 6dc7319b..cca38230 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -339,10 +339,20 @@ const BY_BUFF = { } } }); }, - // 奥术法阵: 攻击使目标失去特殊能力 silence s + // 奥术法阵: 攻击使目标失去特殊能力 silence s. PRTS 盟约记录 备注 (as 源石溶剂's): "携带后,全场范围内的所有敌人类我方单位也会获得 + // 此装备的“造成伤害时使目标失去特殊能力5秒”效果(该效果的付与为我方阵营索敌,可对空,无视目标可选性)" — the damage of every + // enemy-record unit on our side (炎佑, a partner's too) silences its target while a carrier is on the field [ASSUMED: it + // ends with its carriers]; one effect per damage instance however many carriers. silence_attachment(battle, u, p, S) { const d = num(p.silence); - if (d > 0) onAttackEnemies(S, u, (t) => battle.applyStatus(t, 'silence', { duration: d, source: u })); + if (!(d > 0)) return; + onAttackEnemies(S, u, (t) => battle.applyStatus(t, 'silence', { duration: d, source: u })); + S.on('damaged', (c) => { + const a = c.source, t = c.target; + if (!a || a === u || a.side !== 'ally' || !ENEMY_RECORD.test(a.defId ?? '') || !t || t.side !== 'enemy' || !t.alive || !onField(u)) return; + if (c.dmg) { if (c.dmg.arcaneSilence) return; c.dmg.arcaneSilence = true; } + battle.applyStatus(t, 'silence', { duration: d, source: a }); + }); }, // 海沟实验体: 伤害减免 value per damage instance (after DEF/RES) halfidle_block_fixed_damage(battle, u, p, S) { diff --git a/server/sim/content/kits/tier1.js b/server/sim/content/kits/tier1.js index cf48b6db..2cb9b0e3 100644 --- a/server/sim/content/kits/tier1.js +++ b/server/sim/content/kits/tier1.js @@ -20,7 +20,7 @@ // counter {id} · crit {id} · dp {n, id} · heal {id} · taunt {id} · summon {id, token} · pull {id} · sonic {radius} · // shield {id} · overload {id} · takeoff {id} · sleep {id} · buff {id, kind} · reveal {id} · dodge (engine kind). -import { COLS } from '../../constants.js'; +import { COLS, CHAIN_RADIUS } from '../../constants.js'; import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js'; import { frontOf, offsetTile } from '../../dir.js'; import { bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js'; @@ -276,7 +276,7 @@ export function tinmanKit(bb, chess, def) { unit.mem.tinZones = (unit.mem.tinZones ?? 0) + 1; makeZone(battle, unit, { x, y, radius, duration: dur, skill: 'tinman', onPulse(b) { - for (const e of b.enemiesInRadius(x, y, radius)) { + for (const e of b.foesInRadius(x, y, radius)) { if (e.isFlying || e.s.flags.untargetable) continue; if (wither > 1) b.addBuff(e, { key: witherKey, duration: 1.05, data: { mul: wither }, source: unit }); b.dealDamage(unit, e, { amount: atk * dmgScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] }); @@ -390,7 +390,7 @@ export default { } }], install(battle, unit) { // the chain shape (count / sluggish, elite module included) comes from the resolved chain profile - const ch = unit.profile.chain || { count: num(traitBb(chess)['attack@max_target'], 3), radius: 1.8, sluggish: num(traitBb(chess)['attack@sluggish'], 0.5) }; + const ch = unit.profile.chain || { count: num(traitBb(chess)['attack@max_target'], 3), radius: CHAIN_RADIUS, sluggish: num(traitBb(chess)['attack@sluggish'], 0.5) }; if (unit.skill && unit.skill.spec.attack) unit.skill.spec.attack.chain = { ...ch, falloff: 0 }; }, }; @@ -590,7 +590,7 @@ export default { battle.addDp(unit.ownerId, num(bb.cost)); battle.fx('dp', { x: unit.x, y: unit.y, n: num(bb.cost), id: unit.id }); const grid = def?.skill?.rangeGrid; - const foes = grid ? enemiesInGrid(battle, unit, grid) : battle.enemiesInRadius(unit.x, unit.y, RING1).filter((e) => !e.s.flags.untargetable); + const foes = grid ? enemiesInGrid(battle, unit, grid) : battle.foesInRadius(unit.x, unit.y, RING1).filter((e) => !e.s.flags.untargetable); battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'swordRain' }); for (const e of foes) { for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill'] }); @@ -776,7 +776,7 @@ export default { const atk = unit.s.atk; makeZone(battle, unit, { x, y, radius: SPORE_RADIUS, duration: dur, skill: 'spores', onPulse(b) { - for (const e of b.enemiesInRadius(x, y, SPORE_RADIUS)) { + for (const e of b.foesInRadius(x, y, SPORE_RADIUS)) { if (e.s.flags.untargetable) continue; b.applyStatus(e, 'sluggish', { duration: 1.05, source: unit }); b.applyStatus(e, 'silence', { duration: 1.05, source: unit }); diff --git a/server/sim/content/kits/tier2.js b/server/sim/content/kits/tier2.js index 0428d383..58e823f3 100644 --- a/server/sim/content/kits/tier2.js +++ b/server/sim/content/kits/tier2.js @@ -681,7 +681,7 @@ export default { for (const a of alliesInGridOf(battle, unit)) { if (a === unit || a.kind !== 'op') continue; battle.fx('sonic', { x: a.x, y: a.y, radius: rad, id: unit.id }); - for (const e of battle.enemiesInRadius(a.x, a.y, rad)) { + for (const e of battle.foesInRadius(a.x, a.y, rad)) { if (!e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, isSplash: true, tags: ['sonic'] }); } } @@ -756,7 +756,7 @@ export default { kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, attack: { hitAllBlocked: true }, onStart({ battle, unit }) { const grid = def?.skill?.rangeGrid; - const foes = grid ? enemiesInGrid(battle, unit, grid, { canHitFly: false, groundOnly: true }) : battle.enemiesInRadius(unit.x, unit.y, 1.5).filter((e) => !e.isFlying); + const foes = grid ? enemiesInGrid(battle, unit, grid, { canHitFly: false, groundOnly: true }) : battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !e.isFlying); battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'resolve' }); for (const e of foes) battle.applyStatus(e, 'tremble', { duration: num(bb.not_combat), source: unit }); }, @@ -840,7 +840,7 @@ export default { battle.fx('zone', { x, y, radius, dur, id: unit.id, skill: 'tinman1' }); battle.every(STEP, (b, sc) => { const pulse = i % per === 0; - for (const e of b.enemiesInRadius(x, y, radius)) { + for (const e of b.foesInRadius(x, y, radius)) { if (e.isFlying || e.s.flags.untargetable) continue; if (weak > 0) b.applyStatus(e, 'weaken', { duration: STEP + 0.05, value: weak, source: unit }); if (wither > 1) b.addBuff(e, { key: 'tinman:wither', duration: STEP + 0.05, data: { mul: wither }, source: unit }); diff --git a/server/sim/content/kits/tier3.js b/server/sim/content/kits/tier3.js index 67a23b63..698c869a 100644 --- a/server/sim/content/kits/tier3.js +++ b/server/sim/content/kits/tier3.js @@ -82,7 +82,7 @@ function profileMul(battle, unit, target) { } /** Targetable enemies within `r` tiles of (x, y), nearest first (spawn order breaks ties), excluding `skip`. */ function enemiesAround(battle, unit, x, y, r, skip = null) { - const out = battle.enemiesInRadius(x, y, r).filter((e) => !(skip && skip.has(e)) && canTargetEnemy(unit, e, { canHitFly: true })); + const out = battle.foesInRadius(x, y, r).filter((e) => !(skip && skip.has(e)) && canTargetEnemy(unit, e, { canHitFly: true })); const d = (e) => bodyDist(e, x, y); return out.sort((a, b) => d(a) - d(b) || (a.spawnSeq ?? a.id) - (b.spawnSeq ?? b.id)); } @@ -113,6 +113,8 @@ const gridKeys = (grid, u, ext = 0) => absoluteRangeKeys(grid || [[0, 0]], u.til const fx = (battle, kind, u, extra = {}) => battle.fx(kind, { x: u.x, y: u.y, id: u.id, ...extra }); const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null); const NINE = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]; +/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */ +const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]); /** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */ const COLLIDE = 0.6; /** 忍冬 S3's 迷彩 (until her next cast): its own buff key, never merged with another unit status. */ @@ -401,11 +403,11 @@ const KITS = { // 大买家: coin at skill start + coin & ATK stack per trait payment; 破财消灾: DP-paid revive (cost doubles) // S1 仗义疏财 (passive, 2 coins): an attack spends a coin to heal the most injured ally (< 70 % HP) of the 8 // surrounding tiles for attack@heal_scale × ATK. S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin; - // closing it spends every coin on random ground enemies of the front range (atk_scale phys + a small push, radial - // despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2: the RandomGold ability - // (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位, 弹道不可对空). - // Auto-close (the mode casts everything itself; the player's "主动关闭" is not available): once the purse is full - // (10) and an enemy stands in range. 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks). + // closing it spends every coin on random ground enemies of range 2-4 in front and those she blocks (atk_scale phys + + // a small push, radial despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2: + // the RandomGold ability (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位, + // 弹道不可对空). Auto-close (the mode casts everything itself; the player's "主动关闭" is not available): once the + // purse is full (10) and a coin target stands there. 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks). chess_char_3_04_a: (bb, chess, def) => { const d = defOf(chess, def); const t0 = talentBb(d, 0), t1 = talentBb(d, 1); @@ -476,22 +478,29 @@ const KITS = { [S1]: () => ({ kind: 'passive' }), // (coins → heals: installS1) [S3]: (s) => { const cash = num(s.bb.atk_scale, 1), force = num(s.bb.force, 0), full = num(s.bb.sp, 10); + // the 【金币标记】 targets: ground enemies on range 2-4 in front of her (range_table "2-4") and every unit she blocks + const coinMarks = (battle, unit) => { + const list = enemiesOn(battle, unit, gridKeys(SWIRE2_COIN_GRID, unit), 0, { ...unit.profile, canHitFly: false }); + for (const e of unit.blocking || []) if (e.alive && !e.isFlying && !list.includes(e)) list.push(e); + return list; + }; return { kind: 'toggle', attack: { hits: 2 }, onTick({ battle, unit, skill }) { - if ((unit.mem.coins ?? 0) >= full && enemiesOn(battle, unit, unit.rangeKeys, 0, { ...unit.profile, canHitFly: false }).length) skill.end('manual'); + if ((unit.mem.coins ?? 0) >= full && coinMarks(battle, unit).length) skill.end('manual'); }, onEnd({ battle, unit, reason }) { if (reason !== 'manual' || !unit.alive) return; const n = unit.mem.coins ?? 0; unit.mem.coins = 0; let spent = 0; - // PRTS 备注: 【金币标记】 goes on "前方范围内的地面敌方单位与自身阻挡的所有单位" and the coins are "弹道(不可对空)" — - // air units (FLY, 近地悬浮, 浮空) are never paid - const ground = { ...unit.profile, canHitFly: false }; + // PRTS 备注: closing it "立即对前方范围2-4内的地面敌方单位与自身阻挡的所有单位施加【金币标记】", the coins going to + // marked units at random — 弹道(不可对空): air units (FLY, 近地悬浮, 浮空) are never paid. Until 0.1.1 the + // coins went to her attack range (1-1) instead of range 2-4. + const marked = coinMarks(battle, unit); for (let i = 0; i < n; i++) { - const e = battle.rng.pick(enemiesOn(battle, unit, unit.rangeKeys, 0, ground)); + const e = battle.rng.pick(marked.filter((x) => x.alive && !x.isFlying)); if (!e) break; spent++; battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] }); diff --git a/server/sim/content/kits/tier4.js b/server/sim/content/kits/tier4.js index 6e6b8d58..b44d1133 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -43,6 +43,8 @@ const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze( const BILLRO_S3_RAMP = 20; /** 卡涅利安 S3 charged mark: one buff per enemy, shared by every 卡涅利安 (PRTS 备注), ≤ 5 stacks (skill text). */ const BILLRO_MARK = 'billro:mark'; +/** 阿罗玛's 非首次标记 (talent 起泡性能测试): one per enemy, shared by every 阿罗玛 (PRTS 备注). */ +const AROMA_MARK = 'aroma:bubbled'; const BILLRO_MARK_MAX = 5; // --------------------------------------------------------------------------------------------------------------- @@ -863,14 +865,26 @@ const kits = { }), skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, onStart({ battle, unit }) { battle.fx('slippery', { x: unit.x, y: unit.y, id: unit.id }); } }, talents: [{ install(battle, unit) { // first attack on each enemy: ×1.1 and levitate 2.5 s - unit.mem.bubbled = new Set(); + // PRTS 阿罗玛 备注: "天赋采用施加非首次标记的方式判断是否为'首次进行攻击',自身离场时移除自身已施加的标记,不同阿罗玛之间的 + // 非首次标记通用" — one mark per enemy (AROMA_MARK, the setter as source) for every 阿罗玛; hers go when she leaves + // the field (until 0.1.1 each 阿罗玛 kept her own list, so two of them both triggered on one enemy) + unit.mem.bubbled = new Set(); // the enemies she marked battle.on('hit', (c) => { const e = c.target; - if (c.source !== unit || !c.dmg.isAttack || e.side !== 'enemy' || unit.mem.bubbled.has(e.id)) return; + if (c.source !== unit || !c.dmg.isAttack || e.side !== 'enemy' || e.findBuff(AROMA_MARK)) return; + battle.addBuff(e, { key: AROMA_MARK, source: unit }); unit.mem.bubbled.add(e.id); c.dmg.mul *= num(t0.damage_scale, 1.1); if (battle.applyStatus(e, 'levitate', { duration: num(t0.levitate_duration, 2.5), source: unit })) battle.fx('levitate', { x: e.x, y: e.y, id: e.id }); }, { owner: unit }); + battle.on('death', (c) => { + if (c.unit !== unit) return; + for (const id of unit.mem.bubbled) { + const e = battle.unitById(id); + if (e && e.findBuff(AROMA_MARK)?.source === unit) battle.removeBuff(e, AROMA_MARK); + } + unit.mem.bubbled.clear(); + }, { owner: unit }); } }], install(battle, unit) { // S2: landing damage for every levitated enemy (any source) that lands inside her range while the skill runs @@ -979,7 +993,7 @@ const kits = { onTick({ battle, unit, dt }) { const T = unit.mem.tornado; if (!T) return; - const inside = battle.enemiesInRadius(T.x, T.y, R); + const inside = battle.foesInRadius(T.x, T.y, R); for (const e of inside) pulse(battle, e, `glady:slow:${unit.id}`, { moveMul: Math.max(0, 1 + num(bb.move_speed, -0.5)) }); T.acc += dt; if (T.acc + 1e-9 < iv) return; @@ -996,7 +1010,7 @@ const kits = { const T = unit.mem.tornado; unit.mem.tornado = null; if (!T || reason === 'death' || !unit.alive) return; - for (const e of battle.enemiesInRadius(T.x, T.y, R)) pullSelf(battle, unit, e, force); + for (const e of battle.foesInRadius(T.x, T.y, R)) pullSelf(battle, unit, e, force); battle.fx('pull', { x: T.x, y: T.y, id: unit.id }); }, }, @@ -1946,7 +1960,7 @@ const kits = { if (S2 && skillActive(unit)) { // S2: motes orbit wider and hit enemies instead of operators const hitAt = unit.mem.moteHit || (unit.mem.moteHit = new Map()); - const foes = battle.enemiesInRadius(unit.x, unit.y, num(bb.outside_radius, 2)).filter((e) => canTargetEnemy(unit, e, { canHitFly: true })) + const foes = battle.foesInRadius(unit.x, unit.y, num(bb.outside_radius, 2)).filter((e) => canTargetEnemy(unit, e, { canHitFly: true })) .sort((a, b) => a.id - b.id); for (const e of foes) { if ((hitAt.get(e.id) ?? -Infinity) > battle.time + 1e-9) continue; diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index 17c5bcd5..66507b23 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -446,7 +446,7 @@ const KITS = { unit.mem.titiWardAcc = 0; for (const a of unit.mem.titiWard || []) { if (!a.alive || !a.deployed || !a.findBuff('titi:ward')) continue; - for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) if (e.alive) battle.applyStatus(e, 'sleep', { duration: AURA_DUR, source: unit }); + for (const e of battle.foesInRadius(a.x, a.y, RING1)) if (e.alive) battle.applyStatus(e, 'sleep', { duration: AURA_DUR, source: unit }); } }, onEnd({ battle, unit }) { @@ -507,7 +507,7 @@ const KITS = { const asleep = new Map(); // PRTS 备注: "向1.5半径内除该单位外仇恨值最高的敌方单位施加沉睡" — highest aggro (taunt) first, then the nearest const sleepOthers = (from) => { - const cands = battle.enemiesInRadius(from.x, from.y, radius).filter((o) => o !== from && !o.s.flags.sleep && !o.mem?.candleOwner) + const cands = battle.foesInRadius(from.x, from.y, radius).filter((o) => o !== from && !o.s.flags.sleep && !o.mem?.candleOwner) .sort((a, b) => (b.s.taunt || 0) - (a.s.taunt || 0) || dist(a, from) - dist(b, from) || a.spawnSeq - b.spawnSeq); let n = 0; for (const o of cands) { @@ -604,7 +604,7 @@ const KITS = { battle.addBuff(t, { key: `blaze2:aid:${unit.id}`, duration: num(bb.max_duration, 20), interval: Math.max(0.1, num(bb.interval, 1)), visible: true, data: { src: unit }, onTick: ({ unit: x }) => { - for (const e of battle.enemiesInRadius(x.x, x.y, r)) { + for (const e of battle.foesInRadius(x.x, x.y, r)) { const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeAid'] }); if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_multiplier), tags: ['skill', 'blazeAid'] }); } @@ -676,7 +676,7 @@ const KITS = { if (u.deploySeq !== dep || u.hp < u.s.maxHp - 1e-6) return; battle.removeBuff(u, buff); u.mem.downed = false; - for (const e of battle.enemiesInRadius(u.x, u.y, NEARBY_RADIUS)) if (e.alive) battle.applyStatus(e, 'stun', { duration: num(t1.stun), source: u }); + for (const e of battle.foesInRadius(u.x, u.y, NEARBY_RADIUS)) if (e.alive) battle.applyStatus(e, 'stun', { duration: num(t1.stun), source: u }); battle.fx('revive', { x: u.x, y: u.y, id: u.id, r: NEARBY_RADIUS }); }, }); @@ -793,7 +793,7 @@ const KITS = { // for this one: it never lands on or catches air units (FLY, 近地悬浮, 浮空) const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying); if (!main) return; - const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep) + const near = battle.foesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep) .sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq) .slice(0, Math.max(1, num(bb.max_target, 2))); const force = num(bb.force, 1); @@ -827,7 +827,7 @@ const KITS = { const [sr, sc] = frontOf(unit.tileR, unit.tileC, unit.dir, stop ?? 0); const fromX = unit.x, fromY = unit.y; battle.fx('anchor', { x: sc, y: sr, id: unit.id, fromX, fromY, r: radius }); - for (const e of battle.enemiesInRadius(sc, sr, radius)) { + for (const e of battle.foesInRadius(sc, sr, radius)) { battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchor'] }); if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit }); } @@ -894,7 +894,7 @@ const KITS = { const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1); const nCandles = Math.max(1, num(bb['attack@max_target'], 3)); const candleKey = talentRec(chess, 2)?.bbStr?.take_extra_enemy_key ?? CANDLE_KEY; - const aroundN = (battle, a) => battle.enemiesInRadius(a.x, a.y, RING1).length; + const aroundN = (battle, a) => battle.foesInRadius(a.x, a.y, RING1).length; return { skills: lazySkills({ skchr_etlchi_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), hits: 2 } }), @@ -917,7 +917,7 @@ const KITS = { // PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空); // 起飞 = an airborne skywalker (蒂比's skill: off the ground, flag blockFly) const air = !a.ground && !!a.s.flags.blockFly; - for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) { + for (const e of battle.foesInRadius(a.x, a.y, RING1)) { if (e.isFlying && !air) continue; battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] }); } @@ -1939,7 +1939,7 @@ const KITS = { if (z.anchor && z.anchor.alive && z.anchor.deployed) { z.x = z.anchor.x; z.y = z.anchor.y; } const iv = Math.max(0.1, num(bb.interval, 1)); const steps = Math.min(Math.max(0, num(bb.max_stack_cnt, 15)), Math.floor((z.t - AURA_IV + 1e-9) / iv)); - const foes = battle.enemiesInRadius(z.x, z.y, RING1); + const foes = battle.foesInRadius(z.x, z.y, RING1); for (const e of foes) seaHits.set(e, Math.max(seaHits.get(e) ?? -1, steps)); if (z.acc >= iv - 1e-9) { z.acc -= iv; @@ -2037,7 +2037,7 @@ const KITS = { z.x = Math.max(R.c0, Math.min(R.c1, z.x + z.vx * AURA_IV)); z.y = Math.max(R.r0, Math.min(R.r1, z.y + z.vy * AURA_IV)); const r = r0 + grow * z.t; - const foes = battle.enemiesInRadius(z.x, z.y, r).filter((e) => !e.isFlying); + const foes = battle.foesInRadius(z.x, z.y, r).filter((e) => !e.isFlying); for (const e of foes) battle.addBuff(e, { key: 'thorn2:rot', duration: AURA_DUR, mods: { healingTakenMul: healMul } }); if (z.acc >= 1 - 1e-9) { z.acc -= 1; @@ -2252,7 +2252,7 @@ const KITS = { { install(battle, unit) { // 游侠 const dr = num(t0.damage_resistance); whileOn(battle, unit, 0.2, () => { - unit.mem.mlyNear = battle.enemiesInRadius(unit.x, unit.y, AROUND_RADIUS).length; + unit.mem.mlyNear = battle.foesInRadius(unit.x, unit.y, AROUND_RADIUS).length; if (dr > 0 && unit.mem.mlyNear >= num(t0.cnt, 3)) battle.addBuff(unit, { key: 'mlynar:ranger', duration: 0.3, mods: { dmgTakenMul: 1 - dr } }); }); } }, @@ -2399,7 +2399,7 @@ const KITS = { battle.fx('iceSpike', { x: c, y: r, id: unit.id, r: ICICLE_RADIUS }); battle.after(ICICLE_DELAY, () => { const hit = []; - for (const e of battle.enemiesInRadius(c, r, ICICLE_RADIUS)) { + for (const e of battle.foesInRadius(c, r, ICICLE_RADIUS)) { battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'iceSpike'] }); if (e.alive && cold > 0) battle.applyStatus(e, 'cold', { duration: cold, source: unit }); if (e.alive) hit.push(e); diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 86efcd2c..0e2d3b87 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -103,7 +103,7 @@ import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy, aggroCmp } from '../../targeting.js'; import { aggregateMods } from '../../buffs.js'; -import { COLS, ROWS, PULL_STOP_RADIUS } from '../../constants.js'; +import { COLS, ROWS, PULL_STOP_RADIUS, CHAIN_RADIUS } from '../../constants.js'; import { rotateOffset } from '../../dir.js'; import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js'; import { hasHp } from '../../damage.js'; @@ -449,7 +449,7 @@ function lemuen(bb, chess, def) { }; const blast = (battle, unit, atk, x, y) => { battle.fx('bombard', { x, y, id: unit.id, r: d2 }); - for (const e of battle.enemiesInRadius(x, y, d2)) { + for (const e of battle.foesInRadius(x, y, d2)) { if (e.s.flags.untargetable) continue; const d = bodyDist(e, x, y); battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] }); @@ -1078,7 +1078,7 @@ function pasngr(bb, chess, def) { const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}; const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null; const strike = (battle, unit, first, scale) => { - const ch0 = unit.profile?.chain || { count: 4, falloff: 0.15, radius: 1.8 }; + const ch0 = unit.profile?.chain || { count: 4, falloff: 0.15, radius: CHAIN_RADIUS }; // elite module (电磁调节器) upgrades the skill's chain too: skill@chain.atk_scale / skill@sluggish const ch = { ...ch0, falloff: tb['skill@chain.atk_scale'] != null ? 1 - num(tb['skill@chain.atk_scale']) : num(ch0.falloff, 0.15) }; const count = Math.max(1, Math.floor(num(bb['chain.max_target'], ch.count || 4))); @@ -1091,7 +1091,7 @@ function pasngr(bb, chess, def) { battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * Math.pow(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] }); if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit }); let best = null, bd = Infinity; - for (const x of battle.enemiesInRadius(prev.x, prev.y, ch.radius || 1.8)) { + for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) { if (hit.has(x.id) || !canTargetEnemy(unit, x, ANY)) continue; const d = bodyDist(x, prev.x, prev.y); if (d < bd - 1e-9) { bd = d; best = x; } @@ -1100,7 +1100,7 @@ function pasngr(bb, chess, def) { } }; // the chain of her profile (trait / module: bounce count, falloff, 停顿) with skill overrides - const chainOf = (unit, o) => ({ ...(unit.profile?.chain || { count: 4, falloff: 0.15, radius: 1.8, sluggish: 0.5 }), ...o }); + const chainOf = (unit, o) => ({ ...(unit.profile?.chain || { count: 4, falloff: 0.15, radius: CHAIN_RADIUS, sluggish: 0.5 }), ...o }); const skills = { // S1 电能之触: next attack at atk_scale × ATK, bouncing over max_target enemies with a sluggish-s 停顿 (the module's // skill@pasngr_s_1.chain.atk_scale sets its falloff) @@ -1140,7 +1140,7 @@ function pasngr(bb, chess, def) { const h = battle.every(iv, () => { if (!live(unit) || unit.deploySeq !== seq) { h.cancel(); return; } // the storm ends when she leaves the field if (++k >= n) h.cancel(); - const zone = battle.enemiesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY)); + const zone = battle.foesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY)); const e = battle.rng.pick(zone); if (e) strike(battle, unit, e, scale); }, { owner: unit }); @@ -1271,7 +1271,7 @@ function pepe(bb, chess, def) { if (ctx.attacker !== unit) return; const t = ctx.targets[0]; const r = num(ctx.profile?.splashRadius, 1); - unit.mem.pepeBoost = !!t && battle.enemiesInRadius(t.x, t.y, r).length >= cnt; + unit.mem.pepeBoost = !!t && battle.foesInRadius(t.x, t.y, r).length >= cnt; }, { owner: unit }); battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.dmg.isAttack && unit.mem.pepeBoost) ctx.dmg.mul *= sc; }, { owner: unit }); battle.on('attack', (ctx) => { if (ctx.attacker === unit) unit.mem.pepeBoost = false; }, { owner: unit, priority: -100 }); @@ -1505,7 +1505,7 @@ function reed2(bb, chess, def) { battle.after(0, () => { if (!live(unit)) return; battle.fx('scorchBurst', { x, y, id: unit.id, r: aoeR }); - for (const e of battle.enemiesInRadius(x, y, aoeR, true)) { // splash around the victim: 中点判定 + for (const e of battle.foesInRadius(x, y, aoeR, true)) { // splash around the victim: 中点判定 if (!e.alive) continue; battle.dealDamage(unit, e, { amount: unit.s.atk * aoe, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'scorch'] }); scorch(battle, unit, e); @@ -2024,7 +2024,8 @@ function mlyss(bb, chess, def) { // ------------------------------------------------------------------------------------------------------------------ // 迷迭香 chess_char_6_12 (投掷手) — S2 末梢阻断; 歼灭战装备; 感知稳定 -const ROSMON_SPLASH_MUL = 1.3; // [ASSUMED] "溅射范围扩大" (not in data) +/** S2 末梢阻断 "溅射范围扩大": radius 1.5 (PRTS 溅射半径一览, 技能: 迷迭香 末梢阻断 1.5; ×1.3 [ASSUMED] until 0.1.1). */ +const ROSMON_S2_SPLASH = 1.5; function rosmon(bb, chess, def) { const t0 = tbb(def, 0), t1 = tbb(def, 1); @@ -2075,7 +2076,7 @@ function rosmon(bb, chess, def) { onStart({ unit }) { const p = unit.profile; unit.mem.rosSaved = { r: p.splashRadius, n: p.shockTimes }; - p.splashRadius = num(p.splashRadius, 1) * ROSMON_SPLASH_MUL; + p.splashRadius = Math.max(num(p.splashRadius, 0.9), ROSMON_S2_SPLASH); p.shockTimes = num(p.shockTimes, 2) + Math.floor(num(bb.add_times)); }, onEnd({ unit }) { @@ -2176,7 +2177,7 @@ function angel2(bb, chess, def) { if (!c) return; const r = c.tileR, col = c.tileC; battle.fx('airstrike', { x: col, y: r, id: unit.id, r: AIRSTRIKE_RADIUS }); - for (const e of battle.enemiesInRadius(col, r, AIRSTRIKE_RADIUS)) { + for (const e of battle.foesInRadius(col, r, AIRSTRIKE_RADIUS)) { if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@cannon_atk_scale'], 1), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'delivery'] }); } const waiting = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && !a.alive && !a.removed && a !== unit @@ -2251,7 +2252,7 @@ function angel2(bb, chess, def) { sortEnemyTargets(battle, u, cands, null); const c = cands[0]; battle.fx('airstrike', { x: c.x, y: c.y, id: unit.id, r: AIRSTRIKE_RADIUS }); - for (const e of battle.enemiesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定 + for (const e of battle.foesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定 if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, isSplash: true, tags: ['talent', 'airstrike'] }); } }, { owner: unit }); @@ -2420,7 +2421,7 @@ function qiubai(bb, chess, def) { if (!unit.alive || unit.deploySeq !== seq) return; const x = target.x, y = target.y; // (a fallen target keeps its last position) battle.fx('aoe', { x, y, id: unit.id }); - for (const e of battle.enemiesInRadius(x, y, 1.2, true)) { // splash around the target: 中点判定 + for (const e of battle.foesInRadius(x, y, 1.2, true)) { // splash around the target: 中点判定 if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.aoe_scale, 1), type: 'arts', isSkill: true, isSplash: e !== target, tags: ['skill'] }); } }, { owner: unit }); @@ -2507,7 +2508,7 @@ function halo2(bb, chess, def) { let prev = target, px = target.x, py = target.y; for (let i = 0; i < jumps; i++) { let best = null, bd = Infinity; - for (const e of battle.enemiesInRadius(px, py, R)) { + for (const e of battle.foesInRadius(px, py, R)) { if (e === prev || !canTargetEnemy(unit, e, ANY)) continue; const d = bodyDist(e, px, py); if (d < bd - 1e-9) { bd = d; best = e; } @@ -2532,7 +2533,7 @@ function halo2(bb, chess, def) { onHit({ battle, unit, target }) { if (!target) return; const R = num(bb.ability_range_radius, 2), k = Math.max(0, Math.floor(num(bb.max_target, 2))); - const near = battle.enemiesInRadius(target.x, target.y, R).filter((e) => e !== target && canTargetEnemy(unit, e, ANY)) + const near = battle.foesInRadius(target.x, target.y, R).filter((e) => e !== target && canTargetEnemy(unit, e, ANY)) .sort((a, b) => bodyDist(a, target.x, target.y) - bodyDist(b, target.x, target.y) || a.spawnSeq - b.spawnSeq) .slice(0, k); for (const e of near) { @@ -2870,7 +2871,7 @@ function whitw2(bb, chess, def) { } } const near = new Set(); - for (const d of D) for (const e of battle.enemiesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e); + for (const d of D) for (const e of battle.foesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e); if (slow) for (const e of near) battle.addBuff(e, { key: 'whitw2:slow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow) }, source: unit }); unit.mem.droneAcc += dt; if (unit.mem.droneAcc + 1e-9 >= 1) { diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 9b9784dd..7d3b1a9c 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -1106,7 +1106,7 @@ function yanyouKit(bb, raw) { if (f.acc < 1 - 1e-9) return; f.acc -= 1; // centre-point AoE around the locked target, searched again every second (the target itself included) - const hit = flameR > 0 ? battle.enemiesInRadius(t.x, t.y, flameR) : []; + const hit = flameR > 0 ? battle.foesInRadius(t.x, t.y, flameR) : []; if (!hit.includes(t)) hit.unshift(t); for (const e of hit) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] }); battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, r: flameR, n: hit.length, dur: 1 }); @@ -1152,7 +1152,7 @@ function yanyouKit(bb, raw) { auraAcc += dt; if (fragMul > 1 && auraAcc >= YANYOU_AURA_EVERY - 1e-9) { auraAcc = 0; - for (const e of battle.enemiesInRadius(unit.x, unit.y, YANYOU_FRAGILE_RADIUS)) { + for (const e of battle.foesInRadius(unit.x, unit.y, YANYOU_FRAGILE_RADIUS)) { battle.applyStatus(e, 'elemFragile', { duration: 2 * YANYOU_AURA_EVERY, value: fragMul - 1, source: unit }); } } diff --git a/server/sim/professions.js b/server/sim/professions.js index b1522e59..0c23d796 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -29,7 +29,7 @@ import { toLocal, frontOf } from './dir.js'; import { absoluteRangeKeys } from './targeting.js'; import { bodyInKeys, bodyKeys, bodyOnTile } from './body.js'; -import { COLS } from './constants.js'; +import { COLS, CHAIN_RADIUS } from './constants.js'; const P = (o) => Object.freeze(o); const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); @@ -276,13 +276,14 @@ export const SUB = Object.freeze({ closerange: P({}), longrange: P({ priority: 'lowDef' }), aoesniper: P({ splashRadius: 1.1, projectile: 'bomb' }), - bombarder: P({ splashRadius: 1.0, projectile: 'bomb', groundOnly: true, canHitFly: false, + // PRTS 溅射半径一览 (特性): 投掷手 0.9, 扩散术师 1.1 (格雷伊 1.0, TUNE below), 链术师 1.7 jumps; 炮手 1.0 (none in the pool) + bombarder: P({ splashRadius: 0.9, projectile: 'bomb', groundOnly: true, canHitFly: false, afterHit: (battle, unit, target, info) => { // aftershocks: (times − 1) extra hits at append_atk_scale × ATK (default one hit at 50 %) const n = Math.max(1, (unit.profile.shockTimes ?? 2) - 1); for (let i = 1; i <= n; i++) { battle.after(0.3 * i, () => { - for (const e of battle.enemiesInRadius(info.x, info.y, unit.profile.splashRadius || 1, true)) { // splash: 中点判定 + for (const e of battle.foesInRadius(info.x, info.y, unit.profile.splashRadius || 1, true)) { // splash: 中点判定 if (e.isFlying) continue; battle.dealDamage(unit, e, { amount: unit.s.atk * (unit.profile.shockScale ?? 0.5), type: 'phys', isSplash: true, tags: ['aftershock'] }); } @@ -310,7 +311,7 @@ export const SUB = Object.freeze({ // documents no splash radius for either (supporting only: it omits the 撼地者 too) splashcaster: P({ splashRadius: 1.1 }), blastcaster: P({ rangeAoe: true }), - chain: P({ chain: { count: 3, falloff: 0.15, radius: 1.8, sluggish: 0.5 } }), + chain: P({ chain: { count: 3, falloff: 0.15, radius: CHAIN_RADIUS, sluggish: 0.5 } }), funnel: P({ projectile: 'drone', install: installFunnel, dmgMul: (battle, unit, target) => { const f = unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; @@ -397,6 +398,8 @@ export const SUB = Object.freeze({ const TUNE = { fastshot: (tb) => ({ flyScale: num(tb.atk_scale, 1) }), bombarder: (tb) => ({ shockScale: num(tb['attack@append_atk_scale'], 0.5), shockTimes: num(tb['attack@times'], 2) }), + // PRTS 溅射半径一览 特殊: 格雷伊 1.0 (the branch's 1.1 otherwise) + splashcaster: (tb, def) => ((def.charId ?? def.raw?.charId) === 'char_253_greyy' ? { splashRadius: 1.0 } : {}), hunter: (tb) => ({ ammoMax: num(tb.value, 8), ammoScale: num(tb.atk_scale, 1.2) }), reaperrange: (tb, def) => ({ frontScale: num(tb.atk_scale, 1.5), frontGrid: def.raw?.trait?.rangeGrid ?? null }), funnel: (tb) => ({ funnel: { init: num(tb.init_atk_scale, 0.2), delta: num(tb.delta_atk_scale, 0.15), max: num(tb.max_atk_scale, 1.1) } }), diff --git a/test/content/bonds_core.test.js b/test/content/bonds_core.test.js index bbdc4559..1bacf4fd 100644 --- a/test/content/bonds_core.test.js +++ b/test/content/bonds_core.test.js @@ -305,7 +305,7 @@ test('拉特兰: ammo ×(1.05+0.015L) floored; 6: every ammo used → all member // --------------------------------------------------------------------------------------------------------------------- // 阿戈尔 -test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base ATK + block, layers = devoured tiers', () => { +test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 物理流失 less the target\'s DEF, base ATK + block, layers = devoured tiers', () => { const list = [ ['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['fod_a', ['preciShip'], { tier: 4, stats: { atk: 700, blockCnt: 3, maxHp: 20000, def: 900 } }], @@ -319,13 +319,14 @@ test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base A const [g1, g2, g3, f] = ['g1_a', 'g2_a', 'g3_a', 'fod_a'].map((id) => h.unit(id)); const devours = tagged(h, 'bond:egir:devour'); assert.deepEqual(devours.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a'], ['g2_a', 'fod_a']]); - for (const c of devours) close(c.amount, 5000); + // PRTS 盟约记录 "造成5000点物理流失"; PRTS 作战机制: a 物理流失 is reduced by the target's DEF + for (const c of devours) close(c.amount, Math.max(5000 - c.target.s.def, 5000 * 0.05)); close(g1.s.atk, 1000 + 1000 + 700, 1e-6, 'g1 gains g2 + fodder base ATK'); close(g2.s.atk, 1000 + 700); close(g3.s.atk, 1000); assert.equal(g1.s.blockCnt, 2 + 2 + 3); assert.equal(g2.s.blockCnt, 2 + 3); - close(f.hp, 20000 - 10000, 1e-6, '流失: DEF 900 ignored'); + close(f.hp, 20000 - 2 * (5000 - 900), 1e-6, '物理流失: less DEF 900 (DEF-free until 0.1.1)'); const Lnow = L + 1 + 4; // g2 tier 1 + fodder tier 4, once each assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, Lnow); h.step(2); @@ -334,7 +335,7 @@ test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base A checkInvariants(h.b); }); -test('阿戈尔 devour: 流失 ignores the marker’s damage bonuses and the target’s shields; a dead marker’s marks are cancelled; kill → marker', () => { +test('阿戈尔 devour: 物理流失 ignores the marker’s damage bonuses and the target’s shields; a dead marker’s marks are cancelled; kill → marker', () => { const list = [ ['g1_a', ['egirShip', 'kjeragShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['g3_a', ['egirShip']], ['fod_a', ['preciShip'], { tier: 2, stats: { maxHp: 20000, def: 500 } }], @@ -353,7 +354,7 @@ test('阿戈尔 devour: 流失 ignores the marker’s damage bonuses and the tar assert.ok(h.hooksOf('kill').some((c) => c.victim === g2 && c.killer === g1), 'kill credited to the marker'); const dv = tagged(h, 'bond:egir:devour'); assert.deepEqual(dv.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a']], 'g2’s own mark on the fodder is cancelled'); - close(f.hp, 20000 - 5000, 1e-6, 'exactly 5000: no ×1.25, no DEF, shield untouched'); + close(f.hp, 20000 - (5000 - 500), 1e-6, '5000 less its DEF 500: no ×1.25, shield untouched'); assert.equal(f.buffs.find((b) => b.key === 'test:shield')?.shield, 3000); close(g1.s.atk, 1000 + 1000 + 1000, 1e-6, 'the base ATK of everything it marked stays'); assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 1 + 2, 'layers = tiers of the devoured units'); diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index be53f36f..f64aec67 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -1092,15 +1092,27 @@ test(`${nm('enemy_1062_rager_2')}: loses ${tb('enemy_1062_rager_2', 'periodic_da approx(e.s.maxHp - e.hp, 3 * tb('enemy_1062_rager_2', 'periodic_damage.damage')); }); -test(`${nm('enemy_1273_stmgun_2')}: Cannon locks the highest-max-HP unit and bombards (arts ATK×${skb('enemy_1273_stmgun_2', 'Cannon').bb.atk_scale})`, () => { +test(`${nm('enemy_1273_stmgun_2')}: Cannon locks the highest-max-HP unit in range, bombards the highest HP% of its 9 tiles every 0.5 s up to 10 times (arts ATK×${skb('enemy_1273_stmgun_2', 'Cannon').bb.atk_scale}), 失衡免疫 + control immunity meanwhile (PRTS)`, () => { const h = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }, { chessId: 't_wall', row: 11, col: 3 }], chess: { t_wall: WALL('t_wall', { stats: { maxHp: 2e7 } }) } }); h.step(); - const e = put(h, 'enemy_1273_stmgun_2', [10, 9]); - e.profile.noAttack = true; + const far = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }] }); + far.step(); + const e0 = put(far, 'enemy_1273_stmgun_2', [10, 9]); + e0.profile.noAttack = true; const s = skb('enemy_1273_stmgun_2', 'Cannon'); - h.run(s.initCooldown + 4); - assert.ok(h.eventsOf('fx').some((f) => f[1] === 'telegraph' && f[4].kind === 'cannon')); + far.run(s.initCooldown + 1); + assert.ok(!far.eventsOf('fx').some((f) => f[1] === 'telegraph' && f[4].kind === 'cannon'), 'nobody in its range: no cast'); + const e = put(h, 'enemy_1273_stmgun_2', [11, 5]); + e.profile.noAttack = true; + h.run(s.initCooldown + 0.1); + const tel = h.eventsOf('fx').find((f) => f[1] === 'telegraph' && f[4].kind === 'cannon'); + assert.ok(tel && tel[2] === 3 && tel[3] === 11, 'centred on the highest max-HP unit\'s tile'); + assert.ok(e.findBuff('ab:cannon')?.flags.noDisplace, '失衡免疫 during the bombardment'); + assert.equal(h.b.applyStatus(e, 'stun', { duration: 1 }), false, '晕眩免疫 during it'); + h.run(5.5); + assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'explode' && f[4].kind === 'cannon').length, 10, '10 shots'); assert.ok(h.unit('t_wall').stats.taken + h.unit('t_gun').stats.taken > 0); + assert.ok(!e.findBuff('ab:cannon') && h.b.applyStatus(e, 'stun', { duration: 1 }) !== false, 'over afterwards'); }); for (const key of ['enemy_1501_demonk', 'enemy_10018_sgrobh']) { @@ -1818,15 +1830,20 @@ test(`${nm('enemy_1525_blkswb')}: 抵抗; ignores ${tb('enemy_1525_blkswb', 'Def const first = h.hooksOf('damaged').find((c) => c.source === e); approx(first.amount, e.s.atk * skb('enemy_1525_blkswb', 'Blink').bb.atk_scale - 500, 1e-6, '速杀 on its blocker'); assert.ok(e.x < 5 - 0.4, `passed through (x ${e.x})`); - // form 2 (its 速杀 held back so it keeps fighting the wall) + // form 2 const CASTER = chessRec({ id: 't_caster', profession: 'CASTER', projectile: 'none', stats: { atk: 1, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: { spCost: 999 } }); const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_caster', row: 10, col: 4 }], chess: { t_wall: WALL('t_wall', { def: 500 }), t_caster: CASTER }, captureNoisy: true, hooks: ['damaged', 'statusApplied'] }); h2.step(); const k = put(h2, 'enemy_1525_blkswb', [9, 5]); - for (const a of k.mem.ab.list) if (a.id === 'blink' || a.id === 'blink2') a.left = 999; + // its 速杀 held back so it keeps fighting the wall (form 2's starts from its initial cooldown 0 when the 重生 ends) + for (const a of k.mem.ab.list) if (a.id === 'blink') a.left = 999; else if (a.id === 'blink2') a.cond = () => false; killed(h2, k, null); - h2.run(tb('enemy_1525_blkswb', 'Reborn.duration') + tb('enemy_1525_blkswb', 'Reborn.invincible') + 0.1); + assert.ok(h2.runUntil(() => k.form === 'form2', 10), 'the 重生 ends'); + // PRTS 特殊机制 §重生 "重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却" + const b1 = k.mem.ab.list.find((a) => a.id === 'blink'); + assert.ok(b1.left > (b1.icd ?? 0) - 0.04 && b1.left <= (b1.icd ?? 0) + 1e-9, `速杀 (held at 999) back to its initial cooldown (${b1.left} vs ${b1.icd})`); + h2.run(tb('enemy_1525_blkswb', 'Reborn.invincible') + 0.1); assert.ok(k.s.flags.stealth, 'second form: 隐匿'); const t0 = h2.b.time, a0 = k.stats.attacks; h2.runUntil(() => k.stats.attacks >= a0 + 1, 20); diff --git a/test/content/kits_alt_t6.test.js b/test/content/kits_alt_t6.test.js index 990bcd32..316970e6 100644 --- a/test/content/kits_alt_t6.test.js +++ b/test/content/kits_alt_t6.test.js @@ -691,7 +691,8 @@ test('6_11 缪尔赛思 S2 生态耦合: +cost DP; melee copies regenerate and t test('6_12 迷迭香 S1 思维膨大: the next attack adds extra_atk_scale × ATK arts to every enemy it hits', () => { for (const id of both('chess_char_6_12')) { const sid = 'skchr_rosmon_1', bb = bbOf(id, sid); - const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 7] }, { key: 'e', pos: [10, 8] }] }); + // the second enemy inside the 投掷手 splash (PRTS 溅射半径一览 0.9) of the first + const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 7] }, { key: 'e', pos: [10, 7.6] }] }); const u = h.unit(id); usesSkill(u, sid); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.type === 'arts').length >= 2, 6)); diff --git a/test/sim/feedback1-followups.test.js b/test/sim/feedback1-followups.test.js new file mode 100644 index 00000000..5d2cf287 --- /dev/null +++ b/test/sim/feedback1-followups.test.js @@ -0,0 +1,125 @@ +// Follow-ups of the 0.1.1 integration (the workstreams' cross-workstream notes and last review rounds), each against its +// primary source: +// * PRTS 作战机制 §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定" + 隐匿 "无法被敌方的索敌机制和Buff选择器选中": +// radius area damage of an operator (profession splash, skill circles — Battle.foesInRadius) skips an unblocked, +// unrevealed 隐匿 enemy and the untargetable (player report #8: the 逐火 余烬; until 0.1.1 they were hit); +// * PRTS 溅射半径一览: 投掷手 0.9, 链术师 1.7, 扩散术师 1.1 (格雷伊 1.0), 迷迭香 末梢阻断 1.5; +// * PRTS 阿罗玛 天赋 备注: the 非首次标记 is shared by every 阿罗玛 and leaves with its setter; +// * PRTS 盟约记录 奥术法阵 备注: 炎佑's damage silences its target while a carrier is on the field; +// * PRTS 特殊机制 §重生: skills restart from their initial cooldown when a 重生 ends (test/content/enemies_bosses 锏); +// * PRTS 高准度伦蒂尼姆城防自行炮 (test/content/enemies_bosses); PRTS 琳琅诗怀雅 S3 备注 range 2-4 (below). +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 { resolveProfile } from '../../server/sim/professions.js'; +import { CHAIN_RADIUS } from '../../server/sim/constants.js'; +import { spawnYanyou } from '../../server/sim/content/tokens.js'; + +const ds = getDefaultSource(); +const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e8, atk: 0, speed: 0, ...o }); +const stealth = (h, e) => h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true }, persist: true }); +const dmgTo = (h, src, e) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === e).reduce((s, c) => s + c.amount, 0); + +test('profession splash skips an unblocked 隐匿 enemy and hits it once revealed (PRTS §AOE伤害判定: only enemies that can be selected)', () => { + for (const reveal of [false, true]) { + const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_1_14_a', row: 10, col: 3 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true, autoFinish: false }); + h.step(); + const u = h.unit('chess_char_1_14_a'); + const main = h.spawn('enemy_dummy', { pos: [10, 5] }); + const hid = h.spawn('enemy_dummy', { pos: [10, 4.4] }); // 0.6 from the main target: inside 格雷伊's 1.0 splash + stealth(h, hid); + if (reveal) h.b.addBuff(hid, { key: 'test:reveal', flags: { reveal: true }, persist: true }); + h.run(6); + assert.ok(dmgTo(h, u, main) > 0, 'the main target is struck'); + if (reveal) assert.ok(dmgTo(h, u, hid) > 0, 'revealed: splashed (and targetable)'); + else assert.equal(dmgTo(h, u, hid), 0, 'unblocked 隐匿: never splashed (it used to be)'); + checkInvariants(h.b); + } +}); + +test('Battle.foesInRadius: no untargetable or unblocked 隐匿 enemy; a blocked or revealed one counts; enemiesInRadius keeps them all', () => { + const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], timeLimit: 30, autoFinish: false }); + h.step(); + const [a, b, c, d] = [[10, 5], [10, 5.2], [10, 5.4], [10, 5.6]].map((pos) => h.spawn('enemy_dummy', { pos })); + stealth(h, b); + h.b.addBuff(c, { key: 'test:untargetable', flags: { untargetable: true }, persist: true }); + stealth(h, d); + h.b.addBuff(d, { key: 'test:reveal', flags: { reveal: true }, persist: true }); + assert.deepEqual(h.b.foesInRadius(5, 10, 1).map((e) => e.id).sort(), [a.id, d.id].sort()); + assert.equal(h.b.enemiesInRadius(5, 10, 1).length, 4, 'enemy-side auras and heals still reach them all'); +}); + +test('PRTS 溅射半径一览: 投掷手 0.9, 扩散术师 1.1 but 格雷伊 1.0, 链术师 jumps 1.7', () => { + const prof = (id) => resolveProfile(ds.getChess(id)); + assert.equal(prof('chess_char_6_12_a').splashRadius, 0.9, '迷迭香 (投掷手)'); + assert.equal(prof('chess_char_1_14_a').splashRadius, 1.0, '格雷伊'); + assert.equal(prof('chess_char_1_14_b').splashRadius, 1.0, '格雷伊 (elite)'); + for (const id of ['chess_char_4_02_a', 'chess_char_4_14_a', 'chess_char_5_12_a', 'chess_char_5_21_a']) assert.equal(prof(id).splashRadius, 1.1, id); + assert.equal(CHAIN_RADIUS, 1.7); + for (const id of ['chess_char_1_03_a', 'chess_char_6_05_a']) assert.equal(prof(id).chain.radius, 1.7, id); +}); + +test('阿罗玛\'s 非首次标记 is shared (PRTS 备注 "不同阿罗玛之间的非首次标记通用") and leaves with its setter ("自身离场时移除自身已施加的标记")', () => { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: dummy() } }, + units: [{ uid: 'A', chessId: 'chess_char_4_10_a', row: 10, col: 1 }, { uid: 'B', chessId: 'chess_char_4_10_a', row: 10, col: 2 }], + timeLimit: 60, hooks: ['statusApplied', 'damaged'], captureNoisy: true, autoFinish: false, + }); + h.step(); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + const [A, B] = ['A', 'B'].map((uid) => h.b.allyUnits.find((u) => u.uid === uid)); + assert.ok(h.runUntil(() => dmgTo(h, A, e) > 0 && dmgTo(h, B, e) > 0, 15), 'both 阿罗玛 strike it'); + h.run(1); + const lev = () => h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'levitate'); + assert.equal(lev().length, 1, 'one 浮空 for the pair (each used to trigger her own)'); + const mark = e.findBuff('aroma:bubbled'); + assert.ok(mark, 'the shared mark'); + const setter = mark.source, other = setter === A ? B : A; + h.b.kill(setter, null); + assert.ok(!e.findBuff('aroma:bubbled'), 'its setter left: the mark goes'); + const n = lev().length; + assert.ok(h.runUntil(() => lev().length > n, 10), 'the other 阿罗玛\'s next attack is a first attack again'); + assert.equal(e.findBuff('aroma:bubbled').source, other); + checkInvariants(h.b); +}); + +test('奥术法阵 on the field: 炎佑\'s damage silences its target (PRTS 盟约记录 备注 "造成伤害时使目标失去特殊能力5秒"); none without a carrier', () => { + const ARC = 'chess_item_3_08_e_a'; + for (const carried of [true, false]) { + const units = [{ chessId: 'chess_char_1_02_a', row: 12, col: 2, ...(carried ? { items: [ARC] } : {}) }]; + const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units, timeLimit: 60, hooks: ['statusApplied', 'damaged'], captureNoisy: true, autoFinish: false }); + h.step(); + const [y] = spawnYanyou(h.b, 'p1', { atk: 400, hp: 50000 }); + const e = h.spawn('enemy_dummy', { pos: [9, 7] }); + assert.ok(h.runUntil(() => dmgTo(h, y, e) > 0, 20), '炎佑 deals damage'); + h.run(0.2); + const sil = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'silence' && c.source === y); + if (carried) { + assert.ok(sil.length > 0, 'silenced by 炎佑'); + assert.ok(Math.abs(sil[0].duration - 5) < 1e-9, '5 s'); + } else assert.equal(sil.length, 0, 'no carrier: no rider'); + checkInvariants(h.b); + } +}); + +test('琳琅诗怀雅 S3 pays her coins to the ground enemies of range 2-4 in front (PRTS 备注), not only her 1-1 attack range', () => { + const id = 'chess_char_3_04_a'; + const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3, skillIndex: 2 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true, autoFinish: false }); + h.step(); + const u = h.unit(id); + assert.equal(u.skill.id, 'skchr_swire2_3'); + const near = h.spawn('enemy_dummy', { pos: [10, 4] }); // her attack range (1-1) + const far = h.spawn('enemy_dummy', { pos: [10, 5] }); // range 2-4 only (two tiles ahead) + const side = h.spawn('enemy_dummy', { pos: [11, 4] }); // range 2-4's diagonal front + const out = h.spawn('enemy_dummy', { pos: [10, 6] }); // outside both + assert.ok(h.runUntil(() => u.skill.active, 20), 'S3 on'); + u.mem.coins = 10; + const cash = () => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('swire2Cash')); + assert.ok(h.runUntil(() => cash().length >= 10, 10), 'the full purse is spent'); + const hit = new Set(cash().map((c) => c.target)); + assert.ok(hit.has(far) || hit.has(side), 'coins reach range 2-4 beyond her attack range'); + assert.ok(!hit.has(out), 'nothing outside range 2-4'); + for (const e of hit) assert.ok([near, far, side].includes(e)); + checkInvariants(h.b); +});