diablo2-web/tests/skills/sor/adversarial-challenger-sor....

660 lines
26 KiB
TypeScript

/**
* 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/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { computeEffectiveResistance } from '../../../src/game/engine/combat-pipeline.ts'
import {
calculateStaticFieldDamage,
calculateEnergyShieldAbsorbPct,
calculateEnergyShieldManaRatio,
calculateChainLightningJumps,
} from '../../../src/game/engine/missile-engine.ts'
import {
tickProjectiles,
type Projectile,
type ProjectileTarget,
} from '../../../src/game/skills.ts'
import { GameEngine } from '../../../src/game/engine.ts'
import { castSkill } from '../../../src/scene/act-scene.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<number, number>()
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)
}
})
})
})