feedback1e WR: 阵法术师 / 轰击术师 strike every enemy in range (卡涅利安 E3)
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.
This commit is contained in:
1 parent
7f134737ce
commit
27181d88bd
8 files changed
+332
-25
No files matched your search
+7
-4
@@ -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 |
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)} %`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user