749 lines
28 KiB
TypeScript
749 lines
28 KiB
TypeScript
/**
|
|
* Adversarial Challenger Stress & Boundary Suite: Necromancer Summoning & Curses
|
|
* Cohort 3 Parity Stress-Testing (IDs 66..75, 86..95)
|
|
*
|
|
* Ground Truth Invariants Tested:
|
|
* 1. Skeleton Max Count: Exact formula across slvl 1..50 (slvl 1->1, 2->2, 3->3, 4->3, 6->4, 9->5, 12->6, 15->7, 18->8, 20->8, 30->12, 50->18).
|
|
* Skeleton Mastery: +8 life/lvl, +2 dmg/lvl, +10 def/lvl, +5% revive life/lvl, +10% damage/lvl.
|
|
* 2. Skeletal Mages: 4 elemental variants (Fire, Cold, Lightning, Poison) generate correct missile types, damage, and duration scaling.
|
|
* 3. Golems:
|
|
* - Clay Golem: slow-on-hit percentage formula min(75, floor(11 + 40*(slvl-1)/(slvl+6))), slow attacker when hit.
|
|
* - Blood Golem: 70% life steal to player, 30% damage transfer to master.
|
|
* - Iron Golem: inherits item properties and stats from targeted metallic item, innate Thorns reflection.
|
|
* - Fire Golem: Holy Fire radial aura and 100% fire absorb healing.
|
|
* - Golem exclusivity: Exactly 1 active golem allowed across all 4 types.
|
|
* 4. Revive: 180.0s (4500 frames) lifetime, +200% life (+5%/lvl SM), +215% damage (+10%/lvl SM), pet cap equals slvl.
|
|
* 5. Curse Exclusivity: Casting any cursetype=1 curse cleanly overwrites prior cursetype=1 curse without stat leakage across 100 cycles.
|
|
* 6. Attract Immunity: A monster affected by Attract (cursetype=2) CANNOT have Attract overwritten by any other curse.
|
|
* 7. Lower Resist Immunity Breaking: When base resistance >= 100%, Lower Resist applies at 1/5th efficiency.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest'
|
|
import {
|
|
calculateSkeletonMaxCount,
|
|
calculateSkeletalMageMaxCount,
|
|
calculateReviveMaxCount,
|
|
calculateSkeletonMasteryStats,
|
|
calculateGolemMasteryStats,
|
|
calculateSummonResistPct,
|
|
calculateClayGolemStats,
|
|
calculateBloodGolemStats,
|
|
calculateIronGolemStats,
|
|
calculateFireGolemStats,
|
|
calculateReviveStats,
|
|
calculateCurseEffect,
|
|
calculateCurseDurationFrames,
|
|
calculateLowerResistReduction,
|
|
computeImmunityBreak,
|
|
type IronGolemItemProps,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
computeEffectiveResistance,
|
|
executeSUnitDmg,
|
|
type CombatUnitContext,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { SummonManager, type SummonedPetUnit } from '../../../src/game/engine/summon-manager.ts'
|
|
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
|
|
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
|
|
|
|
describe('Adversarial Challenger: Necromancer Summoning & Curses Stress Suite', () => {
|
|
let registry: any
|
|
|
|
beforeEach(async () => {
|
|
registry = await getSharedDataRegistry()
|
|
})
|
|
|
|
// =========================================================================
|
|
// Target 1: Skeleton Max Count Formula (slvl 1..50) & Skeleton Mastery
|
|
// =========================================================================
|
|
describe('Target 1: Skeleton Max Count & Skeleton Mastery Scaling', () => {
|
|
it('verifies exact 1.13c max skeleton count across all skill levels from 1 to 50', () => {
|
|
// Oracle: slvl < 4 ? slvl : 2 + Math.floor(slvl / 3)
|
|
const expectedMilestones: Record<number, number> = {
|
|
1: 1,
|
|
2: 2,
|
|
3: 3,
|
|
4: 3,
|
|
5: 3,
|
|
6: 4,
|
|
7: 4,
|
|
8: 4,
|
|
9: 5,
|
|
10: 5,
|
|
11: 5,
|
|
12: 6,
|
|
13: 6,
|
|
14: 6,
|
|
15: 7,
|
|
16: 7,
|
|
17: 7,
|
|
18: 8,
|
|
19: 8,
|
|
20: 8,
|
|
21: 9,
|
|
24: 10,
|
|
27: 11,
|
|
30: 12,
|
|
40: 15,
|
|
50: 18,
|
|
}
|
|
|
|
for (let slvl = 1; slvl <= 50; slvl++) {
|
|
const actual = calculateSkeletonMaxCount(slvl)
|
|
const expected = slvl < 4 ? slvl : 2 + Math.floor(slvl / 3)
|
|
expect(actual).toBe(expected)
|
|
|
|
if (expectedMilestones[slvl] !== undefined) {
|
|
expect(actual).toBe(expectedMilestones[slvl])
|
|
}
|
|
}
|
|
|
|
// Edge case: invalid/zero/negative levels
|
|
expect(calculateSkeletonMaxCount(0)).toBe(0)
|
|
expect(calculateSkeletonMaxCount(-5)).toBe(0)
|
|
expect(calculateSkeletonMaxCount(NaN)).toBe(0)
|
|
})
|
|
|
|
it('verifies Skeleton Mastery linear bonuses: +8 life/lvl, +2 dmg/lvl, +10 def/lvl', () => {
|
|
for (let slvl = 1; slvl <= 40; slvl++) {
|
|
const stats = calculateSkeletonMasteryStats(slvl)
|
|
expect(stats.skeletonHp).toBe(8 * slvl)
|
|
expect(stats.skeletonDamage).toBe(2 * slvl)
|
|
expect(stats.defense).toBe(10 * slvl)
|
|
expect(stats.reviveHpPct).toBe(5 * slvl)
|
|
expect(stats.damagePct).toBe(10 * slvl)
|
|
}
|
|
|
|
// Edge case: slvl 0 returns zeroed stats
|
|
const zero = calculateSkeletonMasteryStats(0)
|
|
expect(zero.skeletonHp).toBe(0)
|
|
expect(zero.skeletonDamage).toBe(0)
|
|
expect(zero.defense).toBe(0)
|
|
})
|
|
|
|
it('stress-tests SummonManager pet cap and Mastery scaling in actual summon creation', () => {
|
|
const sm = new SummonManager(registry)
|
|
const playerStats = new UnitStatList(registry, {
|
|
level: 80,
|
|
})
|
|
// Give slvl 20 Skeleton Mastery (Skill 69)
|
|
playerStats.setBaseSkillLevel(69, 20)
|
|
|
|
const owner: CombatUnitContext = {
|
|
id: 'player-necro',
|
|
name: 'Necromancer',
|
|
statList: playerStats,
|
|
stateBus: new StateBus(playerStats, registry),
|
|
}
|
|
|
|
// Prepare 20 corpses
|
|
const corpses = Array.from({ length: 20 }, (_, i) => ({
|
|
id: `corpse-${i}`,
|
|
consumed: false,
|
|
baseMaxHp: 500,
|
|
isBoss: false,
|
|
}))
|
|
|
|
// At slvl 20 Raise Skeleton, max count is 8
|
|
for (let i = 0; i < 15; i++) {
|
|
const outcome = sm.createPet({
|
|
owner,
|
|
skillId: 70, // Raise Skeleton
|
|
slvl: 20,
|
|
corpses,
|
|
})
|
|
expect(outcome.created).toBe(true)
|
|
}
|
|
|
|
const activeSkeletons = sm.getActivePets().filter(p => p.kind === 'skeleton')
|
|
expect(activeSkeletons.length).toBe(8)
|
|
|
|
// Verify each skeleton's HP includes mastery:
|
|
// (base (21) + 20*15 + 8*20) * (1 + 10%*20) = (21 + 300 + 160) * 3 = 481 * 3 = 1443
|
|
const sample = activeSkeletons[0]!
|
|
expect(sample.maxHp).toBeGreaterThanOrEqual(1400)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Target 2: Skeletal Mages Elemental Variants & Scaling
|
|
// =========================================================================
|
|
describe('Target 2: Skeletal Mages 4 Elemental Variants & Missiles', () => {
|
|
it('verifies 4 elemental variants (Fire, Cold, Lightning, Poison) generate correct missile types', () => {
|
|
const sm = new SummonManager(registry)
|
|
const playerStats = new UnitStatList(registry, { level: 80 })
|
|
const owner: CombatUnitContext = {
|
|
id: 'player-necro',
|
|
name: 'Necromancer',
|
|
statList: playerStats,
|
|
stateBus: new StateBus(playerStats, registry),
|
|
}
|
|
|
|
const corpses = Array.from({ length: 16 }, (_, i) => ({
|
|
id: `corpse-${i}`,
|
|
consumed: false,
|
|
baseMaxHp: 500,
|
|
isBoss: false,
|
|
}))
|
|
|
|
// Summon 8 mages (slvl 20 max count is 8)
|
|
const elementsFound = new Set<string>()
|
|
for (let i = 0; i < 8; i++) {
|
|
const outcome = sm.createPet({
|
|
owner,
|
|
skillId: 80, // Raise Skeletal Mage
|
|
slvl: 20,
|
|
corpses,
|
|
})
|
|
expect(outcome.created).toBe(true)
|
|
if (outcome.pet?.mageElement) {
|
|
elementsFound.add(outcome.pet.mageElement)
|
|
}
|
|
}
|
|
|
|
// All 4 elements must be represented in rotation
|
|
expect(elementsFound.has('fire')).toBe(true)
|
|
expect(elementsFound.has('cold')).toBe(true)
|
|
expect(elementsFound.has('lightning')).toBe(true)
|
|
expect(elementsFound.has('poison')).toBe(true)
|
|
|
|
const activeMages = sm.getActivePets().filter(p => p.kind === 'skeleton_mage')
|
|
expect(activeMages.length).toBe(8)
|
|
})
|
|
|
|
it('verifies tickPets executes the authentic elemental missile for each mage type', () => {
|
|
const sm = new SummonManager(registry)
|
|
const playerStats = new UnitStatList(registry, { level: 80 })
|
|
playerStats.setBaseSkillLevel(69, 10) // Skeleton Mastery slvl 10 (+20 flat dmg)
|
|
const owner: CombatUnitContext = {
|
|
id: 'player-necro',
|
|
name: 'Necromancer',
|
|
statList: playerStats,
|
|
stateBus: new StateBus(playerStats, registry),
|
|
}
|
|
|
|
const spawnedMissiles: { missile: string; elem: string; min: number; max: number }[] = []
|
|
const mockMissileEngine: any = {
|
|
spawnMissile: (params: any) => {
|
|
spawnedMissiles.push({
|
|
missile: params.missileNameOrId,
|
|
elem: params.dmgPacket.elemType,
|
|
min: params.dmgPacket.elemMin256,
|
|
max: params.dmgPacket.elemMax256,
|
|
})
|
|
},
|
|
}
|
|
|
|
const corpses = Array.from({ length: 8 }, (_, i) => ({
|
|
id: `corpse-${i}`,
|
|
consumed: false,
|
|
baseMaxHp: 500,
|
|
isBoss: false,
|
|
}))
|
|
|
|
// Create 4 mages (one of each element)
|
|
for (let i = 0; i < 4; i++) {
|
|
sm.createPet({ owner, skillId: 80, slvl: 15, corpses })
|
|
}
|
|
|
|
const enemyStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, maxhp: 1000 * FIXED_ONE })
|
|
const enemy: CombatUnitContext = {
|
|
id: 'monster-1',
|
|
name: 'Zombie',
|
|
statList: enemyStats,
|
|
stateBus: new StateBus(enemyStats, registry),
|
|
}
|
|
|
|
const enemyPositions = new Map<string, { x: number; y: number }>()
|
|
enemyPositions.set('monster-1', { x: 50, y: 120 })
|
|
|
|
sm.tickPets({
|
|
currentTick: 1,
|
|
owner,
|
|
enemies: [enemy],
|
|
enemyPositions,
|
|
missileEngine: mockMissileEngine,
|
|
auraScanner: {} as any,
|
|
})
|
|
|
|
expect(spawnedMissiles.length).toBe(4)
|
|
const missileNames = spawnedMissiles.map(m => m.missile)
|
|
expect(missileNames).toContain('firebolt')
|
|
expect(missileNames).toContain('icebolt')
|
|
expect(missileNames).toContain('chargedbolt')
|
|
expect(missileNames).toContain('poisonexplosioncloud')
|
|
|
|
// Verify elemental damage scaling: (25 + 15*12 + 20) = 225 min, (45 + 15*18 + 20) = 335 max
|
|
for (const m of spawnedMissiles) {
|
|
expect(m.min).toBe(225 * FIXED_ONE)
|
|
expect(m.max).toBe(335 * FIXED_ONE)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Target 3: Golems Deep Mechanics
|
|
// =========================================================================
|
|
describe('Target 3: Golems Parity & Stress Testing', () => {
|
|
it('verifies Clay Golem slow % formula across slvl 1..100 and attacker slow on hit', () => {
|
|
// Formula: min(75, floor(11 + 40 * (slvl - 1) / (slvl + 6)))
|
|
const checkpoints: Record<number, number> = {
|
|
1: 11,
|
|
2: 16,
|
|
5: 25,
|
|
10: 33,
|
|
20: 40,
|
|
50: 46,
|
|
}
|
|
|
|
for (let slvl = 1; slvl <= 100; slvl++) {
|
|
const stats = calculateClayGolemStats(slvl)
|
|
const expected = Math.min(75, Math.floor(11 + (40 * (slvl - 1)) / (slvl + 6)))
|
|
expect(stats.slowPct).toBe(expected)
|
|
expect(stats.slowPct).toBeLessThanOrEqual(75)
|
|
|
|
if (checkpoints[slvl] !== undefined) {
|
|
expect(stats.slowPct).toBe(checkpoints[slvl])
|
|
}
|
|
}
|
|
|
|
// Verify attacker is slowed when hitting Clay Golem in melee
|
|
const clayStats = new UnitStatList(registry, {
|
|
hitpoints: 500 * FIXED_ONE,
|
|
maxhp: 500 * FIXED_ONE,
|
|
clay_golem_slow: 40, // 40% slow
|
|
})
|
|
const clayUnit: CombatUnitContext = {
|
|
id: 'clay-golem',
|
|
name: 'Clay Golem',
|
|
statList: clayStats,
|
|
stateBus: new StateBus(clayStats, registry),
|
|
}
|
|
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: 500 * FIXED_ONE,
|
|
maxhp: 500 * FIXED_ONE,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'attacker-monster',
|
|
name: 'Fallen',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
}
|
|
|
|
// Attacker hits Clay Golem in melee
|
|
const combatOut = executeSUnitDmg(attacker, clayUnit, {
|
|
skillId: 0,
|
|
attackKind: 'melee',
|
|
flatPhysMin256: 30 * FIXED_ONE,
|
|
flatPhysMax256: 30 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
expect(combatOut.attackerStatesApplied).toContain('slowed')
|
|
expect(attacker.stateBus.hasState('slowed')).toBe(true)
|
|
expect(attacker.statList.getAccruedStat('item_slow')).toBe(40)
|
|
})
|
|
|
|
it('verifies Blood Golem life link: 70% life steal to master, 30% damage transfer', () => {
|
|
const stats = calculateBloodGolemStats(20, 10)
|
|
expect(stats.lifeLeechPct).toBe(70)
|
|
expect(stats.damageSharePct).toBe(30)
|
|
|
|
// Test life link healing in SummonManager.tickPets
|
|
const sm = new SummonManager(registry)
|
|
const playerStats = new UnitStatList(registry, {
|
|
hitpoints: 200 * FIXED_ONE,
|
|
maxhp: 500 * FIXED_ONE,
|
|
})
|
|
const owner: CombatUnitContext = {
|
|
id: 'player-necro',
|
|
name: 'Necromancer',
|
|
statList: playerStats,
|
|
stateBus: new StateBus(playerStats, registry),
|
|
}
|
|
|
|
const outcome = sm.createPet({ owner, skillId: 85, slvl: 10 }) // Blood Golem
|
|
expect(outcome.created).toBe(true)
|
|
|
|
const enemyStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, maxhp: 1000 * FIXED_ONE })
|
|
const enemy: CombatUnitContext = {
|
|
id: 'enemy-1',
|
|
name: 'Gargoyle',
|
|
statList: enemyStats,
|
|
stateBus: new StateBus(enemyStats, registry),
|
|
}
|
|
const enemyPositions = new Map([['enemy-1', { x: 0, y: 60 }]])
|
|
|
|
// Tick at tick 10 (when melee attack triggers)
|
|
sm.tickPets({
|
|
currentTick: 10,
|
|
owner,
|
|
enemies: [enemy],
|
|
enemyPositions,
|
|
missileEngine: {} as any,
|
|
auraScanner: {} as any,
|
|
})
|
|
|
|
// Master should have been healed by 70% of Blood Golem physical damage
|
|
const curHp = playerStats.getHp256() / FIXED_ONE
|
|
expect(curHp).toBeGreaterThan(200)
|
|
})
|
|
|
|
it('verifies Iron Golem inherits metallic item properties and has innate Thorns', () => {
|
|
const item: IronGolemItemProps = {
|
|
baseDamage: 85,
|
|
defense: 250,
|
|
auras: [119], // Meditation
|
|
}
|
|
const stats = calculateIronGolemStats(15, item, 10)
|
|
expect(stats.inheritedDamage).toBe(85)
|
|
expect(stats.inheritedDefense).toBe(250)
|
|
expect(stats.inheritedAuras).toEqual([119])
|
|
// Thorns return pct: 150 + (15 - 1) * 15 = 150 + 210 = 360%
|
|
expect(stats.thornsReturnPct).toBe(360)
|
|
})
|
|
|
|
it('verifies Fire Golem Holy Fire pulse damage and 100% Fire Absorb healing', () => {
|
|
const fgStats = calculateFireGolemStats(20, 10)
|
|
expect(fgStats.fireAbsorbPct).toBe(100)
|
|
expect(fgStats.holyFirePulseDmg).toBe(42) // 2 + 20 * 2 = 42
|
|
|
|
// Verify Fire Absorb in combat pipeline: Fire damage heals instead of damages
|
|
const golemStats = new UnitStatList(registry, {
|
|
hitpoints: 300 * FIXED_ONE,
|
|
maxhp: 600 * FIXED_ONE,
|
|
fireresist: 0,
|
|
fire_golem_absorb: 100,
|
|
})
|
|
const fireGolem: CombatUnitContext = {
|
|
id: 'fire-golem',
|
|
name: 'Fire Golem',
|
|
statList: golemStats,
|
|
stateBus: new StateBus(golemStats, registry),
|
|
}
|
|
|
|
const fireAttackerStats = new UnitStatList(registry, { hitpoints: 100 * FIXED_ONE })
|
|
const fireAttacker: CombatUnitContext = {
|
|
id: 'fire-caster',
|
|
name: 'Fire Mage',
|
|
statList: fireAttackerStats,
|
|
stateBus: new StateBus(fireAttackerStats, registry),
|
|
}
|
|
|
|
const out = executeSUnitDmg(fireAttacker, fireGolem, {
|
|
skillId: 47, // Fire Ball
|
|
attackKind: 'spell',
|
|
elemType: 'fire',
|
|
elemMin256: 100 * FIXED_ONE,
|
|
elemMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
expect(golemStats.getHp256()).toBe(400 * FIXED_ONE)
|
|
expect(out.totalDamage).toBe(0)
|
|
})
|
|
|
|
it('verifies strict Golem mutual exclusivity (Group 3): exactly 1 active Golem permitted', () => {
|
|
const sm = new SummonManager(registry)
|
|
const stats = new UnitStatList(registry, { level: 80 })
|
|
const owner: CombatUnitContext = {
|
|
id: 'player-necro',
|
|
name: 'Necromancer',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
}
|
|
|
|
// Sequentially summon Clay (75), Blood (85), Iron (90), Fire (94)
|
|
const r1 = sm.createPet({ owner, skillId: 75, slvl: 1 })
|
|
expect(r1.created).toBe(true)
|
|
expect(sm.getActivePets().length).toBe(1)
|
|
expect(sm.getActivePets()[0]?.kind).toBe('clay_golem')
|
|
|
|
const r2 = sm.createPet({ owner, skillId: 85, slvl: 1 })
|
|
expect(r2.created).toBe(true)
|
|
expect(sm.getActivePets().length).toBe(1)
|
|
expect(sm.getActivePets()[0]?.kind).toBe('blood_golem')
|
|
expect(r2.evictedPetUids).toContain(r1.pet!.uid)
|
|
|
|
const r3 = sm.createPet({ owner, skillId: 90, slvl: 1 })
|
|
expect(r3.created).toBe(true)
|
|
expect(sm.getActivePets().length).toBe(1)
|
|
expect(sm.getActivePets()[0]?.kind).toBe('iron_golem')
|
|
expect(r3.evictedPetUids).toContain(r2.pet!.uid)
|
|
|
|
const r4 = sm.createPet({ owner, skillId: 94, slvl: 1 })
|
|
expect(r4.created).toBe(true)
|
|
expect(sm.getActivePets().length).toBe(1)
|
|
expect(sm.getActivePets()[0]?.kind).toBe('fire_golem')
|
|
expect(r4.evictedPetUids).toContain(r3.pet!.uid)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Target 4: Revive 1.13c Invariants
|
|
// =========================================================================
|
|
describe('Target 4: Revive 1.13c Invariants & Expiration', () => {
|
|
it('verifies 180s (4500 frames) lifetime, +200% life, +215% damage, and pet cap = slvl', () => {
|
|
for (let slvl = 1; slvl <= 30; slvl++) {
|
|
expect(calculateReviveMaxCount(slvl)).toBe(slvl)
|
|
|
|
const stats0 = calculateReviveStats(slvl, 0)
|
|
expect(stats0.durationFrames).toBe(4500)
|
|
expect(stats0.hpMultiplier).toBe(3.0) // 1.0 + 200% = 3.0x
|
|
expect(stats0.damageMultiplier).toBe(3.15) // 1.0 + 215% = 3.15x
|
|
|
|
// With Skeleton Mastery slvl 20: +5%/lvl HP (+100%), +10%/lvl DMG (+200%)
|
|
const stats20 = calculateReviveStats(slvl, 20)
|
|
expect(stats20.hpMultiplier).toBe(4.0) // 3.0 + 20*0.05 = 4.0x
|
|
expect(stats20.damageMultiplier).toBe(5.15) // 3.15 + 20*0.10 = 5.15x
|
|
}
|
|
})
|
|
|
|
it('verifies Revive despawns when 4500-tick lifetime expires', () => {
|
|
const sm = new SummonManager(registry)
|
|
const stats = new UnitStatList(registry, { level: 80 })
|
|
const owner: CombatUnitContext = {
|
|
id: 'player-necro',
|
|
name: 'Necromancer',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
}
|
|
|
|
const corpses = [{ id: 'corpse-revive', consumed: false, baseMaxHp: 800, isBoss: false }]
|
|
const outcome = sm.createPet({ owner, skillId: 95, slvl: 5, corpses })
|
|
expect(outcome.created).toBe(true)
|
|
const pet = outcome.pet!
|
|
expect(pet.lifespanTicks).toBe(4500)
|
|
|
|
// Fast forward lifespan ticks
|
|
pet.lifespanTicks = 2
|
|
sm.tickPets({
|
|
currentTick: 1,
|
|
owner,
|
|
enemies: [],
|
|
enemyPositions: new Map(),
|
|
missileEngine: {} as any,
|
|
auraScanner: {} as any,
|
|
})
|
|
expect(sm.getActivePets().length).toBe(1)
|
|
expect(pet.lifespanTicks).toBe(1)
|
|
|
|
// Final tick: should expire and despawn
|
|
sm.tickPets({
|
|
currentTick: 2,
|
|
owner,
|
|
enemies: [],
|
|
enemyPositions: new Map(),
|
|
missileEngine: {} as any,
|
|
auraScanner: {} as any,
|
|
})
|
|
expect(sm.getActivePets().length).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Target 5: Curse Exclusivity (cursetype=1 Mutual Overwrite Without Stat Leakage)
|
|
// =========================================================================
|
|
describe('Target 5: Curse Exclusivity Across 100 Alternating Cycles', () => {
|
|
it('rapidly alternates across all 8 cursetype=1 curses with clean overwrites and 0 stat leakage', () => {
|
|
const monsterStats = new UnitStatList(registry, {
|
|
level: 85,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
maxhp: 1000 * FIXED_ONE,
|
|
damageresist: 50,
|
|
fireresist: 50,
|
|
coldresist: 50,
|
|
lightresist: 50,
|
|
poisonresist: 50,
|
|
})
|
|
const bus = new StateBus(monsterStats, registry)
|
|
|
|
const curseSequence = [
|
|
{ id: 66, name: 'amplifydamage', statKey: 'damageresist', val: -100 },
|
|
{ id: 72, name: 'weaken', statKey: 'damagepercent', val: -33 },
|
|
{ id: 76, name: 'ironmaiden', statKey: 'ironmaiden_return_pct', val: 200 },
|
|
{ id: 82, name: 'lifetap', statKey: 'lifetap_leech_pct', val: 50 },
|
|
{ id: 87, name: 'decrepify', statKey: 'item_slow', val: 50 },
|
|
{ id: 91, name: 'lowerresist', statKey: 'fireresist', val: -32 },
|
|
]
|
|
|
|
// Run 100 cycles of alternating curse applications
|
|
for (let cycle = 0; cycle < 100; cycle++) {
|
|
const curse = curseSequence[cycle % curseSequence.length]!
|
|
|
|
const res = bus.applyState({
|
|
stateNameOrId: curse.name,
|
|
sourceSkillId: curse.id,
|
|
slvl: 1,
|
|
durationFrames: 300,
|
|
curseTypeOverride: 1,
|
|
stats: { [curse.statKey]: curse.val },
|
|
})
|
|
|
|
expect(res.applied).toBe(true)
|
|
|
|
// Invariant 1: Exactly 1 curse state exists in the stateBus
|
|
const activeCurses = bus.getActiveEntries().filter(s => s.curseType === 1)
|
|
expect(activeCurses.length).toBe(1)
|
|
expect(activeCurses[0]?.stateName).toBe(curse.name)
|
|
|
|
// Invariant 2: Current curse's stat modifier is active
|
|
expect(monsterStats.getAccruedStat(curse.statKey as any)).toBeDefined()
|
|
|
|
// Invariant 3: Overwritten curse stats do NOT leak into other stats
|
|
for (const other of curseSequence) {
|
|
if (other.name !== curse.name && other.statKey !== curse.statKey) {
|
|
// Modifiers from previous curses must be zero
|
|
expect(monsterStats.getModifierBonus(other.statKey as any)).toBe(0)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Target 6: Attract Overwrite Immunity (cursetype=2)
|
|
// =========================================================================
|
|
describe('Target 6: Attract Overwrite Immunity', () => {
|
|
it('verifies that an Attracted monster cannot have Attract overwritten by any curse', () => {
|
|
const monsterStats = new UnitStatList(registry, {
|
|
level: 85,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
maxhp: 1000 * FIXED_ONE,
|
|
damageresist: 0,
|
|
})
|
|
const bus = new StateBus(monsterStats, registry)
|
|
|
|
// Apply Attract (Skill 86, cursetype = 2)
|
|
const attractRes = bus.applyState({
|
|
stateNameOrId: 'attract',
|
|
sourceSkillId: 86,
|
|
slvl: 5,
|
|
durationFrames: 660,
|
|
curseTypeOverride: 2,
|
|
})
|
|
expect(attractRes.applied).toBe(true)
|
|
expect(bus.hasState('attract')).toBe(true)
|
|
|
|
// Attempt to cast competing curses against Attracted target
|
|
const competingCurses = [
|
|
{ id: 66, name: 'amplifydamage', statKey: 'damageresist', val: -100 },
|
|
{ id: 72, name: 'weaken', statKey: 'damagepercent', val: -33 },
|
|
{ id: 76, name: 'ironmaiden', statKey: 'ironmaiden_return_pct', val: 200 },
|
|
{ id: 82, name: 'lifetap', statKey: 'lifetap_leech_pct', val: 50 },
|
|
{ id: 87, name: 'decrepify', statKey: 'item_slow', val: 50 },
|
|
{ id: 91, name: 'lowerresist', statKey: 'fireresist', val: -32 },
|
|
{ id: 71, name: 'dimvision', statKey: 'item_blind', val: 1 },
|
|
{ id: 81, name: 'confuse', statKey: 'item_confuse', val: 1 },
|
|
{ id: 86, name: 'attract', statKey: 'attract', val: 1 }, // Re-casting Attract
|
|
]
|
|
|
|
for (const comp of competingCurses) {
|
|
const attempt = bus.applyState({
|
|
stateNameOrId: comp.name,
|
|
sourceSkillId: comp.id,
|
|
slvl: 10,
|
|
durationFrames: 500,
|
|
curseTypeOverride: comp.name === 'attract' ? 2 : 1,
|
|
stats: { [comp.statKey]: comp.val },
|
|
})
|
|
|
|
// Must be rejected due to Attract immunity
|
|
expect(attempt.applied).toBe(false)
|
|
expect(attempt.blockedByAttract).toBe(true)
|
|
|
|
// Attract state must remain active and undisturbed
|
|
expect(bus.hasState('attract')).toBe(true)
|
|
expect(bus.getState('attract')?.slvl).toBe(5)
|
|
|
|
// Zero stat contamination from the rejected curse
|
|
if (comp.statKey !== 'damageresist') {
|
|
expect(monsterStats.getModifierBonus(comp.statKey as any)).toBe(0)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Target 7: Lower Resist 1/5th Immunity Breaking Rule
|
|
// =========================================================================
|
|
describe('Target 7: Lower Resist Immunity Breaking Rule (1/5th Efficiency)', () => {
|
|
it('verifies Lower Resist slvl 20 (-63%) applies at 1/5th (12%) against elemental immunes', () => {
|
|
const lrSlvl20 = calculateLowerResistReduction(20)
|
|
expect(lrSlvl20).toBe(63) // 63% reduction
|
|
|
|
// Case 1: Non-immune (baseRes = 99%)
|
|
// Full reduction applies: 99 - 63 = 36%
|
|
const r99 = computeEffectiveResistance({
|
|
baseRes: 99,
|
|
lowerResistPierce: lrSlvl20,
|
|
})
|
|
expect(r99.convictionAndLRApplied).toBe(63)
|
|
expect(r99.resAfterConvictionLR).toBe(36)
|
|
expect(r99.isImmune).toBe(false)
|
|
expect(r99.effectiveRes).toBe(36)
|
|
|
|
// Case 2: Immune (baseRes = 100%)
|
|
// 1/5th rule: floor(63 / 5) = 12%
|
|
// 100 - 12 = 88% -> Broken!
|
|
const r100 = computeEffectiveResistance({
|
|
baseRes: 100,
|
|
lowerResistPierce: lrSlvl20,
|
|
})
|
|
expect(r100.convictionAndLRApplied).toBe(12)
|
|
expect(r100.resAfterConvictionLR).toBe(88)
|
|
expect(r100.isImmune).toBe(false)
|
|
expect(r100.effectiveRes).toBe(88)
|
|
|
|
// Case 3: Immune (baseRes = 110%)
|
|
// 110 - 12 = 98% -> Broken!
|
|
const r110 = computeEffectiveResistance({
|
|
baseRes: 110,
|
|
lowerResistPierce: lrSlvl20,
|
|
})
|
|
expect(r110.convictionAndLRApplied).toBe(12)
|
|
expect(r110.resAfterConvictionLR).toBe(98)
|
|
expect(r110.isImmune).toBe(false)
|
|
expect(r110.effectiveRes).toBe(98)
|
|
|
|
// Case 4: Immune unbreakable (baseRes = 120%)
|
|
// 120 - 12 = 108% >= 100% -> NOT Broken!
|
|
const r120 = computeEffectiveResistance({
|
|
baseRes: 120,
|
|
lowerResistPierce: lrSlvl20,
|
|
})
|
|
expect(r120.convictionAndLRApplied).toBe(12)
|
|
expect(r120.resAfterConvictionLR).toBe(108)
|
|
expect(r120.isImmune).toBe(true)
|
|
expect(r120.effectiveRes).toBe(100)
|
|
|
|
// Case 5: Conviction (-85%) + Lower Resist (-63%) stacking:
|
|
// Total pierce = 148% -> 1/5th = 29%
|
|
// Against baseRes = 125%: 125 - 29 = 96% -> Broken!
|
|
const rStackedBreak = computeEffectiveResistance({
|
|
baseRes: 125,
|
|
convictionPierce: 85,
|
|
lowerResistPierce: lrSlvl20,
|
|
})
|
|
expect(rStackedBreak.convictionAndLRApplied).toBe(29)
|
|
expect(rStackedBreak.resAfterConvictionLR).toBe(96)
|
|
expect(rStackedBreak.isImmune).toBe(false)
|
|
expect(rStackedBreak.effectiveRes).toBe(96)
|
|
|
|
// Case 6: Against baseRes = 130%: 130 - 29 = 101% -> Unbreakable!
|
|
const rStackedImmune = computeEffectiveResistance({
|
|
baseRes: 130,
|
|
convictionPierce: 85,
|
|
lowerResistPierce: lrSlvl20,
|
|
})
|
|
expect(rStackedImmune.convictionAndLRApplied).toBe(29)
|
|
expect(rStackedImmune.resAfterConvictionLR).toBe(101)
|
|
expect(rStackedImmune.isImmune).toBe(true)
|
|
expect(rStackedImmune.effectiveRes).toBe(100)
|
|
})
|
|
})
|
|
})
|