From 7aede84be9b5a4e39ed169d22183a7fa8133183f Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 18:04:48 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20WV:=20=E9=BA=A6=E5=93=B2=E4=BC=A6?= =?UTF-8?q?=20S1,=20=E4=BB=A4=20S3=20and=20=E7=94=B5=E5=BC=A7=20S2=20/=20S?= =?UTF-8?q?3=20also=20cast=20on=20an=20enemy=20inside=20the=20area=20they?= =?UTF-8?q?=20act=20through=20around=20a=20standing=20summon=20(drones'=20?= =?UTF-8?q?ranges,=20each=20summon's=20x-5=20ground=20only,=20=E8=B5=9B?= =?UTF-8?q?=E6=9F=AF's=20range=20ground=20only,=20=E6=A1=91=E7=89=B9?= =?UTF-8?q?=E6=8B=89's=20range)=20=E2=80=94=20the=20owner's=20larger-range?= =?UTF-8?q?=20rule=20(the=20owner's=20decision=20of=202026-10-06),=20via?= =?UTF-8?q?=20a=20shared=20summonTriggerArea=20and=20an=20additive=20{=20k?= =?UTF-8?q?eys,=20profile=20}=20entry=20for=20skill.addTriggerRange;=20gol?= =?UTF-8?q?den=20unchanged=20(no=20corpus=20battle=20places=20a=20?= =?UTF-8?q?=E8=87=AA=E9=80=89=20summon)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 8 +- server/sim/content/kits/ops/op-ling.js | 14 +- server/sim/content/kits/ops/op-mgllan.js | 12 +- server/sim/content/kits/ops/op-radian.js | 14 +- server/sim/content/kits/shared/summoner.js | 32 ++++- server/sim/skills.js | 17 ++- .../feedback5-wv-summon-triggers.test.js | 123 ++++++++++++++++++ 7 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 test/content/feedback5-wv-summon-triggers.test.js diff --git a/docs/SIM.md b/docs/SIM.md index c543acac..f6f78657 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -850,8 +850,12 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged (`unit.baseRangeKeys`: its own grid + its permanent rangeExtend — "攻击范围扩大" modules/talents as persist never-expiring `rangeExtend` buffs; no skill range, no temporary extend, no extra keys) or blocked by the melee unit — or, checked **every tick**, an enemy (flyers included) inside a content trigger range (`unit.skill.addTriggerRange(fn)`, - `fn(battle, unit)` → list of ally units (their current range) or tile-key arrays; returns an unregister fn; not for - heal skills: 海嗣 "攻击范围视为自身攻击范围的延伸", 流形); `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any + `fn(battle, unit)` → list of ally units (their current range), tile-key arrays or `{ keys, profile }` (tile keys with + the enemy profile the effect selects by — `canHitFly` false: ground enemies only); returns an unregister fn; not for + heal skills: 海嗣 "攻击范围视为自身攻击范围的延伸", 流形, 谬因 S2's beam, and the areas a skill acts through around its + owner's standing summons — 麦哲伦 S1 (her drones' ranges), 令 S3 (each summon's x-5, ground), 电弧 S2 (赛柯's range, + ground) / S3 (桑特拉's range): the owner's larger-range rule of 2026-10-06, kits/shared/summoner.js + `summonTriggerArea`); `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any living enemy (stealthed, untargetable, flying included) on `trigger.customRangeGrid` (= the skill's rangeGrid), every tick, no attack needed; `trigger.allies` (+ `hpAtMost`, default 1) — set by a kit, or by the data for an ally row (黍 S3's official `TRY_SEARCH_ALLY_SKILL`, "技能范围内存在可治疗的我方单位时释放技能": tools/build-data.mjs `TRIGGER_ALLY_RULES`, diff --git a/server/sim/content/kits/ops/op-ling.js b/server/sim/content/kits/ops/op-ling.js index 4b9cce27..e5866006 100644 --- a/server/sim/content/kits/ops/op-ling.js +++ b/server/sim/content/kits/ops/op-ling.js @@ -42,7 +42,9 @@ // The 备注's cast with no target cannot happen here: the data's DEFAULT casts it on an attack. // - S3 宁作吾 (MANUAL, data DEFAULT, 30 s): she and her summons ATK +atk, DEF +def; every `interval` (0.5) s from the moment // it covers a summon, every ground enemy on that summon's x-5 takes atk_scale × 令's ATK arts (溅射: not dodgeable, her -// damage — PRTS); +cnt held when it ends (not when she leaves). Passive: the 弦惊 merge — at a 弦惊's deployment, another +// damage — PRTS); +cnt held when it ends (not when she leaves). Its cast: the data's DEFAULT, and — the owner's +// larger-range rule of 2026-10-06 (its pulses act on each summon's x-5) — also a ground enemy on a standing summon's x-5 +// (shared/summoner.js summonTriggerArea, checked every tick). Passive: the 弦惊 merge — at a 弦惊's deployment, another // basic 弦惊 of hers on its range ⇒ that one turns 高级形态 and the new one is absorbed (withdrawn); else, the new one // standing on the range of one or more basic ones ⇒ it turns 高级形态 and the latest deployed of them is absorbed. 高级形态 // (until it leaves the field): its skill's 2.max_hp / 2.atk / 2.def (直接乘算), RES ×(1 + 2.magic_resistance), attack @@ -53,7 +55,7 @@ // stats). Its attributes are in the stats. import { num, talentBb, traitBb, moduleOn, skillRec, batMod, giveSp, up } from '../shared/tier1.js'; -import { summonDeck, holdBuff, tokenStat } from '../shared/summoner.js'; +import { summonDeck, holdBuff, tokenStat, summonTriggerArea } from '../shared/summoner.js'; import { absoluteRangeKeys } from '../../../targeting.js'; import { acquireTargets } from '../../../ai.js'; import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js'; @@ -72,6 +74,8 @@ export const RECYCLE_DELAY = 1.2; const X5 = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]); const TAG_S2 = 'ling:s2'; const TAG_S3 = 'ling:s3'; +/** S3's pulses: ground enemies only ("不可对空"). */ +const GROUND = Object.freeze({ canHitFly: false }); const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; const skillOn = (u, id) => !!(u && u.skill && u.skill.active && u.skill.id === id); @@ -127,7 +131,7 @@ function soulKit(t, owner, opts) { if (battle.time + 1e-9 < s.mem.lingAoeAt) return; s.mem.lingAoeAt += iv; const keys = absoluteRangeKeys(X5, s.tileR, s.tileC, s.dir, 0); - for (const e of battle.enemiesInKeys(keys, s, { canHitFly: false })) { + for (const e of battle.enemiesInKeys(keys, s, GROUND)) { if (!e.alive) continue; battle.dealDamage(owner, e, { amount: owner.s.atk * num(opts.b3.atk_scale), type: 'arts', isSkill: true, isSplash: true, canDodge: false, tags: ['skill', TAG_S3] }); } @@ -241,6 +245,10 @@ export default { } }, // 随付笺咏醉屠苏 lives in the deck's onLeave above ], + install(battle, unit) { + // S3's cast: a ground enemy on a standing summon's x-5 too (the owner's larger-range rule, 2026-10-06) + summonTriggerArea(battle, unit, S3, (t) => ({ keys: absoluteRangeKeys(X5, t.tileR, t.tileC, t.dir, 0), profile: GROUND })); + }, }; }, }; diff --git a/server/sim/content/kits/ops/op-mgllan.js b/server/sim/content/kits/ops/op-mgllan.js index 90ae1580..1a0ac7b9 100644 --- a/server/sim/content/kits/ops/op-mgllan.js +++ b/server/sim/content/kits/ops/op-mgllan.js @@ -38,7 +38,9 @@ // - S1 高效制冷模块 (MANUAL, data DEFAULT, 15 s): passive (while S1 is picked and not running) every attack@interval (3) s from // her deployment / the skill's end, every enemy (air units too) on her attack range and on each of her standing drones' // ranges (龙腾.F: x-4) takes 停顿 attack@sluggish s; while it runs, every 3 s from the cast, 束缚 attack@frozen_duration s -// instead. It acts while she is stunned or silenced (the buff is neither — isStunnable / isSilenceable 0). +// instead. It acts while she is stunned or silenced (the buff is neither — isStunnable / isSilenceable 0). Its cast: +// the data's DEFAULT, and — the owner's larger-range rule of 2026-10-06 (its 束缚 acts through the drones' ranges) — +// also an enemy (air units too) on a standing drone's range (shared/summoner.js summonTriggerArea, checked every tick). // - S2 激光开采模块 (MANUAL, data DEFAULT, 15 s): she and her drones ASPD +attack_speed; a 龙腾.L strikes every enemy on its tile // (群体) while it runs. S3 武装打击模块 (MANUAL, data DEFAULT, 15 s): she and her drones ATK +atk; 龙腾.A's blast radius // BLAST_S3 (1.25). Passive parts ("无人机可以部署在近战位 / 远程位"): placement — the piece's position, nothing in battle. @@ -51,10 +53,12 @@ // drones' lower costs (the module variant); stage 2+ T1 "最多同时部署4个", stage 3 "属性更强的" (the module variant's stats). import { num, talentBb, traitBb, moduleOn, skillRec, up } from '../shared/tier1.js'; -import { summonDeck, holdBuff, tokenStat } from '../shared/summoner.js'; +import { summonDeck, holdBuff, tokenStat, summonTriggerArea } from '../shared/summoner.js'; import { absoluteRangeKeys } from '../../../targeting.js'; import { COLS } from '../../../constants.js'; +/** S1's selection on her range and her drones' (air units too). */ +const ANY = Object.freeze({ canHitFly: true }); const S1 = 'skchr_mgllan_1'; const S2 = 'skchr_mgllan_2'; const S3 = 'skchr_mgllan_3'; @@ -171,6 +175,8 @@ export default { install(battle, unit) { // S1 高效制冷模块: every attack@interval s, 停顿 (passive) / 束缚 (running) on her range and her drones' ranges if (picked !== S1) return; + // its cast: an enemy on a standing drone's range too (the owner's larger-range rule, 2026-10-06) + summonTriggerArea(battle, unit, S1, (d) => ({ keys: d.rangeKeys, profile: ANY })); const iv = num(b1['attack@interval'], 3); if (!(iv > 0)) return; const restart = (c) => { if (c.unit === unit) unit.mem.mgllanPulseAt = battle.time + iv; }; @@ -186,7 +192,7 @@ export default { if (!(duration > 0)) return; const done = new Set(); for (const src of [unit, ...(unit.mem.summonDeck?.standing() ?? [])]) { - for (const e of battle.enemiesInKeys(src.rangeKeys, src, { canHitFly: true })) { + for (const e of battle.enemiesInKeys(src.rangeKeys, src, ANY)) { if (done.has(e)) continue; done.add(e); battle.applyStatus(e, key, { duration, source: src }); diff --git a/server/sim/content/kits/ops/op-radian.js b/server/sim/content/kits/ops/op-radian.js index 18fa33a5..3561f556 100644 --- a/server/sim/content/kits/ops/op-radian.js +++ b/server/sim/content/kits/ops/op-radian.js @@ -43,10 +43,14 @@ // - S2 环形鳞地 (MANUAL, data DEFAULT, 25 s): +cnt held; she and her summons ATK +atk (base_attack_time 0: no change); 赛柯's // attacks fire BULLET_OFFSETS (three bullets: its centre, 0.2 tile left-up and right-down — fixed, PRTS) that fly its token // skill's attack@projectile_life_time s (0.6 at rank 4, 0.8 at rank 7: "子弹飞行距离+1"); its 3-2 range is unchanged at ranks -// 4 / 7 (PRTS). Passive "会持续向前方发射子弹": 赛柯's data. +// 4 / 7 (PRTS). Passive "会持续向前方发射子弹": 赛柯's data. Its cast: the data's DEFAULT, and — the owner's larger-range +// rule of 2026-10-06 (it acts through 赛柯's shots) — also a ground enemy on a standing summon's range (its 3-2: the +// bullets reach no air unit; shared/summoner.js summonTriggerArea, checked every tick). // - S3 手牵手 (MANUAL, data DEFAULT, 30 s): +cnt held; she and her summons ATK +atk; before each damage of her attacks and of // any damage of her summons on an enemy: 停顿 `sluggish` s and 法术脆弱 (artsFragile damage_scale − 1, "同名效果取最高") for // weak[magic][limit] s (a dodged hit still carries them [ASSUMED]). Passive "召唤物可部署在远程位…协同攻击": 桑特拉 (above). +// Its cast: the data's DEFAULT, and — the owner's larger-range rule of 2026-10-06 (it acts through her summons' damage) +// — also an enemy (air units too: S3 备注 "召唤物可对空") on a standing summon's range (summonTriggerArea). // - Modules SO-A 电弧特勤证章 / SO-B 新起点: their trait and talent parts act "在【岁的界园志异】中" only (the client gates them to // the 集成战略 theme rogue_yan / validInGameTag roguelike): N/A here — the module talents the composed record carries // (SO-A's hidden respawn_time / prob / sp, SO-B's attack@max_target and x-5 recall) are ignored; their attributes are in the @@ -54,7 +58,7 @@ // its tests. import { num, talentBb, skillRec, up } from '../shared/tier1.js'; -import { summonDeck, holdBuff, tokenStat } from '../shared/summoner.js'; +import { summonDeck, holdBuff, tokenStat, summonTriggerArea } from '../shared/summoner.js'; import { acquireTargets } from '../../../ai.js'; import { aggregateMods } from '../../../buffs.js'; import { dirVec } from '../../../dir.js'; @@ -79,6 +83,9 @@ const BULLET_OFFSETS = Object.freeze([[0, 0], [-0.2, 0.2], [0.2, -0.2]]); const LINK_SPEED = 50; const SHIELD_TYPES = Object.freeze(['phys', 'arts', 'true']); const KEY_BARRIER = 'radian:s1:barrier'; +/** The summons' areas a skill casts on (summonTriggerArea): 赛柯's bullets hit ground enemies only, S3's summons air too. */ +const GROUND = Object.freeze({ canHitFly: false }); +const ANY = Object.freeze({ canHitFly: true }); const TAG_BULLET = 'radian:bullet'; const TAG_LINK = 'radian:link'; @@ -268,6 +275,9 @@ export default { // 加油~ lives in the deck's install above (its 鼓舞 at each deployment and every second) ], install(battle, unit) { + // S2 / S3's cast: an enemy on a standing summon's range too (the owner's larger-range rule, 2026-10-06) + summonTriggerArea(battle, unit, S2, (t) => ({ keys: t.rangeKeys, profile: GROUND })); + summonTriggerArea(battle, unit, S3, (t) => ({ keys: t.rangeKeys, profile: ANY })); // S3 手牵手: before each damage of her attacks / of her summons' damage on an enemy, 停顿 + 法术脆弱 if (picked !== S3) return; const slug = num(b3.sluggish), dur = num(b3['weak[magic][limit]']), frag = num(b3.damage_scale, 1) - 1; diff --git a/server/sim/content/kits/shared/summoner.js b/server/sim/content/kits/shared/summoner.js index dea26936..41e4df46 100644 --- a/server/sim/content/kits/shared/summoner.js +++ b/server/sim/content/kits/shared/summoner.js @@ -1,6 +1,7 @@ // server/sim/content/kits/shared/summoner.js — the 召唤师 (summoner) branch in 卫戍: the summon deck the 自选 kits of 麦哲伦 // (ops/op-mgllan.js), 令 (ops/op-ling.js) and 电弧 (ops/op-radian.js) share — the holding (持有), the return of a placed piece -// that left the field, recalls (回收) and the summons vanishing with their owner. Kit contract and rules: ../README.md. +// that left the field, recalls (回收), the summons vanishing with their owner, and the skills that cast on an enemy in a +// summon's area (summonTriggerArea: the owner's larger-range rule, 2026-10-06). Kit contract and rules: ../README.md. // // Sources: the client's battle data (charpack char_248_mgllan / char_2023_ling / char_4195_radian: Talents/1 // charge_token[born] — RechargeToken `cnt` at every deployment of the owner; CommonAbilities die_to_kill_token — KillTokens @@ -42,6 +43,35 @@ export function holdBuff(battle, u, key, want, mods, extra = null) { battle.addBuff(u, { key, mods, tags: ['skill'], ...extra }); } +/** + * The owner's larger-range rule through summons (the owner's decision of 2026-10-06; ACTIVE_RANGE, server/sim/skills.js: + * "a skill whose effect reaches farther than the operator's own range casts when an enemy is inside the larger area"): + * when `owner`'s picked skill is `skillId`, it also casts while an enemy is inside the area it acts through around one of + * the owner's summons standing on the field — on top of its data rule (DEFAULT: an enemy in the owner's own range, about + * to attack). A content trigger range (SkillRuntime.addTriggerRange), checked every tick: `area(piece)` gives that + * summon's area as `{ keys, profile }` (absolute tile keys; the enemy profile the effect selects by — `canHitFly` false + * when it cannot reach air units) or null. Used by 麦哲伦 S1 (her drones' ranges), 令 S3 (each summon's x-5) and 电弧 S2 / + * S3 (赛柯's / 桑特拉's ranges). Returns the unregister fn, or null when the skill is another one. + * @param {object} battle + * @param {object} owner the summoner unit (its deck: owner.mem.summonDeck) + * @param {string} skillId + * @param {(piece: object) => ({ keys: number[], profile?: object } | null)} area + */ +export function summonTriggerArea(battle, owner, skillId, area) { + const sk = owner?.skill; + if (!sk || sk.id !== skillId) return null; + return sk.addTriggerRange(() => { + const deck = owner.mem.summonDeck; + if (!deck || !up(owner) || battle.finished) return []; + const out = []; + for (const t of deck.standing()) { + const a = area(t); + if (a && Array.isArray(a.keys) && a.keys.length) out.push(a); + } + return out; + }); +} + /** * A field of a summon's token record at its owner's form and module (data/backups.json `tokens[id].variants[@ * ]`, `byModule[uniEquipId]`): the stats the normalised def drops (`deployLimit`, `deckStack`). diff --git a/server/sim/skills.js b/server/sim/skills.js index 44090f2d..18316a1c 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -8,7 +8,8 @@ // Trigger rules (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; data: tools/build-data.mjs resolveTrigger): // DEFAULT — the basic strategy: ready + about to attack/heal + enemy / injured ally in the INITIAL range (or blocked by // a melee unit) — or, checked every tick, an enemy inside one of the content trigger ranges added with -// addTriggerRange: 海嗣, 流形; +// addTriggerRange: 海嗣, 流形, 谬因 S2's beam, the summons' areas a skill acts through (麦哲伦 S1, 令 S3, 电弧 S2 / S3 — +// the owner's larger-range rule, 2026-10-06: kits/shared/summoner.js summonTriggerArea); // SKILL_RANGE — a MANUAL skill with a 技能范围 of its own: "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其 // 不可选中)时释放技能": any living enemy on the trigger grid (the skill range; stealthed / untargetable / flying ones // too), checked every tick, no attack needed. Kit option `trigger.allies` (+ `hpAtMost`, default 1): a healable, @@ -135,9 +136,12 @@ export class SkillRuntime { /** * Extra DEFAULT-trigger range (海嗣 "攻击范围视为自身攻击范围的延伸", 流形): `fn(battle, unit)` returns a list whose - * entries are ally units (their current `rangeKeys` count while they are on the field) or arrays of absolute tile - * keys. A targetable enemy (flyers included) on those tiles satisfies the DEFAULT rule (and unknown DEFAULT-like - * rules); it is checked every tick, since the unit itself may have nothing to attack. Returns an unregister fn. + * entries are ally units (their current `rangeKeys` count while they are on the field), arrays of absolute tile + * keys, or `{ keys, profile }` — tile keys with the enemy profile the effect selects by (`canHitFly` false: ground + * enemies only — the owner's larger-range rule through a summon's area, kits/shared/summoner.js summonTriggerArea; + * 0.2.0 WV, additive). A targetable enemy (flyers included unless the entry's profile says otherwise) on those tiles + * satisfies the DEFAULT rule (and unknown DEFAULT-like rules); it is checked every tick, since the unit itself may + * have nothing to attack. Returns an unregister fn. */ addTriggerRange(fn) { if (typeof fn !== 'function') return () => {}; @@ -153,10 +157,11 @@ export class SkillRuntime { const list = b._safe(() => fn(b, u), 'skill.triggerRange', u); if (!list || typeof list[Symbol.iterator] !== 'function') continue; for (const x of list) { - let keys = null; + let keys = null, prof = TRIGGER_PROFILE; if (Array.isArray(x)) keys = x; else if (x && typeof x === 'object' && x.side === 'ally' && x.alive && x.deployed && !x.hidden) keys = x.rangeKeys; - if (keys && keys.length && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length) return true; + else if (x && typeof x === 'object' && Array.isArray(x.keys)) { keys = x.keys; if (x.profile) prof = x.profile; } + if (keys && keys.length && b.enemiesInKeys(keys, u, prof).length) return true; } } return false; diff --git a/test/content/feedback5-wv-summon-triggers.test.js b/test/content/feedback5-wv-summon-triggers.test.js new file mode 100644 index 00000000..21959314 --- /dev/null +++ b/test/content/feedback5-wv-summon-triggers.test.js @@ -0,0 +1,123 @@ +// test/content/feedback5-wv-summon-triggers.test.js — 0.2.0 WV #2: the owner's larger-range rule through summons (the +// owner's decision of 2026-10-06: "a skill whose effect reaches farther than the operator's own range casts when an enemy is +// inside the larger area"). A skill that acts through its owner's summons casts on an enemy inside such an area of a summon +// standing on the field, on top of its data rule (DEFAULT: an enemy in the owner's own range) — kits/shared/summoner.js +// summonTriggerArea (a content trigger range, server/sim/skills.js addTriggerRange with a `{ keys, profile }` entry): +// * 麦哲伦 S1 高效制冷模块 — her drones' ranges (air units too: its 停顿 / 束缚 reach them); +// * 令 S3 宁作吾 — each summon's x-5 (ground enemies only: its pulses "不可对空"); +// * 电弧 S2 环形鳞地 — 赛柯's range (ground only: its bullets reach no air unit); S3 手牵手 — 桑特拉's range (air too). +// Run: node --test test/content/feedback5-wv-summon-triggers.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { COLS } from '../../server/sim/constants.js'; + +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) }; +const MG = 'char_248_mgllan', LING = 'char_2023_ling', RADIAN = 'char_4195_radian'; +const DRONE_F = 'token_10005_mgllan_drone1'; +const SOUL3 = 'token_10020_ling_soul3'; +const TOWER2 = 'token_10052_radian_tower2', TOWER3 = 'token_10053_radian_tower3'; +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +/** `charId` as uid 1 at (9, 2) facing RIGHT (her 3-1: rows 8–10, cols 2–5) with skill `skill`; `pieces` = [[tokenId, r, c]]. */ +function field(charId, skill, pieces = [], { tier = 6, elite = true } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed: 5, + flags: { dpInit: 99, dpPerSec: 0, dpMax: 999, startOpCooldown: 0 }, hooks: ['skillStart', 'skillEnd', 'death'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId, skillIndex: skill, uniEquipId: null }, elite, row: 9, col: 2 }, + ...pieces.map(([tokenId, r, c], i) => ({ uid: 10 + i, kind: 'token', tokenId, ownerUid: 1, row: r, col: c, dir: 'RIGHT' }))], + }); + h.step(); + const u = h.unit(1); + return { h, u, ps: h.b.allyUnits.filter((a) => a.kind === 'token' && a.ownerUnit === u) }; +} +/** Fill the skill and run `s` seconds; true when it was cast. */ +function casts(h, u, s = 2) { + u.skill.gainSp(999); + h.run(s); + return u.skill.activations > 0; +} +const outOfHerRange = (u, r, c) => !u.rangeKeySet.has(r * COLS + c); + +test('#2 麦哲伦 S1: an enemy on a standing drone\'s range (air units too) casts it; none there or with the drone gone: no cast; S2 / S3 get no such area', () => { + { // a ground enemy on 龙腾.F's x-4, far from her 3-1 + const { h, u, ps } = field(MG, 0, [[DRONE_F, 12, 9]]); + assert.equal(u.skill.rule, 'DEFAULT'); + assert.ok(ps[0].alive && ps[0].rangeKeySet.has(12 * COLS + 10) && outOfHerRange(u, 12, 10), 'the setup'); + h.spawn('enemy_dummy', { pos: [11, 13] }); // outside every area + assert.ok(!casts(h, u), 'nobody in her range or a drone\'s: no cast'); + h.spawn('enemy_dummy', { pos: [12, 10] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'an enemy on the drone\'s range casts S1'); + done(h); + } + { // a flyer there too + const { h, u } = field(MG, 0, [[DRONE_F, 12, 9]]); + h.spawn('enemy_fly', { pos: [11, 10] }); + assert.ok(casts(h, u), 'a flyer on the drone\'s range'); + done(h); + } + { // the drone off the field: nothing + const { h, u, ps } = field(MG, 0, [[DRONE_F, 12, 9]]); + h.b.retreat(ps[0], { reason: 'retreat' }); + h.spawn('enemy_dummy', { pos: [12, 10] }); + assert.ok(!casts(h, u), 'no standing drone: no cast'); + done(h); + } + for (const skill of [1, 2]) { + const { h, u } = field(MG, skill, []); + assert.equal(u.skill.triggerRanges.length, 0, `S${skill + 1}`); + done(h); + } +}); + +test('#2 令 S3: a ground enemy on a standing summon\'s x-5 casts it; a flyer there, or a ground enemy off the x-5, does not; S1 / S2 get no such area', () => { + { // 弦惊 at (12, 9): its x-5 = (12, 9) and its four side neighbours + const { h, u, ps } = field(LING, 2, [[SOUL3, 12, 9]]); + assert.equal(u.skill.rule, 'DEFAULT'); + assert.ok(ps[0].alive && outOfHerRange(u, 11, 9), 'the setup'); + h.spawn('enemy_fly', { pos: [11, 9] }); // on the x-5, but air + h.spawn('enemy_dummy', { pos: [11, 10] }); // diagonal: off the x-5 + assert.ok(!casts(h, u), 'a flyer on the x-5 / a ground enemy off it: no cast'); + h.spawn('enemy_dummy', { pos: [11, 9] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'a ground enemy on the summon\'s x-5 casts S3'); + done(h); + } + for (const skill of [0, 1]) { + const { h, u } = field(LING, skill, []); + assert.equal(u.skill.triggerRanges.length, 0, `S${skill + 1}`); + done(h); + } +}); + +test('#2 电弧 S2: a ground enemy on 赛柯\'s range casts it (a flyer there does not); S3: an enemy on 桑特拉\'s range, air units too; S1 gets no such area', () => { + { // 赛柯 at (12, 6) facing RIGHT: its 3-2 = (12, 6) … (12, 9) + const { h, u, ps } = field(RADIAN, 1, [[TOWER2, 12, 6]]); + assert.equal(u.skill.rule, 'DEFAULT'); + assert.ok(ps[0].alive && ps[0].rangeKeySet.has(12 * COLS + 9) && outOfHerRange(u, 12, 9), 'the setup'); + h.spawn('enemy_fly', { pos: [12, 9] }); + assert.ok(!casts(h, u), 'a flyer on 赛柯\'s range: no cast'); + h.spawn('enemy_dummy', { pos: [12, 9] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'a ground enemy on 赛柯\'s range casts S2'); + done(h); + } + { // 桑特拉 at (12, 6) facing RIGHT: its 3-1 = rows 11–13, cols 6–9 + const { h, u, ps } = field(RADIAN, 2, [[TOWER3, 12, 6]]); + assert.ok(ps[0].alive && ps[0].rangeKeySet.has(12 * COLS + 9) && outOfHerRange(u, 12, 9), 'the setup'); + h.spawn('enemy_dummy', { pos: [11, 13] }); // outside every area + assert.ok(!casts(h, u), 'nobody in an area: no cast'); + h.spawn('enemy_fly', { pos: [12, 9] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'a flyer on 桑特拉\'s range casts S3'); + done(h); + } + { + const { h, u } = field(RADIAN, 0, []); + assert.equal(u.skill.triggerRanges.length, 0, 'S1'); + done(h); + } +});