/** * Adversarial Stress-Testing Suite for Cohort 2 — Sorceress Skills (IDs 36..65) * Challenger: teamwork_preview_challenger * * Probe Dimensions: * 1. Cold Armor state exclusivity (Frozen/Shiver/Chilling Armor mutual exclusion vs Enchant/Energy Shield coexistence) * 2. Static Field (25% current HP reduction, difficulty caps: Normal 1 HP, NM 33%, Hell 50%, Lightning immunity check) * 3. Energy Shield (mana absorption percentage scaling, Telekinesis synergy formula (32 - min(20, tkBlvl)) / 16, mana depletion handling) * 4. Chain Lightning (4-frame NextHitDelay and ping-pong hopping behavior between targets) * 5. Cold Mastery (enemy cold resistance flat reduction capped at -100% floor and inability to break natural cold immunities) * 6. Hydra (18 active heads / 6 groups cap with FIFO eviction on 7th cast) */ import { describe, expect, it } from 'vitest' import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' import { UnitStatList, FIXED_ONE } from '../../../src/server/engine/stat-list.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { computeEffectiveResistance } from '../../../src/server/engine/combat-pipeline.ts' import { calculateStaticFieldDamage, calculateEnergyShieldAbsorbPct, calculateEnergyShieldManaRatio, calculateChainLightningJumps, } from '../../../src/server/engine/missile-engine.ts' import { tickProjectiles, type Projectile, type ProjectileTarget, } from '../../../src/server/skills/skills.ts' import { GameEngine } from '../../../src/server/engine/engine.ts' import { castSkill } from '../../../src/server/scene/skill-caster.ts' describe('Adversarial Stress Testing — Sorceress Skills (Cohort 2)', () => { // ========================================================================= // 1. Cold Armor State Exclusivity & Coexistence Stress Test // ========================================================================= describe('1. Cold Armor state exclusivity & buff coexistence', () => { it('enforces strict mutual exclusion among Cold Armors across random permutations', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) const bus = new StateBus(stats, registry) const armors = ['frozenarmor', 'shiverarmor', 'chillingarmor'] // 30 consecutive transitions across random armors let lastCast = '' for (let i = 0; i < 30; i++) { const nextArmor = armors[i % armors.length]! bus.applyState({ stateNameOrId: nextArmor, slvl: 10 + (i % 5), durationFrames: 2500, currentFrame: i * 50, stats: { skill_armor_percent: 50 + i }, }) // Exactly one cold armor state must be active const activeColdArmors = armors.filter(a => bus.hasState(a)) expect(activeColdArmors).toEqual([nextArmor]) expect(bus.hasState(nextArmor)).toBe(true) if (lastCast && lastCast !== nextArmor) { expect(bus.hasState(lastCast)).toBe(false) } lastCast = nextArmor } }) it('does not clobber Enchant, Energy Shield, or Thunder Storm when cycling Cold Armors', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) const bus = new StateBus(stats, registry) // Apply other Sorceress buffs bus.applyState({ stateNameOrId: 'enchant', slvl: 20, durationFrames: 5000, stats: { fire_min_dmg: 100, fire_max_dmg: 150 }, }) bus.applyState({ stateNameOrId: 'energyshield', slvl: 20, durationFrames: 5000, stats: { energyshield_pct: 75 }, }) bus.applyState({ stateNameOrId: 'thunderstorm', slvl: 20, durationFrames: 5000, stats: { ltng_min_dmg: 50, ltng_max_dmg: 200 }, }) expect(bus.hasState('enchant')).toBe(true) expect(bus.hasState('energyshield')).toBe(true) expect(bus.hasState('thunderstorm')).toBe(true) // Cast Frozen Armor bus.applyState({ stateNameOrId: 'frozenarmor', slvl: 15, durationFrames: 3000 }) expect(bus.hasState('frozenarmor')).toBe(true) expect(bus.hasState('enchant')).toBe(true) expect(bus.hasState('energyshield')).toBe(true) expect(bus.hasState('thunderstorm')).toBe(true) // Switch to Shiver Armor bus.applyState({ stateNameOrId: 'shiverarmor', slvl: 15, durationFrames: 3000 }) expect(bus.hasState('shiverarmor')).toBe(true) expect(bus.hasState('frozenarmor')).toBe(false) expect(bus.hasState('enchant')).toBe(true) expect(bus.hasState('energyshield')).toBe(true) expect(bus.hasState('thunderstorm')).toBe(true) // Switch to Chilling Armor bus.applyState({ stateNameOrId: 'chillingarmor', slvl: 15, durationFrames: 3000 }) expect(bus.hasState('chillingarmor')).toBe(true) expect(bus.hasState('shiverarmor')).toBe(false) expect(bus.hasState('frozenarmor')).toBe(false) expect(bus.hasState('enchant')).toBe(true) expect(bus.hasState('energyshield')).toBe(true) expect(bus.hasState('thunderstorm')).toBe(true) // Refresh Enchant: must not clobber Chilling Armor bus.applyState({ stateNameOrId: 'enchant', slvl: 25, durationFrames: 6000 }) expect(bus.hasState('chillingarmor')).toBe(true) expect(bus.hasState('enchant')).toBe(true) expect(bus.hasState('energyshield')).toBe(true) expect(bus.hasState('thunderstorm')).toBe(true) }) it('rejects lower level recast when a higher level buff is already active with remaining duration', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) const bus = new StateBus(stats, registry) // Apply high-level buff const r1 = bus.applyState({ stateNameOrId: 'frozenarmor', slvl: 20, durationFrames: 5000, currentFrame: 100, stats: { skill_armor_percent: 125 }, }) expect(r1.applied).toBe(true) // Try casting lower level buff const r2 = bus.applyState({ stateNameOrId: 'frozenarmor', slvl: 5, durationFrames: 2000, currentFrame: 200, stats: { skill_armor_percent: 50 }, }) expect(r2.applied).toBe(false) expect(r2.activeSlvl).toBe(20) // Verify the active state retained slvl 20 const entry = bus.getState('frozenarmor') expect(entry?.slvl).toBe(20) }) }) // ========================================================================= // 2. Static Field Stress Test & Difficulty Caps // ========================================================================= describe('2. Static Field: 25% current HP reduction and difficulty caps', () => { it('verifies lightning immunity completely prevents damage regardless of HP or difficulty', () => { const difficulties: ('normal' | 'nightmare' | 'hell')[] = ['normal', 'nightmare', 'hell'] const testResistances = [100, 105, 120, 150, 200] for (const diff of difficulties) { for (const res of testResistances) { const outcome = calculateStaticFieldDamage(10000, 10000, diff, res) expect(outcome.damage).toBe(0) expect(outcome.newHp).toBe(10000) expect(outcome.immune).toBe(true) expect(outcome.blockedByFloor).toBe(false) } } }) it('fuzzes 1000 random HP states across all difficulties ensuring invariant compliance', () => { const difficulties: ('normal' | 'nightmare' | 'hell')[] = ['normal', 'nightmare', 'hell'] const sampleMaxHps = [1, 2, 5, 10, 50, 100, 333, 999, 1000, 5000, 100000, 5000000] for (const maxHp of sampleMaxHps) { for (let i = 0; i < 20; i++) { const currentHp = Math.max(1, Math.floor(Math.random() * maxHp) + 1) const ltngRes = Math.floor(Math.random() * 190) - 50 // -50% to 140% for (const diff of difficulties) { const result = calculateStaticFieldDamage(currentHp, maxHp, diff, ltngRes) const minPct = diff === 'hell' ? 50 : diff === 'nightmare' ? 33 : 0 const floorHp = diff === 'normal' ? 1 : Math.max(1, Math.floor((maxHp * minPct) / 100)) // 1.13c Assembly Invariant (SKILLS_AuraCallback_StaticField): // HP Floor check executes before damage & resistance if (currentHp <= floorHp) { expect(result.damage).toBe(0) expect(result.newHp).toBe(currentHp) expect(result.blockedByFloor).toBe(true) } else if (ltngRes >= 100) { expect(result.damage).toBe(0) expect(result.immune).toBe(true) expect(result.newHp).toBe(currentHp) } else { expect(result.damage).toBeGreaterThanOrEqual(0) expect(result.newHp).toBeLessThanOrEqual(currentHp) // Never fatal: newHp must be at least 1 expect(result.newHp).toBeGreaterThanOrEqual(1) } } } } }) it('monotonically drains HP until difficulty floors are reached', () => { // Normal difficulty: drains down to <= 3 HP (where floor(3 * 0.25) == 0 in integer math) let hpNormal = 100000 let castsNormal = 0 while (hpNormal > 3 && castsNormal < 100) { const res = calculateStaticFieldDamage(hpNormal, 100000, 'normal', 0) if (res.damage === 0) break hpNormal = res.newHp castsNormal++ } expect(hpNormal).toBeLessThanOrEqual(3) expect(hpNormal).toBeGreaterThanOrEqual(1) // At 1 HP floor, returns 0 damage immediately with blockedByFloor = true const at1Hp = calculateStaticFieldDamage(1, 100000, 'normal', 0) expect(at1Hp.damage).toBe(0) expect(at1Hp.blockedByFloor).toBe(true) // Nightmare difficulty: floor is 33% = 3300 HP let hpNm = 10000 let castsNm = 0 while (castsNm < 50) { const res = calculateStaticFieldDamage(hpNm, 10000, 'nightmare', 0) if (res.damage === 0) break hpNm = res.newHp castsNm++ } expect(hpNm).toBeLessThanOrEqual(3300) const nmBlocked = calculateStaticFieldDamage(hpNm, 10000, 'nightmare', 0) expect(nmBlocked.damage).toBe(0) expect(nmBlocked.blockedByFloor).toBe(true) // Hell difficulty: floor is 50% = 5000 HP let hpHell = 10000 let castsHell = 0 while (castsHell < 50) { const res = calculateStaticFieldDamage(hpHell, 10000, 'hell', 0) if (res.damage === 0) break hpHell = res.newHp castsHell++ } expect(hpHell).toBeLessThanOrEqual(5000) const hellBlocked = calculateStaticFieldDamage(hpHell, 10000, 'hell', 0) expect(hellBlocked.damage).toBe(0) expect(hellBlocked.blockedByFloor).toBe(true) }) }) // ========================================================================= // 3. Energy Shield: Absorption Scaling, Telekinesis Synergy & Mana Depletion // ========================================================================= describe('3. Energy Shield: mana absorption, Telekinesis synergy formula and depletion', () => { it('verifies exact Energy Shield absorb percentage progression up to 95% cap', () => { expect(calculateEnergyShieldAbsorbPct(1)).toBe(20) expect(calculateEnergyShieldAbsorbPct(2)).toBe(25) expect(calculateEnergyShieldAbsorbPct(8)).toBe(55) expect(calculateEnergyShieldAbsorbPct(9)).toBe(57) expect(calculateEnergyShieldAbsorbPct(16)).toBe(71) expect(calculateEnergyShieldAbsorbPct(17)).toBe(72) expect(calculateEnergyShieldAbsorbPct(20)).toBe(75) expect(calculateEnergyShieldAbsorbPct(40)).toBe(95) expect(calculateEnergyShieldAbsorbPct(50)).toBe(95) // hard cap at 95% }) it('verifies Telekinesis synergy formula (32 - min(20, tkBlvl)) / 16 exact values', () => { // 0 hard points: 32 / 16 = 2.00:1 (takes 2 mana per 1 HP absorbed) expect(calculateEnergyShieldManaRatio(0)).toBe(2.0) // 1 hard point: 31 / 16 = 1.9375 expect(calculateEnergyShieldManaRatio(1)).toBe(1.9375) // 8 hard points: 24 / 16 = 1.50:1 expect(calculateEnergyShieldManaRatio(8)).toBe(1.5) // 16 hard points: 16 / 16 = 1.00:1 expect(calculateEnergyShieldManaRatio(16)).toBe(1.0) // 20 hard points: 12 / 16 = 0.75:1 expect(calculateEnergyShieldManaRatio(20)).toBe(0.75) // >20 hard points (soft points or cheating): clamped at 20 hard points expect(calculateEnergyShieldManaRatio(25)).toBe(0.75) expect(calculateEnergyShieldManaRatio(40)).toBe(0.75) }) it('properly absorbs damage and drains mana according to Telekinesis synergy', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) stats.setHp256(1000 * FIXED_ONE) stats.setMana256(1000 * FIXED_ONE) stats.setBaseSkillLevel(43, 20) // TK 20 hard points -> ratio = 0.75 const bus = new StateBus(stats, registry) bus.applyState({ stateNameOrId: 'energyshield', slvl: 20, // 75% absorb durationFrames: 5000, stats: { energyshield_pct: 75 }, }) // Deliver 200 physical damage const outcome = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 200 * FIXED_ONE, incomingElem256: 0, }) // 75% of 200 = 150 damage absorbed expect(outcome.absorbedPhys256).toBe(150 * FIXED_ONE) const remainingPhys256 = 200 * FIXED_ONE - outcome.absorbedPhys256 expect(remainingPhys256).toBe(50 * FIXED_ONE) // At ratio 0.75: 150 * 0.75 = 112.5 -> integer trunc = 112 mana drained const expectedManaDrained = Math.trunc((150 * FIXED_ONE * (32 - 20)) / 16) expect(outcome.manaDrained256).toBe(expectedManaDrained) expect(stats.getMana256()).toBe(1000 * FIXED_ONE - expectedManaDrained) expect(bus.hasState('energyshield')).toBe(true) }) it('collapses Energy Shield upon mana depletion and absorbs only up to available mana', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) stats.setHp256(1000 * FIXED_ONE) // Only 50 mana available! stats.setMana256(50 * FIXED_ONE) stats.setBaseSkillLevel(43, 0) // TK 0 hard points -> ratio = 2.0 (32/16) const bus = new StateBus(stats, registry) bus.applyState({ stateNameOrId: 'energyshield', slvl: 20, // 75% absorb desired durationFrames: 5000, stats: { energyshield_pct: 75 }, }) // Incoming 200 physical damage. Desired absorb: 150 damage (requires 300 mana) const outcome = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 200 * FIXED_ONE, incomingElem256: 0, }) // Available 50 mana can only absorb 50 / 2 = 25 damage expect(outcome.absorbedPhys256).toBe(25 * FIXED_ONE) const remainingPhys256 = 200 * FIXED_ONE - outcome.absorbedPhys256 expect(remainingPhys256).toBe(175 * FIXED_ONE) expect(outcome.manaDrained256).toBe(50 * FIXED_ONE) expect(stats.getMana256()).toBe(0) // Energy Shield must collapse when mana is fully depleted! expect(bus.hasState('energyshield')).toBe(false) }) it('bypasses Energy Shield completely for poison damage and open wounds', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) stats.setHp256(1000 * FIXED_ONE) stats.setMana256(1000 * FIXED_ONE) const bus = new StateBus(stats, registry) bus.applyState({ stateNameOrId: 'energyshield', slvl: 20, durationFrames: 5000, stats: { energyshield_pct: 75 }, }) // Poison attack const poisonOutcome = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 0, incomingElem256: 100 * FIXED_ONE, elemType: 'pois', isPoison: true, }) // Poison must bypass ES: 0 absorbed, 0 mana drained, full damage to HP expect(poisonOutcome.absorbedElem256).toBe(0) expect(poisonOutcome.manaDrained256).toBe(0) const remainingElem256 = 100 * FIXED_ONE - poisonOutcome.absorbedElem256 expect(remainingElem256).toBe(100 * FIXED_ONE) expect(stats.getMana256()).toBe(1000 * FIXED_ONE) expect(bus.hasState('energyshield')).toBe(true) }) }) // ========================================================================= // 4. Chain Lightning: NextHitDelay and Ping-Pong Hopping // ========================================================================= describe('4. Chain Lightning: NextHitDelay = 4 and ping-pong hopping', () => { it('calculates 1.13c jumps: 5 + floor(slvl / 5)', () => { expect(calculateChainLightningJumps(1)).toBe(5) expect(calculateChainLightningJumps(4)).toBe(5) expect(calculateChainLightningJumps(5)).toBe(6) expect(calculateChainLightningJumps(10)).toBe(7) expect(calculateChainLightningJumps(20)).toBe(9) expect(calculateChainLightningJumps(30)).toBe(11) }) it('ping-pongs between 2 valid targets alternating until all jumps are consumed', () => { const t1: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true } const t2: ProjectileTarget = { index: 2, x: 200, y: 0, radius: 16, alive: true } const targets = [t1, t2] // Start missile aiming at t1 with 4 jumps remaining let activeProjectiles: readonly Projectile[] = [ { skillId: '53', x: 80, y: 0, vx: 30, vy: 0, damage: 50, ttl: 25, fromPlayer: true, missileType: 'chainlightning', chainRemaining: 4, nextHit: 1, nextDelay: 4, }, ] const hitTargetIndices: number[] = [] const terrain = { overlap: () => 0 } for (let tick = 1; tick <= 50; tick++) { const step = tickProjectiles(activeProjectiles, targets, terrain) for (const hit of step.hits) { hitTargetIndices.push(hit.targetIndex) } activeProjectiles = step.alive if (activeProjectiles.length === 0) break } // Expected: hit 1 -> jump to 2 -> jump to 1 -> jump to 2 -> jump to 1 (5 total hits) expect(hitTargetIndices).toEqual([1, 2, 1, 2, 1]) }) it('enforces 4-frame NextHitDelay immunity on the victim', () => { const nextDelayMap = new Map() const target: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true } const terrain = { overlap: () => 0 } const projectileA: Projectile = { skillId: '53', x: 95, y: 0, vx: 10, vy: 0, damage: 50, ttl: 10, fromPlayer: true, missileType: 'chainlightning', nextDelay: 4, } const projectileB: Projectile = { skillId: '53', x: 95, y: 0, vx: 10, vy: 0, damage: 50, ttl: 10, fromPlayer: true, missileType: 'chainlightning', nextDelay: 4, } // Tick 10: projectileA hits target, sets nextDelay expiration to 10 + 4 = 14 const step1 = tickProjectiles([projectileA], [target], { ...terrain, nextDelayByTarget: nextDelayMap, currentTick: 10, }) expect(step1.hits.length).toBe(1) expect(nextDelayMap.get(1)).toBe(14) // Tick 12 (< 14): projectileB tries to hit target within NextDelay window -> BLOCKED! const step2 = tickProjectiles([projectileB], [target], { ...terrain, nextDelayByTarget: nextDelayMap, currentTick: 12, }) expect(step2.hits.length).toBe(0) // Tick 15 (>= 14): NextDelay expired, projectile can hit again! const step3 = tickProjectiles([projectileB], [target], { ...terrain, nextDelayByTarget: nextDelayMap, currentTick: 15, }) expect(step3.hits.length).toBe(1) }) }) // ========================================================================= // 5. Cold Mastery: Flat Enemy Cold Resistance Reduction & Immunity Invariance // ========================================================================= describe('5. Cold Mastery: flat reduction capped at -100% and immunity invariance', () => { it('flatly reduces enemy resistance for non-immune monsters, capped at -100%', () => { // 50% base cold res - 100% Cold Mastery -> -50% effective res const r1 = computeEffectiveResistance({ baseRes: 50, passiveEnemyPierce: 100, }) expect(r1.effectiveRes).toBe(-50) expect(r1.isImmune).toBe(false) // 0% base cold res - 100% Cold Mastery -> -100% effective res (floor) const r2 = computeEffectiveResistance({ baseRes: 0, passiveEnemyPierce: 100, }) expect(r2.effectiveRes).toBe(-100) expect(r2.isImmune).toBe(false) // 0% base cold res - 200% Cold Mastery -> CLAMPED TO -100% FLOOR! const r3 = computeEffectiveResistance({ baseRes: 0, passiveEnemyPierce: 200, }) expect(r3.effectiveRes).toBe(-100) expect(r3.isImmune).toBe(false) // -50% base cold res - 100% Cold Mastery -> CLAMPED TO -100% FLOOR! const r4 = computeEffectiveResistance({ baseRes: -50, passiveEnemyPierce: 100, }) expect(r4.effectiveRes).toBe(-100) expect(r4.isImmune).toBe(false) }) it('CANNOT break natural cold immunities on its own regardless of magnitude', () => { // Naturally immune monster (100% cold res) with massive -200% Cold Mastery const r100 = computeEffectiveResistance({ baseRes: 100, passiveEnemyPierce: 200, }) expect(r100.isImmune).toBe(true) expect(r100.effectiveRes).toBe(100) // Heavy immune monster (150% cold res) with -250% Cold Mastery const r150 = computeEffectiveResistance({ baseRes: 150, passiveEnemyPierce: 250, }) expect(r150.isImmune).toBe(true) expect(r150.effectiveRes).toBe(100) }) it('applies at full strength ONLY AFTER an external source (Conviction/Lower Resist) breaks immunity', () => { // 105% base res. Conviction 85% at 1/5th = 17% reduction -> 105 - 17 = 88% (immunity broken!) // Once broken, Cold Mastery 100% applies at full strength: 88 - 100 = -12% const broken = computeEffectiveResistance({ baseRes: 105, convictionPierce: 85, passiveEnemyPierce: 100, }) expect(broken.isImmune).toBe(false) expect(broken.resAfterConvictionLR).toBe(88) expect(broken.effectiveRes).toBe(-12) // Insufficient Conviction: 130% base res. Conviction 85% at 1/5th = 17% -> 113% >= 100% (NOT broken!) // Cold Mastery still CANNOT apply! const unbroken = computeEffectiveResistance({ baseRes: 130, convictionPierce: 85, passiveEnemyPierce: 150, }) expect(unbroken.isImmune).toBe(true) expect(unbroken.resAfterConvictionLR).toBe(113) expect(unbroken.effectiveRes).toBe(100) }) }) // ========================================================================= // 6. Hydra: 18 Active Heads (6 Groups) Cap & FIFO Eviction // ========================================================================= describe('6. Hydra: 18 active heads (6 groups) cap with FIFO eviction', () => { it('enforces maximum 6 groups (18 heads) and FIFO eviction of oldest group on 7th and 8th cast', () => { const engine = new GameEngine( { widthPx: 1200, heightPx: 1200, overlap: () => 0 }, { spawn: { x: 200, y: 200 }, stats: [], questDefs: [], npcDefs: [] } as any, ) engine.world.player.x = 200 engine.world.player.y = 200 engine.world.player.mana = 2000 const dummyRuntime = { walkable: { isWalkable: () => true }, collision: { isWalkable: () => true }, } as any const statusEl = { textContent: '' } as HTMLElement // Cast 1 to 6 groups for (let cast = 1; cast <= 6; cast++) { engine.world.player.cooldown = 0 const ok = castSkill(62, 300 + cast * 30, 300, { engine, runtime: dummyRuntime, hudManager: null, status: statusEl, }) expect(ok).toBe(true) expect(engine.projectiles.length).toBe(cast * 3) } // 6 groups = exactly 18 heads expect(engine.projectiles.length).toBe(18) const group1HeadPositions = engine.projectiles.slice(0, 3).map(p => ({ x: p.x, y: p.y })) const group2HeadPositions = engine.projectiles.slice(3, 6).map(p => ({ x: p.x, y: p.y })) // Cast 7th time -> evicts Group 1, cap remains at 18 engine.world.player.cooldown = 0 const ok7 = castSkill(62, 600, 300, { engine, runtime: dummyRuntime, hudManager: null, status: statusEl, }) expect(ok7).toBe(true) expect(engine.projectiles.length).toBe(18) // Group 1 heads must be gone for (const pos of group1HeadPositions) { expect(engine.projectiles.some(p => p.x === pos.x && p.y === pos.y)).toBe(false) } // Group 2 heads must still be present for (const pos of group2HeadPositions) { expect(engine.projectiles.some(p => p.x === pos.x && p.y === pos.y)).toBe(true) } // Cast 8th time -> evicts Group 2, cap remains at 18 engine.world.player.cooldown = 0 const ok8 = castSkill(62, 650, 300, { engine, runtime: dummyRuntime, hudManager: null, status: statusEl, }) expect(ok8).toBe(true) expect(engine.projectiles.length).toBe(18) // Group 2 heads must now be gone for (const pos of group2HeadPositions) { expect(engine.projectiles.some(p => p.x === pos.x && p.y === pos.y)).toBe(false) } }) }) })