From 27181d88bda0c2de46e42247f998023c3ce34e89 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 04:59:08 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feedback1e=20WR:=20=E9=98=B5=E6=B3=95?= =?UTF-8?q?=E6=9C=AF=E5=B8=88=20/=20=E8=BD=B0=E5=87=BB=E6=9C=AF=E5=B8=88?= =?UTF-8?q?=20strike=20every=20enemy=20in=20range=20(=E5=8D=A1=E6=B6=85?= =?UTF-8?q?=E5=88=A9=E5=AE=89=20E3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community report E3 "卡涅利安的攻击不是真群攻" was real: the phalanx trait "技能开启时攻击造成群体法术伤害" was read as one bolt at the priority target plus a 1.1-tile splash, so spread enemies took one hit per attack (act2 m01, round 10: 2.3 of 12.4 enemies on her range per attack, never all). Officially a 阵法术师 attack hits every enemy inside the attack range at once, the same damage near and far (Arknights Terra Wiki, Phalanx Caster; PRTS 林 S3 note; PRTS 作战机制 AOE), and so does the 轰击术师 line (Terra Wiki, Blast Caster; PRTS 作战机制: 伊芙利特's 炎爆 is a range-locked AoE). The 扩散术师 keep their 1.1-tile splash around the target. - professions.js: phalanx and blastcaster get allInRange + rangeAoe (instant 'beam' hits, no projectile) instead of the 1.1 splash. - 卡涅利安 S3 食噬之印: ATK climbs in 1 s steps to the full bonus at 20 s (PRTS note) instead of continuously over the 21 s duration. - fx.js: phalanx / blastcaster leave the splash-ring list. - Tests: real battles counting damaged enemies per attack for every phalanx skill, both blast casters, a splash-caster guard, a real stage and wave, the S3 ramp; opt-in headless-Chrome check of the strikes. - SIM.md profession rows and kit comments updated. --- docs/SIM.md | 11 +- public/js/render/fx.js | 3 +- server/sim/content/kits/tier3.js | 5 +- server/sim/content/kits/tier4.js | 25 ++-- server/sim/professions.js | 21 ++- test/content/kits_t4.test.js | 4 +- test/render/phalanx-aoe.browser.test.js | 106 ++++++++++++++ test/sim/feedback1e-aoe.test.js | 182 ++++++++++++++++++++++++ 8 files changed, 332 insertions(+), 25 deletions(-) create mode 100644 test/render/phalanx-aoe.browser.test.js create mode 100644 test/sim/feedback1e-aoe.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..ae10f8c7 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -886,17 +886,20 @@ PRTS 选择器, is not in the data): "地面敌人" / "不可对空" ⇒ `!e.isF 风丸 折纸生花, 乌尔比安 S3, 缄默德克萨斯 S2, 余 S2, 见行者 S2, 耀骑士临光 不畏苦暗 and the token appear bursts — “耀阳”, 沙之碑, 迷迭香的战术装备's stun, 纸偶). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体 for good. Ranged attacks fly as projectiles (`constants.js PROJECTILE_SPEEDS`: arrow 14, bolt 11, bomb/lob 8, orb 10, drone 16, enemy 10 tiles/s; boomerang 15 out, -3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃); melee/`none` hits are instant. Kit-settable profile flags beyond the +3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃); melee/`none` hits are instant, and so are `'beam'` hits (a 锁定攻击范围 AoE +without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`, `afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays -`'boomerang'` whatever the data's generic ranged projectile says) (see the header of professions.js). +`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (likewise `'beam'`, with `allInRange`) +(see the header of professions.js). | sub | behaviour | |---|---| | 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 / blastcaster | splash 1.1 tiles at full damage | +| aoesniper / splashcaster | splash 1.1 tiles around the struck target at full damage (Arknights Terra Wiki, Splash Caster: "a radius of 1.1 tiles centered on the target") | +| blastcaster | `rangeAoe`: every enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (Terra Wiki, Blast Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE; community report E3) | | bombarder | ground-only splash 1.0 + aftershock(s) at 50 % ATK (bb append_atk_scale / times) | | 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) | @@ -904,7 +907,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.8-tile jumps, 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; attacks with 1.1 splash while on | +| phalanx | no attack & DEF +200 %, RES +20 (bb) while the skill is off; while on, `rangeAoe`: each attack strikes every enemy on its range at once (blocked enemies included), the same damage near and far, instant (`'beam'`) — "群体法术伤害" (Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; PRTS 林 S3 备注 "单次普攻最多触发1次效果"; community report E3: it used to be one bolt + a 1.1 splash) | | physician | heal the lowest HP% injured ally in range (a skill `targeting.maxTargets` widens any heal profile) | | ringhealer | heal 3 allies | | chainhealer | heal bounces 3× (−25 %, bb chain.*) within 2.5 tiles | diff --git a/public/js/render/fx.js b/public/js/render/fx.js index 2b33238a..000e3321 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -130,7 +130,8 @@ const camKey = (c) => (c ? c.tx + c.ty * 1e3 + c.tz * 1e6 + c.tilt * 7.13 + c.di /** Characters of a damage number as drawn (heals get a '+'). */ const numChars = (v, style) => String(Math.round(v)).length + (style === 'heal' ? 1 : 0); -const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'blastcaster', 'bombarder', 'phalanx', 'fortress', 'hammer']); +/** Sub-professions whose attacks splash around the struck target (the 阵法术师 / 轰击术师 strike every enemy in range). */ +const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'bombarder', 'fortress', 'hammer']); let fontsReady = false; /** Generate the damage-number bitmap fonts (after web fonts loaded, if possible). */ diff --git a/server/sim/content/kits/tier3.js b/server/sim/content/kits/tier3.js index e6c50a7a..801b0b87 100644 --- a/server/sim/content/kits/tier3.js +++ b/server/sim/content/kits/tier3.js @@ -768,10 +768,11 @@ const KITS = { return kit; }, - // ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each hit pushes the target towards her (splash arts), end-of-skill burst on + // ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each attack strikes every enemy on her range (the trait's 群体法术伤害) and + // pushes each one towards her, end-of-skill burst on // every enemy in range; 地质学者: DEF aura (skill off) / less likely targeted (skill on); // 精锐 module PLX-X: keeps part of the guard (DEF/RES) while the skill runs - // S1 风语: wider range (skill grid), attacks at attack@atk_scale (群体 arts splash of the trait). Auto-cast: the data + // S1 风语: wider range (skill grid), attacks every enemy on it at attack@atk_scale. Auto-cast: the data // rule SEARCH is the 阵法术师 row (PRTS 卫戍协议/帮助 "不受基础策略影响,在初始攻击范围内存在敌人时释放技能"; it // covers every MANUAL skill of the class — user playtest #6) = an enemy inside her INITIAL range — the engine's // DEFAULT rule, checked every tick for a unit that does not attack while its skill is off — not any enemy on the diff --git a/server/sim/content/kits/tier4.js b/server/sim/content/kits/tier4.js index a55983ad..73be93ba 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -16,8 +16,8 @@ // groupId `abyssal` (data/chess.json has no groupId: charIds from docs/research/03-operators.json). // - "友方干员" effects touch operators only (summons/devices excluded); SP gifts skip units whose timed skill runs // (AK: no SP gain during a skill — the engine's gainSp enforces it too; the kits skip such units when picking). -// - On-hit statuses of AoE skill attacks (卡涅利安) apply to every enemy the attack damages (a SkillSpec -// `attack.onHit` only sees the main target), via a `damaged` hook. +// - On-hit statuses of 卡涅利安's attacks apply to every enemy the attack damages — the 阵法术师 trait strikes every +// enemy on her range at once (professions.js `allInRange`, community report E3) — via a `damaged` hook. // - Dodge from a skill that adds to other dodge sources (焰尾 S3) is rolled independently in a `hit` hook, so the // sources combine as 1 − Π(1 − p) (the same rule the engine now applies to stacked dodge mods). // - Operator loadouts (DESIGN §16): every selectable non-default skill of the 22 visible chess is authored in the kit's @@ -37,6 +37,8 @@ const AURA = 0.2; // aura refresh period (s) const AURA_DUR = 0.25; // aura buff lifetime (s): lapses ~1 tick after the source stops refreshing it const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']); const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i]))); +/** 卡涅利安 S3 食噬之印: the ATK bonus climbs in 1 s steps over 20 s (PRTS 备注; the blackboard has no ramp time). */ +const BILLRO_S3_RAMP = 20; // --------------------------------------------------------------------------------------------------------------- // helpers @@ -834,7 +836,7 @@ const kits = { kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 1.5), - onEachHit({ battle, unit, target }) { // every victim of the blast (main + splash) that flies + onEachHit({ battle, unit, target }) { // every enemy of her line the blast strikes that flies if (target && target.alive && target.isFlying) battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb.atk_scale_to_fly, 0.55), type: 'arts', isSkill: true, tags: ['skill', 'antiAir'] }); }, }, @@ -1712,11 +1714,12 @@ const kits = { }; }, - // ===== 卡涅利安 (phalanx) S2 沙缚镣锁 — faster AoE, 0.3 s sluggish; charged: ATK +10 % and bind; talents + // ===== 卡涅利安 (phalanx) S2 沙缚镣锁 — faster attacks on every enemy in range (the trait's 群体法术伤害), 0.3 s + // sluggish on each; charged: ATK +10 % and bind; talents // S1 沙暴守卫 (SEARCH: ATK/DEF up; charged — cast with every charge stored — the trait's DEF/RES guard stays on); - // S3 食噬之印 (wider range, ATK ramps to +140 %/+200 % over the skill; charged: every hit marks the target, +20 % - // damage from her per mark, ≤ 5, until the skill ends). Talent 生命之餐 heals on every skill; module PLX-X keeps - // part of the guard during any skill, PLX-Y (乡音): +3 % damage per enemy in range (≤ 5) + // S3 食噬之印 (wider range, ATK +0 % → +140 %/+200 % in 1 s steps over 20 s — PRTS 备注; charged: every hit marks + // its target, +20 % damage from her per mark, ≤ 5, until the skill ends). Talent 生命之餐 heals on every skill; + // module PLX-X keeps part of the guard during any skill, PLX-Y (乡音): +3 % damage per enemy in range (≤ 5) chess_char_4_24_a: (bb, chess, def) => { const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); const tb = def.traitBb || {}; @@ -1748,10 +1751,11 @@ const kits = { battle.addBuff(unit, { key: 'billro:s3atk', mods: { atkPct: 0 } }); battle.fx('devour', { x: unit.x, y: unit.y, id: unit.id }); }, - onTick({ unit, skill, dt }) { // 攻击力逐渐增至+N% (linear over the skill duration) + onTick({ unit, dt }) { // 攻击力逐渐增至+N%: PRTS 备注 "从+0%开始在20秒内线性增加,攻击力每1秒更新1次" unit.mem.billroRamp = (unit.mem.billroRamp ?? 0) + dt; + const steps = Math.min(BILLRO_S3_RAMP, Math.floor(unit.mem.billroRamp + 1e-9)); const b = unit.findBuff('billro:s3atk'); - if (b) { b.mods = { atkPct: num(bb.atk) * Math.min(1, unit.mem.billroRamp / Math.max(0.1, skill.duration)) }; unit.markDirty(); } + if (b && b.mods.atkPct !== num(bb.atk) * steps / BILLRO_S3_RAMP) { b.mods = { atkPct: num(bb.atk) * steps / BILLRO_S3_RAMP }; unit.markDirty(); } }, onEnd({ battle, unit }) { battle.removeBuff(unit, 'billro:s3atk'); @@ -1817,8 +1821,7 @@ const kits = { }, { owner: unit }); } if (!S2) return; - // "每次攻击对目标造成0.3秒停顿" (charged: 束缚): every enemy damaged by her AoE skill attack — the splash victims - // too (a SkillSpec attack.onHit only sees the main target) + // "每次攻击对目标造成0.3秒停顿" (charged: 束缚): every enemy her attack damages — each one on her range battle.on('damaged', (c) => { const e = c.target; if (c.source !== unit || !c.dmg || !c.dmg.isAttack || e.side !== 'enemy' || !e.alive || !skillActive(unit)) return; diff --git a/server/sim/professions.js b/server/sim/professions.js index 23290044..05223670 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -5,10 +5,13 @@ // data fields (dmgType/attackKind/projectile/canHitFly/targetPriority from data/chess.json) → kit.trait overrides. // Profile fields: // attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none' -// projectile 'none'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'|'boomerang' ('boomerang': out to the target and back to -// the thrower, ai.js throwBoomerang) boomerang bool (回环射手: keeps 'boomerang') +// projectile 'none'|'beam'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'|'boomerang' ('beam': an instant hit drawn as a +// line; 'boomerang': out to the target and back to the thrower, ai.js throwBoomerang) +// boomerang bool (回环射手: keeps 'boomerang') // canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy) -// allInRange bool splashRadius tiles splashScale (× damage for splash victims) +// allInRange bool (every enemy on the range at once) splashRadius tiles (around the struck target) +// rangeAoe bool (a 锁定攻击范围 AoE without a projectile: allInRange, instant 'beam' hits) +// splashScale (× damage for splash victims) // splashOthersOnly bool groundOnly bool hits n (damage instances per attack) // chain {count, falloff, radius, sluggish} heal {mode:'single'|'multi'|'chain', count, falloff, farMul, elementHealRatio} // priority 'fly'|'lowDef'|'ranged'|'lowestHp'|'highestHp'|'nearest'|'farthest'|'notBurst'|null @@ -297,8 +300,12 @@ export const SUB = Object.freeze({ return front ? (unit.profile.frontScale ?? 1.5) : 1; } }), // --- CASTER + // "群体法术伤害" names two shapes: the 扩散术师 splash 1.1 tiles around the struck target (Arknights Terra Wiki, Splash + // Caster), while the 轰击术师 ("超远距离的群体法术伤害") and the 阵法术师 strike every enemy inside the attack range at + // once, the same damage near and far (Terra Wiki, Blast / Phalanx Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a + // 锁定攻击范围 AoE) — community report E3 after 0.1.0 splashcaster: P({ splashRadius: 1.1 }), - blastcaster: P({ splashRadius: 1.1, projectile: 'bomb' }), + blastcaster: P({ allInRange: true, rangeAoe: true }), chain: P({ chain: { count: 3, falloff: 0.15, radius: 1.8, sluggish: 0.5 } }), funnel: P({ projectile: 'drone', install: installFunnel, dmgMul: (battle, unit, target) => { @@ -309,7 +316,7 @@ export const SUB = Object.freeze({ } }), mystic: P({ install: installMystic, hitsFn: (battle, unit) => { const n = 1 + (unit.trait.stored ?? 0); unit.trait.stored = 0; return n; } }), - phalanx: P({ noAttackUnlessSkill: true, splashRadius: 1.1, install: installPhalanx }), + phalanx: P({ noAttackUnlessSkill: true, allInRange: true, rangeAoe: true, install: installPhalanx }), primcaster: P({}), corecaster: P({}), // --- MEDIC @@ -476,6 +483,10 @@ export function resolveProfile(def, kitTrait = null) { // a boomerang thrower (回环射手) always throws its boomerang: the data's generic ranged projectile ('arrow', a // build-data default) would turn the out-and-back flight into a plain shot if (p.boomerang && p.attack === 'ranged') p.projectile = 'boomerang'; + // a 锁定攻击范围 AoE (阵法术师, 轰击术师) has no projectile: every enemy on the range is struck at the same moment + // ('beam' = instant hits, drawn as a line to each victim — PRTS 作战机制 "在攻击前摇结束时选取范围内的全体目标,同时 + // 造成伤害"); the data's generic ranged projectile ('bolt') would land them one by one + if (p.rangeAoe && p.attack === 'ranged') p.projectile = 'beam'; if (def.type === 'token') { if (p.dmgType === 'heal') p.heal = p.heal || { mode: 'single' }; if (def.stats.atk <= 0) p.noAttack = true; diff --git a/test/content/kits_t4.test.js b/test/content/kits_t4.test.js index 109bed25..91c4f748 100644 --- a/test/content/kits_t4.test.js +++ b/test/content/kits_t4.test.js @@ -371,7 +371,7 @@ test('阿罗玛: first attack ×1.1 + 2.5 s levitation; S2: +ATK and landing dam assert.ok(noisy(h, 'statusApplied').some((c) => c.target === e && c.status === 'levitate' && c.duration === t0.levitate_duration)); // a second enemy: skill on before it is bubbled → landing damage assert.ok(u.skill.activate('test', { free: true })); - const e2 = h.spawn('enemy_dummy', { pos: [10, 7] }); // splashed by the next attack on the first dummy + const e2 = h.spawn('enemy_dummy', { pos: [10, 7] }); // on her line: struck by the next attack too (轰击术师) approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-9); h.runUntil(() => dmgBy(h, u, (c) => c.dmg?.tags?.includes('landing')).length >= 1, 15); const land = dmgBy(h, u, (c) => c.dmg?.tags?.includes('landing')); @@ -1067,7 +1067,7 @@ test('瑰盐 S2 only defers damage taken by operators (summons take it in full)' assert.equal(noisy(h, 'damaged').filter((c) => c.target === tok && c.dmg?.tags?.includes('hpLoss')).length, 0); }); -test('卡涅利安 S2: every enemy hit by the AoE attack is slowed (splash victims too)', () => { +test('卡涅利安 S2: every enemy hit by the group attack is slowed', () => { const id = 'chess_char_4_24_a', bb = D(id).skill.bb; const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 60, hooks: ['statusApplied'] }); h.step(); diff --git a/test/render/phalanx-aoe.browser.test.js b/test/render/phalanx-aoe.browser.test.js new file mode 100644 index 00000000..7937194a --- /dev/null +++ b/test/render/phalanx-aoe.browser.test.js @@ -0,0 +1,106 @@ +// test/render/phalanx-aoe.browser.test.js — community report E3 after 0.1.0 ("干员卡涅利安的攻击不是真群攻") in headless +// Chrome: a real battle (卡涅利安 S2 沙缚镣锁 with five enemies spread over her range, no two within a 1.1-tile splash) +// is simulated in Node and its frames are fed to the real field view (public/dev/render-demo.html, real Spine models) +// the way the client runner does. Each of her attacks strikes every enemy on her range at once (the 阵法术师 group +// attack, sim/professions.js `rangeAoe`): the field draws a strike line from her to each of the five in the same frame +// and every one of them shows its damage number. +// +// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/phalanx-aoe.browser.test.js +// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/phalanx-aoe.png. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeBattle } from '../helpers/battleHarness.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const OUT = path.join(ROOT, 'test/e2e/out'); +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets')); +const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)'; +const SPREAD = [[10, 7], [10, 3], [12, 5], [9, 5], [11, 6]]; + +/** Real frames: 卡涅利安 (S2 on) against five standing 源石虫 spread over her x-1 range. */ +function recordBattle(seconds = 8) { + const h = makeBattle({ units: [{ chessId: 'chess_char_4_24_a', row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 }); + h.step(); + const u = h.allies()[0]; + const es = SPREAD.map((p) => h.spawn('enemy_1007_slime', { pos: p, routeIndex: 0, mods: { speedMul: 0, hpMul: 1000 } })); + u.skill.gainSp(u.skill.spCost); + const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' }; + h.b.drainEvents(); + const frames = []; + for (let i = 0; i < seconds * 30; i++) { + h.b.step(); // (not h.step(): the harness would drain the events itself) + const { t, ...rest } = h.b.snapshot(); + frames.push({ snap: { ...rest, t: 'b.snap', gt: t }, ev: h.b.drainEvents(), gt: t }); + } + return { meta, frames, opId: u.id, enemyIds: es.map((e) => e.id) }; +} + +describe('阵法术师 group attack in headless Chrome (community report E3)', { skip }, () => { + let srv, browser; + before(async () => { + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../../server/index.js'); + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] }); + mkdirSync(OUT, { recursive: true }); + }); + after(async () => { + await browser?.close(); + await srv?.close(); + }); + + test('every attack draws a strike to each of the five enemies on her range, and each shows its damage', async () => { + const { meta, frames, opId, enemyIds } = recordBattle(); + const attacks = frames.filter((f) => f.ev.some((e) => e[0] === 'atk' && e[1] === opId)); + assert.ok(attacks.length >= 3, 'she attacked'); + for (const f of attacks) assert.equal(new Set(f.ev.filter((e) => e[0] === 'atk' && e[1] === opId).map((e) => e[2])).size, 5, 'one attack, five targets'); + const page = await browser.newPage(); + const problems = []; + page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); }); + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); }); + try { + await page.setViewport({ width: 1280, height: 720 }); + await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`); + await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 }); + await page.evaluate((meta) => { + const v = window.__demo.view; + v.enterBattle(meta); + v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true }); + v.setLocalFeed({ on: true, speed: 2 }); + }, meta); + let maxBeams = 0, shot = false; + const numbered = new Set(); + const CHUNK = 30; + for (let i = 0; i < frames.length; i += CHUNK) { + const got = await page.evaluate(async (chunk, opId) => { + const v = window.__demo.view, fx = v.debug.fx, out = []; + for (const f of chunk) { + if (f.ev.length) v.pushEvents({ t: 'b.ev', fieldId: f.snap.fieldId, gt: f.gt, ev: f.ev }); + v.pushSnapshot(f.snap); + await new Promise((r) => requestAnimationFrame(() => r())); + const me = v.debug.views.get(opId); + const beams = new Set(fx.beamList.filter((b) => b.a === me && b.b).map((b) => b.b.id)); + out.push({ beams: beams.size, nums: [...new Set(fx.nums.filter((n) => !n.fading && n.unit).map((n) => n.unit.id))] }); + } + return out; + }, frames.slice(i, i + CHUNK), opId); + for (const g of got) { + maxBeams = Math.max(maxBeams, g.beams); + for (const id of g.nums) numbered.add(id); + if (g.beams === 5 && !shot) { shot = true; await page.screenshot({ path: path.join(OUT, 'phalanx-aoe.png') }); } + } + } + assert.equal(maxBeams, 5, 'one frame shows her strike on all five enemies'); + for (const id of enemyIds) assert.ok(numbered.has(id), `enemy ${id} shows a damage number`); + assert.deepEqual(problems, []); + } finally { + await page.close(); + } + }); +}); diff --git a/test/sim/feedback1e-aoe.test.js b/test/sim/feedback1e-aoe.test.js new file mode 100644 index 00000000..4c9e446f --- /dev/null +++ b/test/sim/feedback1e-aoe.test.js @@ -0,0 +1,182 @@ +// Community report E3 after the 0.1.0 release: "干员卡涅利安的攻击不是真群攻". The 阵法术师 trait "技能开启时攻击造成群体 +// 法术伤害" is an attack on EVERY enemy inside the attack range (Arknights Terra Wiki, Phalanx Caster: "attacks hit all +// enemies within their range … equal damage to all enemies in range, regardless of distance"; PRTS 作战机制 §AOE伤害判定 +// "对攻击范围内的每个可以被选中的敌人进行判定"; PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill +// several), not one target plus a 1.1-tile splash. The same holds for the 轰击术师 line ("超远距离的群体法术伤害": every +// enemy on the line — Terra Wiki Blast Caster; PRTS 作战机制: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE), while the 扩散术师 +// "群体法术伤害" stays a splash of 1.1 tiles around the struck target (Terra Wiki Splash Caster). Real battles with the +// real chess (every selectable attacking skill, normal + elite), counting the enemies each attack damages. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js'; +import { effectiveProfile } from '../../server/sim/ai.js'; +import { getData } from '../../server/data.js'; + +const ds = getDefaultSource(); +const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e8, speed: 0, ...o }); +const keyOf = (e) => Math.round(e.y) * 21 + Math.round(e.x); + +/** attackId → Map(target → damage) of the normal attacks `u` landed. */ +function perAttack(h, u) { + const out = new Map(); + for (const c of h.hooksOf('damaged')) { + if (c.source !== u || !c.dmg?.isAttack) continue; + const k = c.dmg.attackId; + if (!out.has(k)) out.set(k, new Map()); + const m = out.get(k); + m.set(c.target, (m.get(c.target) ?? 0) + c.amount); + } + return out; +} + +/** One phalanx / blast caster with its skill on and `pos` enemies around it: every attack hits all of them alike. */ +function hitsAll(id, skillIndex, pos, { equal = true, row = 10, col = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: dummy() } }, + units: [{ chessId: id, row, col, ...(skillIndex != null ? { skillIndex } : {}) }], + timeLimit: 120, hooks: ['damaged'], captureNoisy: true, + }); + h.step(); + const u = h.unit(id); + const es = pos.map((p) => h.spawn('enemy_dummy', { pos: p })); + if (u.profile.noAttackUnlessSkill) { + u.skill.gainSp(u.skill.spCost * Math.max(1, u.skill.maxCharges || 1)); + assert.ok(h.runUntil(() => u.skill.active, 10), `${id}/${skillIndex}: skill on`); + } + for (const e of es) assert.ok(u.rangeKeys.includes(keyOf(e)), `${id}/${skillIndex}: enemy at ${e.y},${e.x} inside the range`); + h.run(5); + const atks = [...perAttack(h, u).values()]; + assert.ok(atks.length >= 2, `${id}/${skillIndex}: attacked (${atks.length})`); + for (const m of atks) { + assert.equal(m.size, es.length, `${id}/${skillIndex}: one attack damages every enemy in range (${m.size}/${es.length})`); + if (!equal) continue; + const v = [...m.values()]; + const lo = Math.min(...v), hi = Math.max(...v); + assert.ok(hi - lo <= 1e-6 * hi, `${id}/${skillIndex}: the same damage near and far (${lo} … ${hi})`); + } + checkInvariants(h.b); + return { h, u }; +} + +// pairwise ≥ 1.41 tiles apart — no 1.1-tile splash around one of them reaches another; all on her x-1 range (facing RIGHT) +const SPREAD = [[10, 7], [10, 3], [12, 5], [9, 5], [11, 6]]; + +test('E3 卡涅利安: every attack of each skill hits every enemy in her range, with equal damage (normal + elite)', () => { + for (const id of ['chess_char_4_24_a', 'chess_char_4_24_b']) { + hitsAll(id, 0, SPREAD); // S1 沙暴守卫 + hitsAll(id, 1, SPREAD); // S2 沙缚镣锁 (default) + // S3 食噬之印 widens the range to x-2: an enemy on the wider range only is hit by the same attacks + hitsAll(id, 2, [...SPREAD, [12, 6], [11, 3]]); + } +}); + +test('E3 卡涅利安 S2: the 停顿 (charged: 束缚) lands on every enemy each attack hits', () => { + const id = 'chess_char_4_24_a', bb = ds.getChess(id).skill.bb; + const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5 }], timeLimit: 60, hooks: ['statusApplied', 'damaged'], captureNoisy: true }); + h.step(); + const u = h.unit(id); + const es = SPREAD.map((p) => h.spawn('enemy_dummy', { pos: p })); + u.skill.gainSp(u.skill.spCost); // one charge: an uncharged cast + assert.ok(h.runUntil(() => u.skill.active, 5)); + h.run(2); + for (const e of es) assert.ok(h.hooksOf('statusApplied').some((c) => c.target === e && c.source === u && c.status === 'sluggish' && c.duration === bb['attack@sluggish']), `enemy at ${e.y},${e.x} slowed`); +}); + +test('E3 audit: every 阵法术师 of the pool (薄绿, 蜜蜡, 圣聆初雪) hits every enemy in range with each attack', () => { + for (const id of ['chess_char_3_08_a', 'chess_char_3_08_b']) { + hitsAll(id, 0, [...SPREAD, [12, 6]]); // S1 风语: x-2 + hitsAll(id, 1, SPREAD); // S2 聚能涡旋 (default): each struck enemy is pushed towards her, still in range + } + for (const id of ['chess_char_4_05_a', 'chess_char_4_05_b']) hitsAll(id, null, SPREAD); // S2 守卫尖碑 (hidden chess) + for (const id of ['chess_char_6_02_a', 'chess_char_6_02_b']) { + hitsAll(id, 1, SPREAD); // S2 霜涛覆岭 (toggle) + hitsAll(id, 2, [...SPREAD, [12, 6]]); // S3 群山俯首 (default, x-2) + } +}); + +test('E3 audit: the 轰击术师 (阿罗玛, 协律) hit every enemy on their line, not one target plus a splash', () => { + const LINE = [[10, 3], [10, 5], [10, 7]]; // 2 tiles apart on the 5-1 line of an operator at (10, 2) + hitsAll('chess_char_4_10_a', null, LINE, { col: 2 }); + hitsAll('chess_char_4_10_b', null, LINE, { col: 2, equal: false }); // BLA-X: farther targets take more + hitsAll('chess_char_4_10_a', 0, LINE, { col: 2 }); // S1 强效清洁 (charges) + hitsAll('chess_char_2_15_a', null, LINE, { col: 2 }); + hitsAll('chess_char_2_15_b', null, LINE, { col: 2, equal: false }); +}); + +test('E3 audit: a 扩散术师 still splashes 1.1 tiles around its target — not every enemy in range', () => { + for (const id of ['chess_char_1_14_a', 'chess_char_4_02_a']) { + const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true }); + h.step(); + const u = h.unit(id); + // A and B 1 tile apart (one splash), C 1.41 / 2.24 tiles from them — all three on the 3 × 3 range ahead + const [A, B, C] = [[10, 4], [10, 5], [11, 3]].map((p) => h.spawn('enemy_dummy', { pos: p })); + for (const e of [A, B, C]) assert.ok(u.rangeKeys.includes(keyOf(e)), `${id}: ${e.y},${e.x} in range`); + h.run(8); + const atks = [...perAttack(h, u).values()]; + assert.ok(atks.length >= 2, id); + for (const m of atks) { + const hit = new Set(m.keys()); + assert.ok(hit.size < 3, `${id}: never every enemy in range`); + if (hit.has(A) || hit.has(B)) assert.ok(hit.has(A) && hit.has(B) && !hit.has(C), `${id}: A and B splash each other, C is out of the splash`); + } + } +}); + +test('E3 real stage + real wave: 卡涅利安 targets every enemy on her range with each attack (act2 m01, round 10)', () => { + const data = getData({ log: { warn() {}, error() {}, info() {} } }); + const mode = data.config.modes.mode_multi_normal; + const round = '10'; + const rc = mode.rounds[round]; + const tpl = ds.getWave(rc.template); + const sc = mode.enemyScale?.[round] ?? {}; + const mods = { hpMul: (sc.hp ?? 1) ** (sc.kHp ?? 0), atkMul: (sc.atk ?? 1) ** (sc.kAtk ?? 0), speedMul: sc.speed ?? 1 }; + assert.ok(spawnsFromTemplate(tpl, { mods }).spawns.length > 0); + for (const skillIndex of [1, 2]) { + const id = 'chess_char_4_24_b'; + const h = makeBattle({ + stageId: 'act2autochess_m01', waveTemplate: tpl, mods, timeLimit: rc.combatTimeLimit, seed: 11, flags: { startOpCooldown: 3 }, + units: [ + { chessId: id, row: 10, col: 6, skillIndex }, + { chessId: 'chess_char_1_02_a', row: 10, col: 7 }, { chessId: 'chess_char_1_02_a', row: 11, col: 7 }, + { chessId: 'chess_char_1_01_a', row: 9, col: 7 }, { chessId: 'chess_char_2_14_a', row: 12, col: 6 }, + ], + hooks: [], + }); + const u = h.unit(id); + const rows = []; + // (beforeAttack, after every content hook: the targets of the attack and the enemies on her range at that moment) + h.b.on('beforeAttack', (c) => { + if (c.attacker !== u) return; + const p = effectiveProfile(u); + const want = new Set(h.b.enemiesInKeys(u.rangeKeys, u, p)); + for (const e of h.b.blockedTargets(u, p)) want.add(e); + rows.push({ got: c.targets.length, want: want.size, all: [...want].every((e) => c.targets.includes(e)) }); + }, { priority: -2000 }); + h.runToEnd(rc.combatTimeLimit + 5); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); + const crowded = rows.filter((r) => r.want >= 3); + assert.ok(crowded.length >= 1, `S${skillIndex + 1}: attacks into a crowd (${crowded.length}/${rows.length})`); + for (const r of rows) assert.ok(r.all && r.got === r.want, `S${skillIndex + 1}: ${r.got} targets of ${r.want} enemies on her range`); + } +}); + +test('E3 卡涅利安 S3 食噬之印 values: ATK climbs from +0 % in 1 s steps to the full bonus at 20 s (PRTS 备注), normal + elite', () => { + for (const id of ['chess_char_4_24_a', 'chess_char_4_24_b']) { + const bb = ds.getChess(id, { skillIndex: 2 }).skill.bb; + const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5, skillIndex: 2 }], timeLimit: 60, hooks: [] }); + h.step(); + const u = h.unit(id); + u.skill.addCharge(1); + assert.ok(u.skill.activate('test')); + const t0 = h.b.time; + const bonus = () => u.s.atk / u.base.atk - 1; + // PRTS 卡涅利安 S3 备注 "攻击力从+0%开始在20秒内线性增加,攻击力每1秒更新1次,在第20秒达到最大值" + for (const [at, steps] of [[0.5, 0], [1.5, 1], [10.5, 10], [19.5, 19], [20.5, 20]]) { + h.run(t0 + at - h.b.time); + assert.ok(u.skill.active, `${id}: still on at ${at} s`); + const want = bb.atk * steps / 20; + assert.ok(Math.abs(bonus() - want) < 1e-9, `${id}: +${(bonus() * 100).toFixed(2)} % at ${at} s, want +${(want * 100).toFixed(2)} %`); + } + } +}); From f3a73fec6bb75efb7418dd81a144e5d7c3df58bf Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 05:38:06 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feedback1e=20WR:=20=E5=8D=A1=E6=B6=85?= =?UTF-8?q?=E5=88=A9=E5=AE=89=20charged=20S1=20never=20attacks,=20S3=20mar?= =?UTF-8?q?k=20before=20the=20damage;=20review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the E3 fix (阵法术师 / 轰击术师 strike every enemy in range): - 卡涅利安 S1 沙暴守卫 charged: PRTS 备注 "应用蓄力时:应用技能未开启时的特性" keeps the whole skill-off trait in force, 不攻击 included. A kit trait canAttack now stops her attacks while a charged S1 runs; the guard stays. The E3 test casts S1 uncharged and a new test pins both casts. - 卡涅利安 S3 食噬之印 charged: the mark stacks before the attack's damage (PRTS 备注 "于攻击造成伤害前生效"): x1.2 on the first hit, x2.0 from the 5th (was one attack late). One mark per enemy: another 卡涅利安 only adds stacks, the bonus stays the setter's (PRTS 备注 popup). - rangeAoe is applied after every override (kit trait included) and sets allInRange itself; SUB.phalanx / blastcaster carry rangeAoe only. - 流形 copies (tokens.js and the 缪尔赛思 kit) turn a copied 'beam' into a plain bolt: the copy takes no attack shape. - fx.js: the dead blastcaster shell colour is gone (and its test). - SIM.md / comments cite PRTS 溅射半径一览 (no splash row for these branches), note that stealthed enemies are not struck unless revealed or blocked, and list the table's other radii as open deviations. --- docs/SIM.md | 14 ++-- public/js/render/fx.js | 7 +- server/sim/content/kits/tier4.js | 56 +++++++++---- server/sim/content/kits/tier6.js | 3 +- server/sim/content/tokens.js | 4 +- server/sim/professions.js | 27 +++--- test/content/kits_alt_t4.test.js | 6 +- test/render/fxproj.test.js | 8 -- test/sim/feedback1e-aoe.test.js | 139 ++++++++++++++++++++++++++++++- 9 files changed, 207 insertions(+), 57 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index ae10f8c7..1960727a 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -890,24 +890,24 @@ PRTS 选择器, is not in the data): "地面敌人" / "不可对空" ⇒ `!e.isF without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`, `afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays -`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (likewise `'beam'`, with `allInRange`) -(see the header of professions.js). +`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (applied after every override: sets +`allInRange` and, on a ranged profile, the instant `'beam'`) (see the header of professions.js). | sub | behaviour | |---|---| | 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 (Arknights Terra Wiki, Splash Caster: "a radius of 1.1 tiles centered on the target") | -| blastcaster | `rangeAoe`: every enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (Terra Wiki, Blast Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE; community report E3) | -| bombarder | ground-only splash 1.0 + aftershock(s) at 50 % ATK (bb append_atk_scale / times) | +| 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 | +| 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 (PRTS 溅射半径一览 lists no splash for it; Terra Wiki, Blast Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE; 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 | | 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 | +| 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 | | 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 enemy on its range at once (blocked enemies included), the same damage near and far, instant (`'beam'`) — "群体法术伤害" (Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; PRTS 林 S3 备注 "单次普攻最多触发1次效果"; community report E3: it used to be one bolt + a 1.1 splash) | +| 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'`) — "群体法术伤害" (PRTS 溅射半径一览 lists no splash for it; Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; PRTS 林 S3 备注 "单次普攻最多触发1次效果"; 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 备注) | | physician | heal the lowest HP% injured ally in range (a skill `targeting.maxTargets` widens any heal profile) | | ringhealer | heal 3 allies | | chainhealer | heal bounces 3× (−25 %, bb chain.*) within 2.5 tiles | diff --git a/public/js/render/fx.js b/public/js/render/fx.js index 000e3321..6f1efbbd 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -111,8 +111,6 @@ const FLAME_TINTS = Object.freeze([0xffd27a, 0xffa94d, 0xff8a3d, 0xff5a2a]); const SHELL_RISE = 0.34, SHELL_UP = 5.5; /** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */ const BOMBARD_SHELL = Object.freeze({ look: 'mortar', tint: 0xfff2d8, glow: 0xff9c33, trail: 0xffb35c, smoke: 0x3a3430, len: 1.3, width: 0.3, head: 0.56 }); -/** Sub-professions whose shells are arts (purple blast) — the rest explode orange. */ -const ARTS_SHELLS = new Set(['blastcaster']); /** * An fx anchored on a unit (extra.id) is drawn at that unit's rendered position while the event's own (x, y) is within * this many tiles of it (it happens on the unit); farther away the fx happens at (x, y) — the sim puts the caster in @@ -482,15 +480,14 @@ export class FxSystem { const ux = dist > 1e-6 ? dx / dist : (src.facing || 1) >= 0 ? 1 : -1, uy = dist > 1e-6 ? dy / dist : 0; const hand = Math.min(0.28, dist * 0.3); // the weapon is in front of the body const look = spec.look; - const arts = look === 'shell' && ARTS_SHELLS.has(this.ctx.subProfOf ? this.ctx.subProfOf(src.info?.defId) : null); pr.kind = kind; pr.spec = spec; pr.src = src; pr.tgt = tgt; pr.rise = 0; pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = chestZ(src); pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : chestZ(tgt); pr.t = 0; pr.fade = 0; pr.hit = false; pr.emit = Math.random(); pr.ang = Math.atan2(-uy, ux); // ≈ on screen (rows run up) pr.dur = clamp(dist / projSpeed(kind) / this._ts(), 0.04, 1.5); pr.arc = spec.arc ? spec.arc * clamp(0.45 + dist * 0.18, 0.6, 1.8) : 0; - pr.glow = arts ? 0xb36bff : spec.glow; - pr.trailTint = arts ? 0xc77dff : spec.trail ?? spec.glow; + pr.glow = spec.glow; + pr.trailTint = spec.trail ?? spec.glow; // boomerang legs: kinematic, from (bx, by, bz) at constant speed towards the target, then back to the thrower pr.phase = 0; pr.bx = pr.x0; pr.by = pr.y0; pr.bz = pr.z0; pr.trav = 0; pr.d0 = Math.max(0.1, dist); pr.ux = ux; pr.uy = uy; pr.spin = Math.random() * 6; diff --git a/server/sim/content/kits/tier4.js b/server/sim/content/kits/tier4.js index 73be93ba..e2092931 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -16,8 +16,9 @@ // groupId `abyssal` (data/chess.json has no groupId: charIds from docs/research/03-operators.json). // - "友方干员" effects touch operators only (summons/devices excluded); SP gifts skip units whose timed skill runs // (AK: no SP gain during a skill — the engine's gainSp enforces it too; the kits skip such units when picking). -// - On-hit statuses of 卡涅利安's attacks apply to every enemy the attack damages — the 阵法术师 trait strikes every -// enemy on her range at once (professions.js `allInRange`, community report E3) — via a `damaged` hook. +// - On-hit effects of 卡涅利安's attacks land on every enemy the attack strikes — the 阵法术师 trait strikes every enemy +// on her range at once (professions.js `allInRange` / `rangeAoe`, community report E3): S2's 停顿 / 束缚 via a +// `damaged` hook, the charged S3 mark via a `hit` hook (it applies before the damage, PRTS 备注). // - Dodge from a skill that adds to other dodge sources (焰尾 S3) is rolled independently in a `hit` hook, so the // sources combine as 1 − Π(1 − p) (the same rule the engine now applies to stacked dodge mods). // - Operator loadouts (DESIGN §16): every selectable non-default skill of the 22 visible chess is authored in the kit's @@ -39,6 +40,9 @@ const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', ' const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i]))); /** 卡涅利安 S3 食噬之印: the ATK bonus climbs in 1 s steps over 20 s (PRTS 备注; the blackboard has no ramp time). */ 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'; +const BILLRO_MARK_MAX = 5; // --------------------------------------------------------------------------------------------------------------- // helpers @@ -1716,10 +1720,15 @@ const kits = { // ===== 卡涅利安 (phalanx) S2 沙缚镣锁 — faster attacks on every enemy in range (the trait's 群体法术伤害), 0.3 s // sluggish on each; charged: ATK +10 % and bind; talents - // S1 沙暴守卫 (SEARCH: ATK/DEF up; charged — cast with every charge stored — the trait's DEF/RES guard stays on); - // S3 食噬之印 (wider range, ATK +0 % → +140 %/+200 % in 1 s steps over 20 s — PRTS 备注; charged: every hit marks - // its target, +20 % damage from her per mark, ≤ 5, until the skill ends). Talent 生命之餐 heals on every skill; - // module PLX-X keeps part of the guard during any skill, PLX-Y (乡音): +3 % damage per enemy in range (≤ 5) + // S1 沙暴守卫 (SEARCH: ATK/DEF up; charged — cast with every charge stored — "特性效果在技能期间继续生效", PRTS 备注 + // "应用蓄力时:应用技能未开启时的特性": the skill-off trait stays in force, the DEF/RES guard AND 不攻击 — she makes + // no attack until the skill ends, a pure guard); + // S3 食噬之印 (wider range, ATK +0 % → +140 %/+200 % in 1 s steps over 20 s — PRTS 备注; charged: each attack adds a + // stack of the mark BEFORE its damage — PRTS 备注 "于攻击造成伤害前生效,多层效果之间加算叠加" — +20 % damage from + // her per stack, ≤ 5, so ×1.2 on the first hit and ×2.0 from the 5th, until the skill ends; one mark per enemy: + // another 卡涅利安 only adds stacks to it, the bonus is the setter's — PRTS 备注 popup). Talent 生命之餐 heals on + // every skill; module PLX-X keeps part of the guard during any skill, PLX-Y (乡音): +3 % damage per enemy in range + // (≤ 5) chess_char_4_24_a: (bb, chess, def) => { const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def); const tb = def.traitBb || {}; @@ -1727,15 +1736,17 @@ const kits = { const S1 = isSel(def, 'skchr_billro_1'), S2 = isSel(def, 'skchr_billro_2'), S3 = isSel(def, 'skchr_billro_3'); const g = grid(def.skill?.rangeGrid); const isCharged = (skill) => skill.maxCharges > 1 && skill.charges + 1 >= skill.maxCharges; // cast with every charge stored - const markKey = (unit) => `billro:mark:${unit.id}`; return { + // charged S1: the skill-off trait's 不攻击 stays in force while the skill lasts (PRTS S1 备注) — no attack at all + trait: S1 ? { canAttack: (battle, u) => !(skillActive(u) && u.mem.billroCharged) } : null, skills: alt(def, { skchr_billro_1: () => ({ kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, onStart({ battle, unit, skill }) { unit.mem.billroCharged = isCharged(skill); - // 蓄力额外效果:特性效果在技能期间继续生效 (the profession removes its own guard buff when a skill starts) + // 蓄力额外效果:特性效果在技能期间继续生效 (the profession removes its own guard buff when a skill starts; the + // kit trait's canAttack keeps the trait's 不攻击) if (unit.mem.billroCharged) battle.addBuff(unit, { key: 'billro:s1guard', mods: { defPct: num(unit.profile?.guardDef, 2), resFlat: num(unit.profile?.guardRes, 20) } }); battle.fx('shell', { x: unit.x, y: unit.y, id: unit.id }); }, @@ -1759,7 +1770,11 @@ const kits = { }, onEnd({ battle, unit }) { battle.removeBuff(unit, 'billro:s3atk'); - for (const id of unit.mem.billroMarked || []) { const e = battle.unitById(id); if (e) battle.removeBuff(e, markKey(unit)); } + // "持续至技能结束": the marks she set end with her skill (stacks another 卡涅利安 added to them included) + for (const id of unit.mem.billroMarked || []) { + const e = battle.unitById(id); + if (e && e.findBuff(BILLRO_MARK)?.source === unit) battle.removeBuff(e, BILLRO_MARK); + } unit.mem.billroMarked = null; unit.mem.billroCharged = false; }, @@ -1807,17 +1822,22 @@ const kits = { }, { owner: unit }); } if (S3) { - // charged S3: each hit adds a mark (≤ 5) — +20 % damage taken from her per mark until the skill ends + // charged S3 食噬之印: each attack adds a stack BEFORE its damage (PRTS 备注 "于攻击造成伤害前生效"); stacks add up, + // +20 % damage taken from the mark's setter per stack. One mark per enemy (PRTS 备注 popup: "存在则为已有的BUFF叠加 + // 一次加成,不存在则给与敌人仅对此卡涅利安的伤害生效的一个食噬之印BUFF"): another 卡涅利安 stacks it without the bonus + const per = num(bb['attack@damage_scale'], 0.2); battle.on('hit', (c) => { - if (c.source !== unit) return; - const m = c.target.findBuff?.(markKey(unit)); - if (m) c.dmg.mul *= 1 + num(bb['attack@damage_scale'], 0.2) * (m.stacks || 1); - }, { owner: unit }); - battle.on('damaged', (c) => { const e = c.target; - if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !skillActive(unit) || !unit.mem.billroCharged) return; - battle.addBuff(e, { key: markKey(unit), refresh: 'stack', maxStacks: 5, duration: Math.max(0.1, (unit.skill?.timeLeft ?? 0) + 0.1), source: unit, visible: true }); - unit.mem.billroMarked?.add(e.id); + if (c.source !== unit || e.side !== 'enemy') return; + let m = e.findBuff?.(BILLRO_MARK) || null; + if (c.dmg.isAttack && skillActive(unit) && unit.mem.billroCharged) { + if (m) battle.addBuff(e, { key: BILLRO_MARK, refresh: 'stack', maxStacks: BILLRO_MARK_MAX, duration: m.timeLeft }); + else { + m = battle.addBuff(e, { key: BILLRO_MARK, refresh: 'stack', maxStacks: BILLRO_MARK_MAX, duration: Math.max(0.1, (unit.skill?.timeLeft ?? 0) + 0.1), source: unit, visible: true }); + if (m) unit.mem.billroMarked?.add(e.id); + } + } + if (m && m.source === unit) c.dmg.mul *= 1 + per * (m.stacks || 1); }, { owner: unit }); } if (!S2) return; diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 95650c71..2b26629d 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -1749,7 +1749,8 @@ function copyInto(battle, t, src, scale, ranged) { const p = t.profile; if (p) { p.attack = ranged ? 'ranged' : 'melee'; - p.projectile = ranged ? (sp.projectile && sp.projectile !== 'none' && sp.projectile !== 'orb' ? sp.projectile : 'bolt') : 'none'; + // (a 阵法术师 / 轰击术师's instant 'beam' is their every-enemy-in-range shape: the copy fires a plain bolt) + p.projectile = ranged ? (sp.projectile && sp.projectile !== 'none' && sp.projectile !== 'orb' && sp.projectile !== 'beam' ? sp.projectile : 'bolt') : 'none'; p.canHitFly = ranged ? true : !!sp.canHitFly; if (!(sp.dmgType === 'heal' || sp.dmgType === 'none' || sp.noAttack || sp.noAttackUnlessSkill)) p.dmgType = sp.dmgType; p.heal = null; diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 7dd8b2d6..a49aa8a3 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -728,7 +728,9 @@ function applyManifoldCopy(battle, unit, target, scale) { const p = unit.profile; if (p) { p.attack = ranged ? 'ranged' : 'melee'; - p.projectile = ranged ? (tp.projectile && tp.projectile !== 'none' && tp.projectile !== 'orb' ? tp.projectile : 'bolt') : 'none'; + // the talent's list of copied attributes (生命上限 … 伤害类型) names no attack shape: a 阵法术师 / 轰击术师's instant + // 'beam' (rangeAoe: every enemy in range) becomes a plain single-target bolt, like a healer's orb + p.projectile = ranged ? (tp.projectile && tp.projectile !== 'none' && tp.projectile !== 'orb' && tp.projectile !== 'beam' ? tp.projectile : 'bolt') : 'none'; p.canHitFly = ranged ? true : !!tp.canHitFly; // 初始伤害类型(不攻击、治疗类型则不继承) if (!(tp.dmgType === 'heal' || tp.dmgType === 'none' || tp.noAttack || tp.noAttackUnlessSkill)) p.dmgType = tp.dmgType; diff --git a/server/sim/professions.js b/server/sim/professions.js index 05223670..9c9659be 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -10,7 +10,9 @@ // boomerang bool (回环射手: keeps 'boomerang') // canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy) // allInRange bool (every enemy on the range at once) splashRadius tiles (around the struck target) -// rangeAoe bool (a 锁定攻击范围 AoE without a projectile: allInRange, instant 'beam' hits) +// rangeAoe bool (a 锁定攻击范围 AoE without a projectile — SUB table or kit trait, applied by resolveProfile after +// every override: allInRange + instant 'beam' hits on a ranged profile; only selectable enemies are +// struck — a stealthed one is not, unless revealed or blocked; PRTS 作战机制 §AOE伤害判定) // splashScale (× damage for splash victims) // splashOthersOnly bool groundOnly bool hits n (damage instances per attack) // chain {count, falloff, radius, sluggish} heal {mode:'single'|'multi'|'chain', count, falloff, farMul, elementHealRatio} @@ -300,12 +302,12 @@ export const SUB = Object.freeze({ return front ? (unit.profile.frontScale ?? 1.5) : 1; } }), // --- CASTER - // "群体法术伤害" names two shapes: the 扩散术师 splash 1.1 tiles around the struck target (Arknights Terra Wiki, Splash - // Caster), while the 轰击术师 ("超远距离的群体法术伤害") and the 阵法术师 strike every enemy inside the attack range at - // once, the same damage near and far (Terra Wiki, Blast / Phalanx Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a - // 锁定攻击范围 AoE) — community report E3 after 0.1.0 + // "群体法术伤害" names two shapes: the 扩散术师 splash 1.1 tiles around the struck target (PRTS 溅射半径一览 — which lists + // no splash for the 阵法术师 / 轰击术师 — and Arknights Terra Wiki, Splash Caster), while the 轰击术师 ("超远距离的群体法术 + // 伤害") and the 阵法术师 strike every enemy inside the attack range at once, the same damage near and far (Terra Wiki, + // Blast / Phalanx Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE) — community report E3 after 0.1.0 splashcaster: P({ splashRadius: 1.1 }), - blastcaster: P({ allInRange: true, rangeAoe: true }), + blastcaster: P({ rangeAoe: true }), chain: P({ chain: { count: 3, falloff: 0.15, radius: 1.8, sluggish: 0.5 } }), funnel: P({ projectile: 'drone', install: installFunnel, dmgMul: (battle, unit, target) => { @@ -316,7 +318,7 @@ export const SUB = Object.freeze({ } }), mystic: P({ install: installMystic, hitsFn: (battle, unit) => { const n = 1 + (unit.trait.stored ?? 0); unit.trait.stored = 0; return n; } }), - phalanx: P({ noAttackUnlessSkill: true, allInRange: true, rangeAoe: true, install: installPhalanx }), + phalanx: P({ noAttackUnlessSkill: true, rangeAoe: true, install: installPhalanx }), primcaster: P({}), corecaster: P({}), // --- MEDIC @@ -483,10 +485,6 @@ export function resolveProfile(def, kitTrait = null) { // a boomerang thrower (回环射手) always throws its boomerang: the data's generic ranged projectile ('arrow', a // build-data default) would turn the out-and-back flight into a plain shot if (p.boomerang && p.attack === 'ranged') p.projectile = 'boomerang'; - // a 锁定攻击范围 AoE (阵法术师, 轰击术师) has no projectile: every enemy on the range is struck at the same moment - // ('beam' = instant hits, drawn as a line to each victim — PRTS 作战机制 "在攻击前摇结束时选取范围内的全体目标,同时 - // 造成伤害"); the data's generic ranged projectile ('bolt') would land them one by one - if (p.rangeAoe && p.attack === 'ranged') p.projectile = 'beam'; if (def.type === 'token') { if (p.dmgType === 'heal') p.heal = p.heal || { mode: 'single' }; if (def.stats.atk <= 0) p.noAttack = true; @@ -495,6 +493,13 @@ export function resolveProfile(def, kitTrait = null) { if (p.dmgType !== 'heal' && p.heal) p.heal = null; if (p.dmgType === 'none') p.noAttack = true; if (kitTrait) Object.assign(p, kitTrait); + // a 锁定攻击范围 AoE (阵法术师, 轰击术师) strikes every enemy on its range and has no projectile: they are struck at the + // same moment ('beam' = instant hits, drawn as a line to each victim — PRTS 作战机制 "在攻击前摇结束时选取范围内的全体 + // 目标,同时造成伤害"); the data's generic ranged projectile ('bolt') would land them one by one + if (p.rangeAoe) { + p.allInRange = true; + if (p.attack === 'ranged') p.projectile = 'beam'; + } return p; } diff --git a/test/content/kits_alt_t4.test.js b/test/content/kits_alt_t4.test.js index 63723dc9..98a5bac9 100644 --- a/test/content/kits_alt_t4.test.js +++ b/test/content/kits_alt_t4.test.js @@ -1053,13 +1053,13 @@ test('卡涅利安 S3 食噬之印: wider range, ATK ramps to +atk over the skil assert.ok(u.rangeKeys.length > n0); h.run(sk.duration / 2); approx(u.findBuff('billro:s3atk').mods.atkPct, bb.atk * 0.5, 0.02, `${id}: half-way ramp`); - const m = e.findBuff(`billro:mark:${u.id}`); - assert.ok(m && m.stacks === 5, `${id}: marks capped at 5 (${m?.stacks})`); + const m = e.findBuff('billro:mark'); + assert.ok(m && m.stacks === 5 && m.source === u, `${id}: marks capped at 5 (${m?.stacks})`); const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.target === e); const last = hits[hits.length - 1]; approx(last.amount / (u.s.atk), 1 + bb['attack@damage_scale'] * 5, 0.05, '+100 % with 5 marks'); h.runUntil(() => !u.skill.active, sk.duration); - assert.ok(!e.findBuff(`billro:mark:${u.id}`), 'marks end with the skill'); + assert.ok(!e.findBuff('billro:mark'), 'marks end with the skill'); assert.ok(!u.findBuff('billro:s3atk')); } }); diff --git a/test/render/fxproj.test.js b/test/render/fxproj.test.js index 84dee306..ef58e85c 100644 --- a/test/render/fxproj.test.js +++ b/test/render/fxproj.test.js @@ -199,14 +199,6 @@ describe('shots', () => { } }); - test("an arts blastcaster's bomb explodes purple", () => { - const a = unit(1, 3, 10, { info: { defId: 'char_blast' } }), b = unit(2, 6, 10, { isEnemy: true }); - const { fx } = makeFx({ views: [a, b] }); - fx.ctx.subProfOf = (id) => (id === 'char_blast' ? 'blastcaster' : null); - fx.attack(a, b, 'bomb'); - assert.equal(fx.projs[0].glow, 0xb36bff); - }); - test('chain / beam kinds are beams, melee shots are no projectile', () => { const a = unit(1, 3, 10), b = unit(2, 5, 10, { isEnemy: true }); const { fx } = makeFx({ views: [a, b] }); diff --git a/test/sim/feedback1e-aoe.test.js b/test/sim/feedback1e-aoe.test.js index 4c9e446f..b678e5e7 100644 --- a/test/sim/feedback1e-aoe.test.js +++ b/test/sim/feedback1e-aoe.test.js @@ -4,13 +4,18 @@ // "对攻击范围内的每个可以被选中的敌人进行判定"; PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill // several), not one target plus a 1.1-tile splash. The same holds for the 轰击术师 line ("超远距离的群体法术伤害": every // enemy on the line — Terra Wiki Blast Caster; PRTS 作战机制: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE), while the 扩散术师 -// "群体法术伤害" stays a splash of 1.1 tiles around the struck target (Terra Wiki Splash Caster). Real battles with the -// real chess (every selectable attacking skill, normal + elite), counting the enemies each attack damages. +// "群体法术伤害" stays a splash of 1.1 tiles around the struck target (PRTS 溅射半径一览 — no row for the 阵法术师 / +// 轰击术师 — and Terra Wiki Splash Caster). Real battles with the real chess (every selectable attacking skill, normal + +// elite), counting the enemies each attack damages. Her kit per PRTS 卡涅利安 备注: a charged S1 keeps the skill-off trait +// (不攻击 + the guard), the charged S3 mark stacks before the damage and is one buff per enemy (the setter's bonus); a +// 流形 copy takes no attack shape ('beam' → bolt); `rangeAoe` from any source means every enemy in range, instant. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; import { getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js'; +import { TOKEN_IDS } from '../../server/sim/content/tokens.js'; import { effectiveProfile } from '../../server/sim/ai.js'; +import { resolveProfile } from '../../server/sim/professions.js'; import { getData } from '../../server/data.js'; const ds = getDefaultSource(); @@ -41,7 +46,7 @@ function hitsAll(id, skillIndex, pos, { equal = true, row = 10, col = 5 } = {}) const u = h.unit(id); const es = pos.map((p) => h.spawn('enemy_dummy', { pos: p })); if (u.profile.noAttackUnlessSkill) { - u.skill.gainSp(u.skill.spCost * Math.max(1, u.skill.maxCharges || 1)); + u.skill.gainSp(u.skill.spCost); // one charge: an uncharged cast (a charged 卡涅利安 S1 keeps the trait's 不攻击) assert.ok(h.runUntil(() => u.skill.active, 10), `${id}/${skillIndex}: skill on`); } for (const e of es) assert.ok(u.rangeKeys.includes(keyOf(e)), `${id}/${skillIndex}: enemy at ${e.y},${e.x} inside the range`); @@ -180,3 +185,131 @@ test('E3 卡涅利安 S3 食噬之印 values: ATK climbs from +0 % in 1 s steps } } }); + +test('E3 卡涅利安 S1 沙暴守卫 charged: the skill-off trait stays in force — the DEF/RES guard AND 不攻击 (PRTS 备注 "应用蓄力时:应用技能未开启时的特性"); uncharged she strikes every enemy in range', () => { + for (const id of ['chess_char_4_24_a', 'chess_char_4_24_b']) { + const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5, skillIndex: 0 }], timeLimit: 120, hooks: ['damaged'], captureNoisy: true }); + h.step(); + const u = h.unit(id); + const es = SPREAD.map((p) => h.spawn('enemy_dummy', { pos: p })); + assert.equal(u.skill.maxCharges, 2, `${id}: S1 stores 2 charges`); + u.skill.gainSp(u.skill.spCost * u.skill.maxCharges); // every charge stored: the SEARCH cast is a charged one + assert.ok(h.runUntil(() => u.skill.active, 10), `${id}: charged S1 on`); + u.skill.charges = 0; // (the spare charge would re-cast as soon as it ends) + u.skill.sp = 0; + assert.ok(u.findBuff('billro:s1guard'), `${id}: charged — the trait guard stays`); + const t0 = h.b.time, dur = u.skill.timeLeft; + assert.ok(h.runUntil(() => !u.skill.active, dur + 1), `${id}: the charged S1 ends`); + assert.ok(h.b.time - t0 > dur - 0.1, `${id}: ran its ${dur} s`); + assert.equal(perAttack(h, u).size, 0, `${id}: no attack during a charged S1`); + // uncharged: the guard goes, every attack strikes all five enemies + u.skill.gainSp(u.skill.spCost); + assert.ok(h.runUntil(() => u.skill.active, 10), `${id}: uncharged S1 on`); + assert.ok(!u.findBuff('billro:s1guard'), `${id}: uncharged — no trait guard`); + h.run(5); + const atks = [...perAttack(h, u).values()]; + assert.ok(atks.length >= 2, `${id}: uncharged S1 attacks (${atks.length})`); + for (const m of atks) assert.equal(m.size, es.length, `${id}: uncharged S1 strikes every enemy in range (${m.size}/${es.length})`); + checkInvariants(h.b); + } +}); + +/** `damage / ATK` of every normal attack `u` lands on `e` (0-RES dummy, ATK read at the hit). */ +function ratios(h, u, e) { + const out = []; + h.b.on('damaged', (c) => { if (c.source === u && c.target === e && c.dmg?.isAttack) out.push(c.amount / u.s.atk); }, { priority: -1000 }); + return out; +} + +test('E3 卡涅利安 S3 食噬之印 charged: each attack stacks the mark BEFORE its damage — ×1.2 on the first hit, ×2.0 from the 5th (PRTS 备注), normal + elite', () => { + for (const id of ['chess_char_4_24_a', 'chess_char_4_24_b']) { + const per = ds.getChess(id, { skillIndex: 2 }).skill.bb['attack@damage_scale']; + const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 0 }) } }, units: [{ chessId: id, row: 10, col: 5, skillIndex: 2 }], timeLimit: 120, hooks: [], captureNoisy: true }); + h.step(); + const u = h.unit(id); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + const r = ratios(h, u, e); + u.skill.addCharge(2); + assert.ok(u.skill.activate('test')); + u.skill.charges = 0; + u.skill.sp = 0; + assert.ok(h.runUntil(() => r.length >= 7, 20), `${id}: 7 hits (${r.length})`); + const want = [1, 2, 3, 4, 5, 5, 5].map((n) => 1 + per * n); + for (let i = 0; i < want.length; i++) assert.ok(Math.abs(r[i] - want[i]) < 1e-6, `${id}: hit ${i + 1} ×${r[i].toFixed(4)}, want ×${want[i].toFixed(2)}`); + assert.equal(e.findBuff('billro:mark').stacks, 5); + h.runUntil(() => !u.skill.active, 25); + assert.ok(!e.findBuff('billro:mark'), `${id}: the mark ends with her skill`); + // uncharged: no mark, no bonus + u.skill.gainSp(u.skill.spCost); + assert.ok(h.runUntil(() => u.skill.active, 10)); + const n0 = r.length; + assert.ok(h.runUntil(() => r.length >= n0 + 2, 10)); + for (const x of r.slice(n0)) assert.ok(Math.abs(x - 1) < 1e-6, `${id}: uncharged ×${x}`); + assert.ok(!e.findBuff('billro:mark')); + } +}); + +test('E3 卡涅利安 S3 charged: one mark per enemy — a second 卡涅利安 adds stacks without the bonus, the setter keeps it (PRTS 备注 popup)', () => { + const A = 'chess_char_4_24_a', B = 'chess_char_4_24_b'; + const h = makeBattle({ + defs: { enemies: { enemy_dummy: dummy({ res: 0 }) } }, timeLimit: 120, hooks: [], captureNoisy: true, + units: [{ chessId: A, row: 10, col: 5, skillIndex: 2 }, { chessId: B, row: 10, col: 7, skillIndex: 2 }], + }); + h.step(); + const a = h.unit(A), b = h.unit(B); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); // on both ranges + const ra = ratios(h, a, e), rb = ratios(h, b, e); + const charge = (u) => { u.skill.addCharge(2); assert.ok(u.skill.activate('test')); u.skill.charges = 0; u.skill.sp = 0; }; + charge(a); + assert.ok(h.runUntil(() => ra.length >= 1, 5)); + assert.equal(e.findBuff('billro:mark').source, a, 'set by the first'); + charge(b); + assert.ok(h.runUntil(() => rb.length >= 4 && ra.length >= 4, 15)); + const m = e.findBuff('billro:mark'); + assert.equal(e.buffs.filter((x) => x.key === 'billro:mark').length, 1, 'one mark'); + assert.equal(m.source, a, 'still the setter\'s'); + assert.equal(m.stacks, 5, 'both stacked it'); + for (const x of rb) assert.ok(Math.abs(x - 1) < 1e-6, `the second 卡涅利安 gets no bonus (×${x})`); + assert.ok(Math.abs(ra[ra.length - 1] - 2) < 1e-6, `the setter ×${ra[ra.length - 1]} with 5 stacks`); + // the setter's skill ends: her mark goes; the next charged hit of the other sets a mark of its own + a.skill.stop(); + assert.ok(!e.findBuff('billro:mark'), 'the mark ends with the setter\'s skill'); + const n0 = rb.length; + assert.ok(h.runUntil(() => rb.length > n0, 5)); + assert.equal(e.findBuff('billro:mark').source, b); + assert.ok(Math.abs(rb[n0] - 1.2) < 1e-6, `its own mark ×${rb[n0]}`); + checkInvariants(h.b); +}); + +test('E3 audit: a 流形 copying a 阵法术师 / 轰击术师 fires a single-target bolt — the copy takes no attack shape (tokens.js and the 缪尔赛思 kit)', () => { + const MF = TOKEN_IDS.manifold; + for (const kit of ['managed', 'token']) { + for (const src of ['chess_char_4_10_a', 'chess_char_4_24_a']) { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: dummy() } }, timeLimit: 60, autoFinish: false, hooks: ['damaged'], captureNoisy: true, + ...(kit === 'token' ? { kits: { chess_char_6_11_a: () => ({ skill: null, talents: [], generic: true }) } } : {}), + units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: src, row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 9, col: 6, uid: 3, ownerUid: 1 }], + }); + h.step(); + const m = h.b.allyUnits.find((u) => u.defId === MF && u.alive); + assert.ok(h.runUntil(() => m.mem.mlyss || m.mem.copy, 10), `${kit}/${src}: copied`); + assert.equal((m.mem.mlyss || m.mem.copy).from ?? m.mem.copy?.id, h.unit(src).id); + assert.equal(m.profile.projectile, 'bolt', `${kit}/${src}: a bolt, not the instant 'beam'`); + assert.ok(!m.profile.allInRange && !m.profile.rangeAoe, `${kit}/${src}: single target`); + } + } +}); + +test('E3 profile: rangeAoe is applied after every override — from a kit trait too it means every enemy in range with instant hits', () => { + const def = { profession: 'CASTER', subProf: 'corecaster', attackKind: 'ranged', projectile: 'bolt', dmgType: 'arts', stats: { atk: 100 } }; + const plain = resolveProfile(def); + assert.equal(plain.allInRange, false); + assert.equal(plain.projectile, 'bolt'); + const aoe = resolveProfile(def, { rangeAoe: true }); + assert.equal(aoe.allInRange, true); + assert.equal(aoe.projectile, 'beam'); + for (const sub of ['phalanx', 'blastcaster']) { + const p = resolveProfile({ ...def, subProf: sub, attackKind: sub === 'phalanx' ? 'none' : 'ranged', projectile: sub === 'phalanx' ? 'none' : 'bolt' }); + assert.deepEqual([p.rangeAoe, p.allInRange, p.projectile, p.splashRadius], [true, true, 'beam', 0], sub); + } +}); From 165e7255f8c9d47c715a57f35de10915ba176609 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 06:12:34 +0800 Subject: [PATCH 3/3] feedback1e WR: real-wave test counts the enemies each attack damages; AoE source wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The act2 m01 round 10 test now checks the damage itself: every attack of 卡涅利安 S2/S3 damages each enemy on her range, under the same attack id. SIM.md, the CASTER comment and the test header name the primary source for the 轰击术师 (PRTS 作战机制: 伊芙利特's next-attack skill 炎爆 is a 锁定攻击范围 AoE), mark Terra Wiki as secondary for the 阵法术师 and treat the missing PRTS 溅射半径一览 row as supporting evidence only. --- docs/SIM.md | 4 ++-- server/sim/professions.js | 10 ++++++---- test/sim/feedback1e-aoe.test.js | 31 ++++++++++++++++++++++--------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 1960727a..0611df7b 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -899,7 +899,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | 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 | -| 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 (PRTS 溅射半径一览 lists no splash for it; Terra Wiki, Blast Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE; community report E3). A stealthed enemy is not struck unless it is revealed or blocked (a 锁定范围 AoE cannot hit a 隐匿 unit, PRTS 作战机制) | +| 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 | | 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) | @@ -907,7 +907,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | 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 | | 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'`) — "群体法术伤害" (PRTS 溅射半径一览 lists no splash for it; Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; PRTS 林 S3 备注 "单次普攻最多触发1次效果"; 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 备注) | +| 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 备注) | | physician | heal the lowest HP% injured ally in range (a skill `targeting.maxTargets` widens any heal profile) | | ringhealer | heal 3 allies | | chainhealer | heal bounces 3× (−25 %, bb chain.*) within 2.5 tiles | diff --git a/server/sim/professions.js b/server/sim/professions.js index 9c9659be..b1522e59 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -302,10 +302,12 @@ export const SUB = Object.freeze({ return front ? (unit.profile.frontScale ?? 1.5) : 1; } }), // --- CASTER - // "群体法术伤害" names two shapes: the 扩散术师 splash 1.1 tiles around the struck target (PRTS 溅射半径一览 — which lists - // no splash for the 阵法术师 / 轰击术师 — and Arknights Terra Wiki, Splash Caster), while the 轰击术师 ("超远距离的群体法术 - // 伤害") and the 阵法术师 strike every enemy inside the attack range at once, the same damage near and far (Terra Wiki, - // Blast / Phalanx Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE) — community report E3 after 0.1.0 + // "群体法术伤害" names two shapes: the 扩散术师 splash 1.1 tiles around the struck target (PRTS 溅射半径一览; Arknights + // Terra Wiki, Splash Caster), while the 轰击术师 ("超远距离的群体法术伤害") and the 阵法术师 strike every enemy inside the + // attack range at once, the same damage near and far — community report E3 after 0.1.0. Primary for the 轰击术师: PRTS + // 作战机制 §AOE伤害判定 names 伊芙利特's 炎爆 a 锁定攻击范围 AoE, and 炎爆 is her next-attack skill ("下次攻击造成…", + // PRTS 伊芙利特 S2); for the 阵法术师: Terra Wiki, Phalanx Caster (secondary) and PRTS 林 S3 备注. PRTS 溅射半径一览 + // 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 } }), diff --git a/test/sim/feedback1e-aoe.test.js b/test/sim/feedback1e-aoe.test.js index b678e5e7..3bb1f1b8 100644 --- a/test/sim/feedback1e-aoe.test.js +++ b/test/sim/feedback1e-aoe.test.js @@ -3,10 +3,11 @@ // enemies within their range … equal damage to all enemies in range, regardless of distance"; PRTS 作战机制 §AOE伤害判定 // "对攻击范围内的每个可以被选中的敌人进行判定"; PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill // several), not one target plus a 1.1-tile splash. The same holds for the 轰击术师 line ("超远距离的群体法术伤害": every -// enemy on the line — Terra Wiki Blast Caster; PRTS 作战机制: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE), while the 扩散术师 -// "群体法术伤害" stays a splash of 1.1 tiles around the struck target (PRTS 溅射半径一览 — no row for the 阵法术师 / -// 轰击术师 — and Terra Wiki Splash Caster). Real battles with the real chess (every selectable attacking skill, normal + -// elite), counting the enemies each attack damages. Her kit per PRTS 卡涅利安 备注: a charged S1 keeps the skill-off trait +// enemy on the line — PRTS 作战机制: 伊芙利特's 炎爆, her next-attack skill, is a 锁定攻击范围 AoE; Terra Wiki Blast +// Caster), while the 扩散术师 "群体法术伤害" stays a splash of 1.1 tiles around the struck target (PRTS 溅射半径一览, which +// documents no splash radius for the 阵法术师 / 轰击术师 — supporting only — and Terra Wiki Splash Caster). Real +// battles with the real chess (every selectable attacking skill, normal + elite), counting the enemies each attack +// damages. Her kit per PRTS 卡涅利安 备注: a charged S1 keeps the skill-off trait // (不攻击 + the guard), the charged S3 mark stacks before the damage and is one buff per enemy (the setter's bonus); a // 流形 copy takes no attack shape ('beam' → bolt); `rangeAoe` from any source means every enemy in range, instant. import { test } from 'node:test'; @@ -128,7 +129,7 @@ test('E3 audit: a 扩散术师 still splashes 1.1 tiles around its target — no } }); -test('E3 real stage + real wave: 卡涅利安 targets every enemy on her range with each attack (act2 m01, round 10)', () => { +test('E3 real stage + real wave: 卡涅利安 targets AND damages every enemy on her range with each attack (act2 m01, round 10)', () => { const data = getData({ log: { warn() {}, error() {}, info() {} } }); const mode = data.config.modes.mode_multi_normal; const round = '10'; @@ -150,19 +151,31 @@ test('E3 real stage + real wave: 卡涅利安 targets every enemy on her range w }); const u = h.unit(id); const rows = []; - // (beforeAttack, after every content hook: the targets of the attack and the enemies on her range at that moment) + const hit = new Map(); // attackId → the enemies its damage reached + // (beforeAttack, after every content hook: the targets of the attack and the enemies on her range at that moment; + // the attack that follows takes the next attackId) h.b.on('beforeAttack', (c) => { if (c.attacker !== u) return; const p = effectiveProfile(u); const want = new Set(h.b.enemiesInKeys(u.rangeKeys, u, p)); for (const e of h.b.blockedTargets(u, p)) want.add(e); - rows.push({ got: c.targets.length, want: want.size, all: [...want].every((e) => c.targets.includes(e)) }); + rows.push({ attackId: h.b._attackSeq + 1, got: c.targets.length, want, all: [...want].every((e) => c.targets.includes(e)) }); + }, { priority: -2000 }); + h.b.on('damaged', (c) => { + if (c.source !== u || !c.dmg?.isAttack) return; + if (!hit.has(c.dmg.attackId)) hit.set(c.dmg.attackId, new Set()); + hit.get(c.dmg.attackId).add(c.target); }, { priority: -2000 }); h.runToEnd(rc.combatTimeLimit + 5); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); - const crowded = rows.filter((r) => r.want >= 3); + const crowded = rows.filter((r) => r.want.size >= 3); assert.ok(crowded.length >= 1, `S${skillIndex + 1}: attacks into a crowd (${crowded.length}/${rows.length})`); - for (const r of rows) assert.ok(r.all && r.got === r.want, `S${skillIndex + 1}: ${r.got} targets of ${r.want} enemies on her range`); + for (const r of rows) { + assert.ok(r.all && r.got === r.want.size, `S${skillIndex + 1}: ${r.got} targets of ${r.want.size} enemies on her range`); + // the damage itself: one instant hit on every one of them, in the same attack (no projectile, no splash) + const got = hit.get(r.attackId) ?? new Set(); + assert.ok(got.size === r.want.size && [...r.want].every((e) => got.has(e)), `S${skillIndex + 1}: attack ${r.attackId} damaged ${got.size} of the ${r.want.size} enemies on her range`); + } } });