660 lines
26 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|
|
})
|