923 lines
37 KiB
TypeScript
923 lines
37 KiB
TypeScript
/**
|
|
* Adversarial Empirical Stress Testing Suite for Milestone 4:
|
|
* Sorceress Fire Combat Mechanics, Weapon Scaling, and Double-Dipping
|
|
*
|
|
* Target Systems:
|
|
* - Skill 52: Enchant (weapon fire damage buff, AR bonus, duration)
|
|
* - Skill 61: Fire Mastery (passive fire damage percentage scaling)
|
|
* - SUnitDmg Combat Pipeline (executeSUnitDmg, computeEffectiveResistance)
|
|
* - Combat Sandbox Simulation (CombatWorld, tickPlayer, tickCombat, tickCombatMulti)
|
|
*
|
|
* Key Verification Dimensions:
|
|
* 1. Ranged Weapon Penalty: strictly 33% of Enchant fire damage bonus on ranged weapons (bow/crossbow/throwing)
|
|
* vs 100% on melee weapons.
|
|
* 2. Sorceress Melee Double-Dip:
|
|
* - Cast time: Enchant damage scaled by Fire Mastery (+163% at slvl 20 -> 2.63x).
|
|
* - Melee strike: Sorceress applies Fire Mastery (+163% -> 2.63x) again upon hit: base * (1 + 1.63)^2 = 473.8 - 619.1!
|
|
* - Ranged strike: Sorceress attacks at range -> second Fire Mastery is NOT applied.
|
|
* 3. Non-Sorceress Buff Recipient (Allies / Pets):
|
|
* - Allies receive single-multiplied buff damage on melee (no double-dip).
|
|
* - Allies receive 33% on ranged.
|
|
* 4. Fire Immunity Preservation:
|
|
* - Target Fire Resist = 100%, 110%, 150%: strictly 0 fire damage dealt.
|
|
* - Confirms Fire Mastery has zero resistance pierce and cannot break immunities.
|
|
* - Target Fire Resist = 50% and 0%: verified exact proportional mitigation.
|
|
* 5. Resistance Format Interoperability & Lifecycle Boundary Edge Cases.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
calculateEnchantDamage,
|
|
calculateEnchantAttackRating,
|
|
calculateEnchantDuration,
|
|
calculateFireMasteryBonus,
|
|
calculateEnchantAttackDamage,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
createWorld,
|
|
addPlayer,
|
|
tickCombat,
|
|
applyEnchantBuff,
|
|
type CombatPlayer,
|
|
type CombatWorld,
|
|
type Monster,
|
|
type CombatOptions,
|
|
type CombatTerrain,
|
|
} from '../../../src/game/combat.ts'
|
|
import {
|
|
executeSUnitDmg,
|
|
computeEffectiveResistance,
|
|
type CombatUnitContext,
|
|
type SUnitDmgPacket,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
|
|
/** Helper to create a fully initialized CombatUnitContext */
|
|
function createCombatUnit(
|
|
id: string,
|
|
options?: {
|
|
hp?: number
|
|
isSorceress?: boolean
|
|
fireMasterySlvl?: number
|
|
fireResist?: number
|
|
hasEnchantState?: boolean
|
|
},
|
|
): CombatUnitContext {
|
|
const statList = new UnitStatList()
|
|
const hp = options?.hp ?? 1000
|
|
statList.setBaseStat('hp', hp * FIXED_ONE)
|
|
statList.setBaseStat('maxhp', hp * FIXED_ONE)
|
|
statList.setBaseStat('hitpoints', hp * FIXED_ONE)
|
|
statList.setBaseStat('level', 80)
|
|
statList.setBaseStat('tohit', 10000) // guarantee 95% or autohit
|
|
statList.setBaseStat('armorclass', 100)
|
|
|
|
if (options?.fireMasterySlvl && options.fireMasterySlvl > 0) {
|
|
statList.setBaseSkillLevel(61, options.fireMasterySlvl)
|
|
}
|
|
|
|
if (options?.fireResist !== undefined) {
|
|
statList.setBaseStat('fireresist', options.fireResist)
|
|
}
|
|
|
|
const stateBus = new StateBus(statList)
|
|
if (options?.hasEnchantState) {
|
|
stateBus.applyState({ stateNameOrId: 'enchant', durationFrames: 15000 })
|
|
}
|
|
|
|
return {
|
|
id,
|
|
name: id,
|
|
statList,
|
|
stateBus,
|
|
weaponMinPhys: 10,
|
|
weaponMaxPhys: 10,
|
|
}
|
|
}
|
|
|
|
/** Flat mock terrain */
|
|
const mockTerrain: CombatTerrain = {
|
|
overlap: () => 0,
|
|
}
|
|
|
|
/** Standard combat options for deterministic tests */
|
|
const baseOptions: CombatOptions = {
|
|
playerSpeed: 100,
|
|
playerReach: 50,
|
|
playerCooldownTicks: 10,
|
|
playerDamage: 10, // Base physical damage = 10
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 100,
|
|
}
|
|
|
|
describe('Milestone 4 Adversarial Empirical Stress Test Suite (Combat, Scaling, Double-Dip)', () => {
|
|
// ==========================================================================
|
|
// Suite 1: Ranged Weapon Penalty (33% on Ranged vs 100% on Melee)
|
|
// ==========================================================================
|
|
describe('Suite 1: Ranged Weapon Penalty (33% Ranged vs 100% Melee)', () => {
|
|
it('verifies calculateEnchantAttackDamage strictly scales ranged attacks by 33% across slvl 1..40', () => {
|
|
for (const slvl of [1, 5, 10, 15, 20, 25, 30, 35, 40]) {
|
|
const baseDmg = calculateEnchantDamage(slvl)
|
|
|
|
// Melee non-sorceress attack (100% buff damage)
|
|
const meleeRes = calculateEnchantAttackDamage({
|
|
enchantDamage: baseDmg,
|
|
isRanged: false,
|
|
isSorceress: false,
|
|
})
|
|
expect(meleeRes.min).toBe(baseDmg.min)
|
|
expect(meleeRes.max).toBe(baseDmg.max)
|
|
|
|
// Ranged non-sorceress attack (strictly 33% of buff damage)
|
|
const rangedRes = calculateEnchantAttackDamage({
|
|
enchantDamage: baseDmg,
|
|
isRanged: true,
|
|
isSorceress: false,
|
|
})
|
|
|
|
const expectedRangedMin = Math.round(baseDmg.min * 0.33 * 10) / 10
|
|
const expectedRangedMax = Math.round(baseDmg.max * 0.33 * 10) / 10
|
|
|
|
expect(rangedRes.min).toBe(expectedRangedMin)
|
|
expect(rangedRes.max).toBe(expectedRangedMax)
|
|
expect(rangedRes.average).toBe(Math.round(((expectedRangedMin + expectedRangedMax) / 2) * 10) / 10)
|
|
|
|
// Verify ratio is within rounding precision of 33%
|
|
const ratio = rangedRes.average / meleeRes.average
|
|
expect(ratio).toBeGreaterThan(0.32)
|
|
expect(ratio).toBeLessThan(0.34)
|
|
}
|
|
})
|
|
|
|
it('verifies combat pipeline executeSUnitDmg applies 33% penalty to missile fire damage while physical damage is untouched', () => {
|
|
const attacker = createCombatUnit('attacker_unit', { hasEnchantState: true })
|
|
const defender = createCombatUnit('defender_unit', { fireResist: 0 })
|
|
|
|
// Base Enchant fire damage in 256ths: 100.0 fire damage = 25600
|
|
const enchantFire256 = 100 * FIXED_ONE
|
|
|
|
// 1. Melee attack: full 100% fire damage
|
|
const meleePacket: SUnitDmgPacket = {
|
|
skillId: 52,
|
|
attackKind: 'melee',
|
|
isEnchant: true,
|
|
elemType: 'fire',
|
|
elemMin256: enchantFire256,
|
|
elemMax256: enchantFire256,
|
|
flatPhysMin256: 0,
|
|
flatPhysMax256: 0,
|
|
autoHit: true,
|
|
}
|
|
const meleeOutcome = executeSUnitDmg(attacker, defender, meleePacket)
|
|
expect(meleeOutcome.hit).toBe(true)
|
|
expect(meleeOutcome.physDamage256).toBe(10 * FIXED_ONE)
|
|
expect(meleeOutcome.elemDamage256).toBe(enchantFire256)
|
|
|
|
// 2. Missile attack: strictly 33% fire damage, physical damage remains 10 * FIXED_ONE
|
|
const rangedPacket: SUnitDmgPacket = {
|
|
skillId: 52,
|
|
attackKind: 'missile',
|
|
isEnchant: true,
|
|
elemType: 'fire',
|
|
elemMin256: enchantFire256,
|
|
elemMax256: enchantFire256,
|
|
flatPhysMin256: 0,
|
|
flatPhysMax256: 0,
|
|
autoHit: true,
|
|
}
|
|
const rangedOutcome = executeSUnitDmg(attacker, defender, rangedPacket)
|
|
expect(rangedOutcome.hit).toBe(true)
|
|
// Physical damage is completely unaffected by the 33% elemental ranged penalty!
|
|
expect(rangedOutcome.physDamage256).toBe(10 * FIXED_ONE)
|
|
// Elemental fire damage is strictly Math.trunc(25600 * 33 / 100) = 8448 (= 33.0 fire damage)
|
|
const expectedRangedFire256 = Math.trunc((enchantFire256 * 33) / 100)
|
|
expect(rangedOutcome.elemDamage256).toBe(expectedRangedFire256)
|
|
expect(rangedOutcome.elemDamage256 / FIXED_ONE).toBe(33)
|
|
})
|
|
|
|
it('verifies CombatWorld sandbox tickPlayer correctly differentiates melee (100%) vs ranged (33%) weapons', () => {
|
|
const world: CombatWorld = createWorld(100, 100)
|
|
const targetMonster: Monster = {
|
|
index: 0,
|
|
stats: {
|
|
id: 'test_zombie',
|
|
name: 'Zombie',
|
|
hp: 2000,
|
|
damage: 0,
|
|
cooldownTicks: 25,
|
|
reach: 20,
|
|
aggroRadius: 0,
|
|
speed: 0,
|
|
xp: 50,
|
|
},
|
|
x: 120,
|
|
y: 100,
|
|
hp: 2000,
|
|
cooldown: 0,
|
|
state: 'idle',
|
|
facing: 0,
|
|
hitFlash: 0,
|
|
corpseTicks: 0,
|
|
}
|
|
world.monsters.push(targetMonster)
|
|
|
|
// 1. Melee attack with Enchant slvl 20 (base 68.5 - 89.5, average 79)
|
|
const baseDmg20 = calculateEnchantDamage(20)
|
|
world.player.enchantTicks = 5000
|
|
world.player.enchantDamage = { min: baseDmg20.min, max: baseDmg20.max }
|
|
world.player.isRanged = false
|
|
world.player.isSorceress = false // non-sorc for pure 100% vs 33% check
|
|
|
|
const prevHp = targetMonster.hp
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
|
|
const meleeHitEvent = world.events.find(e => e.kind === 'monsterHit')
|
|
expect(meleeHitEvent).toBeDefined()
|
|
// total = baseOptions.playerDamage (10) + Math.round(79) = 89
|
|
const meleeTotalDamage = meleeHitEvent!.amount!
|
|
expect(meleeTotalDamage).toBe(10 + Math.round((baseDmg20.min + baseDmg20.max) / 2))
|
|
expect(targetMonster.hp).toBe(prevHp - meleeTotalDamage)
|
|
|
|
// Reset cooldown and switch to ranged weapon (bow/throwing)
|
|
world.player.cooldown = 0
|
|
world.player.isRanged = true
|
|
const beforeRangedHp = targetMonster.hp
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
|
|
const rangedHitEvent = world.events.find(e => e.kind === 'monsterHit')
|
|
expect(rangedHitEvent).toBeDefined()
|
|
const rangedTotalDamage = rangedHitEvent!.amount!
|
|
|
|
const expectedRangedFire = Math.round(calculateEnchantAttackDamage({
|
|
enchantDamage: baseDmg20,
|
|
isRanged: true,
|
|
isSorceress: false,
|
|
}).average)
|
|
|
|
expect(rangedTotalDamage).toBe(10 + expectedRangedFire)
|
|
expect(targetMonster.hp).toBe(beforeRangedHp - rangedTotalDamage)
|
|
|
|
// Confirm Enchant fire component on ranged is strictly 33% of melee fire component
|
|
const meleeFireComponent = meleeTotalDamage - 10
|
|
const rangedFireComponent = rangedTotalDamage - 10
|
|
expect(rangedFireComponent / meleeFireComponent).toBeCloseTo(0.33, 1)
|
|
})
|
|
|
|
it('verifies dynamic weapon swapping mid-buff alternates between 100% melee double-dip and 33% ranged penalty', () => {
|
|
const world: CombatWorld = createWorld(100, 100)
|
|
const dummy: Monster = {
|
|
index: 0,
|
|
stats: { id: 'd', name: 'D', hp: 10000, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 10 },
|
|
x: 120, y: 100, hp: 10000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
}
|
|
world.monsters.push(dummy)
|
|
|
|
applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 })
|
|
world.player.isSorceress = true
|
|
world.player.fireMasterySlvl = 20
|
|
|
|
// Strike 1: Melee -> deals 10 physical + 547 fire = 557
|
|
world.player.isRanged = false
|
|
world.player.cooldown = 0
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(557)
|
|
|
|
// Strike 2: Swap to bow (isRanged: true) -> deals 10 physical + 69 fire = 79
|
|
world.events = []
|
|
world.player.isRanged = true
|
|
world.player.cooldown = 0
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(79)
|
|
|
|
// Strike 3: Swap back to melee weapon -> deals 557 again
|
|
world.events = []
|
|
world.player.isRanged = false
|
|
world.player.cooldown = 0
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(557)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 2: Sorceress Melee Double-Dip Mechanics
|
|
// ==========================================================================
|
|
describe('Suite 2: Sorceress Melee Double-Dip Mechanics', () => {
|
|
it('verifies exact mathematical double-dip formula at slvl 20: base * (1 + 1.63)^2 = 473.8 - 619.1 fire damage', () => {
|
|
const slvl = 20
|
|
const fmSlvl = 20
|
|
const fmBonus = calculateFireMasteryBonus(fmSlvl) // +163%
|
|
expect(fmBonus).toBe(163)
|
|
|
|
const fmMultiplier = 1.0 + fmBonus / 100 // 2.63
|
|
|
|
// Base Enchant slvl 20 damage (no FM): 68.5 - 89.5
|
|
const baseDmg = calculateEnchantDamage(slvl)
|
|
expect(baseDmg.min).toBe(68.5)
|
|
expect(baseDmg.max).toBe(89.5)
|
|
|
|
// 1. Cast phase: Enchant damage is amplified by Fire Mastery upon casting
|
|
const castDmg = calculateEnchantDamage(slvl, undefined, fmSlvl)
|
|
expect(castDmg.min).toBe(180.2) // Math.round(68.5 * 2.63 * 10) / 10 = 180.2
|
|
expect(castDmg.max).toBe(235.4) // Math.round(89.5 * 2.63 * 10) / 10 = 235.4
|
|
|
|
// 2. Melee strike phase: Sorceress hits in melee, applying Fire Mastery (+163%) AGAIN
|
|
const meleeStrike = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: fmSlvl,
|
|
})
|
|
|
|
// Exact double-dip bounds:
|
|
// min = Math.round(180.2 * 2.63 * 10) / 10 = 473.9
|
|
// max = Math.round(235.4 * 2.63 * 10) / 10 = 619.1
|
|
expect(meleeStrike.min).toBe(473.9)
|
|
expect(meleeStrike.max).toBe(619.1)
|
|
expect(meleeStrike.average).toBe(546.5)
|
|
|
|
// Raw unrounded base * (1 + 1.63)^2:
|
|
// 68.5 * 2.63^2 = 473.80775
|
|
// 89.5 * 2.63^2 = 619.06275
|
|
expect(meleeStrike.min).toBeCloseTo(baseDmg.min * fmMultiplier * fmMultiplier, 0)
|
|
expect(meleeStrike.max).toBeCloseTo(baseDmg.max * fmMultiplier * fmMultiplier, 0)
|
|
})
|
|
|
|
it('verifies Sorceress attacking at range strictly does NOT receive the second Fire Mastery application', () => {
|
|
const slvl = 20
|
|
const fmSlvl = 20
|
|
const castDmg = calculateEnchantDamage(slvl, undefined, fmSlvl) // 180.2 - 235.4
|
|
|
|
const rangedStrike = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: true,
|
|
isSorceress: true,
|
|
fireMasterySlvl: fmSlvl,
|
|
})
|
|
|
|
// Strictly 33% of cast damage:
|
|
// min = Math.round(180.2 * 0.33 * 10) / 10 = 59.5
|
|
// max = Math.round(235.4 * 0.33 * 10) / 10 = 77.7
|
|
expect(rangedStrike.min).toBe(59.5)
|
|
expect(rangedStrike.max).toBe(77.7)
|
|
expect(rangedStrike.average).toBe(68.6)
|
|
|
|
// Prove that if second FM had been erroneously applied, it would have been:
|
|
// 473.9 * 0.33 = 156.4, which is 2.63x higher!
|
|
expect(rangedStrike.min).toBeLessThan(100)
|
|
expect(rangedStrike.max).toBeLessThan(100)
|
|
})
|
|
|
|
it('verifies double-dip scaling across full Fire Mastery progression (slvl 1, 10, 20, 30, 40)', () => {
|
|
for (const fmSlvl of [1, 10, 20, 30, 40]) {
|
|
const fmBonus = calculateFireMasteryBonus(fmSlvl)
|
|
const fmMul = 1.0 + fmBonus / 100
|
|
const expectedDoubleMultiplier = fmMul * fmMul
|
|
|
|
const baseDmg = calculateEnchantDamage(20) // base 68.5 - 89.5
|
|
const castDmg = calculateEnchantDamage(20, undefined, fmSlvl)
|
|
|
|
const meleeHit = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: fmSlvl,
|
|
})
|
|
|
|
// Verify that melee damage scales as (1 + fmBonus/100)^2
|
|
const actualRatioMin = meleeHit.min / baseDmg.min
|
|
const actualRatioMax = meleeHit.max / baseDmg.max
|
|
|
|
expect(actualRatioMin).toBeCloseTo(expectedDoubleMultiplier, 1)
|
|
expect(actualRatioMax).toBeCloseTo(expectedDoubleMultiplier, 1)
|
|
}
|
|
})
|
|
|
|
it('verifies interaction of Warmth synergy (+180% = 2.8x) and Fire Mastery double-dip ((2.63)^2 = 6.9169x)', () => {
|
|
// Warmth 20 hard points (+180%), Fire Mastery 20 (+163%)
|
|
const warmthPts = 20
|
|
const fmSlvl = 20
|
|
const baseDmg = calculateEnchantDamage(20) // 68.5 - 89.5
|
|
|
|
const buffedCastDmg = calculateEnchantDamage(20, { warmth: warmthPts }, fmSlvl)
|
|
// Warmth multiplier = 1 + 20*0.09 = 2.8
|
|
// FM multiplier = 1 + 1.63 = 2.63
|
|
// Total cast multiplier = 2.8 * 2.63 = 7.364
|
|
// Min: 68.5 * 7.364 = 504.434 -> 504.4
|
|
// Max: 89.5 * 7.364 = 659.078 -> 659.1
|
|
expect(buffedCastDmg.min).toBe(504.4)
|
|
expect(buffedCastDmg.max).toBe(659.1)
|
|
|
|
// Melee double dip: applies FM 2.63 again!
|
|
// Total multiplier = 2.8 * (2.63)^2 = 19.36732x!
|
|
const meleeHit = calculateEnchantAttackDamage({
|
|
enchantDamage: buffedCastDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: fmSlvl,
|
|
})
|
|
|
|
// Min: 504.4 * 2.63 = 1326.572 -> 1326.6
|
|
// Max: 659.1 * 2.63 = 1733.433 -> 1733.4
|
|
expect(meleeHit.min).toBe(1326.6)
|
|
expect(meleeHit.max).toBe(1733.4)
|
|
expect(meleeHit.average).toBe(1530.0)
|
|
|
|
// Ranged hit: 33% penalty and NO second FM
|
|
const rangedHit = calculateEnchantAttackDamage({
|
|
enchantDamage: buffedCastDmg,
|
|
isRanged: true,
|
|
isSorceress: true,
|
|
fireMasterySlvl: fmSlvl,
|
|
})
|
|
expect(rangedHit.min).toBe(166.5) // 504.4 * 0.33 = 166.452 -> 166.5
|
|
expect(rangedHit.max).toBe(217.5) // 659.1 * 0.33 = 217.503 -> 217.5
|
|
expect(rangedHit.average).toBe(192.0)
|
|
})
|
|
|
|
it('verifies extreme skill level scaling (slvl 60 Enchant + slvl 60 Fire Mastery) maintains numerical stability', () => {
|
|
const slvl = 60
|
|
const fmBonus = calculateFireMasteryBonus(60) // 30 + 59 * 7 = 443% -> 5.43x
|
|
expect(fmBonus).toBe(443)
|
|
|
|
const baseDmg = calculateEnchantDamage(60)
|
|
expect(baseDmg.min).toBeGreaterThan(0)
|
|
expect(baseDmg.max).toBeGreaterThan(baseDmg.min)
|
|
expect(Number.isFinite(baseDmg.min)).toBe(true)
|
|
|
|
const castDmg = calculateEnchantDamage(60, undefined, 60)
|
|
expect(castDmg.min).toBeCloseTo(baseDmg.min * 5.43, 0)
|
|
|
|
const meleeDmg = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 60,
|
|
})
|
|
// Double dip = base * (5.43)^2
|
|
expect(meleeDmg.min).toBeCloseTo(baseDmg.min * 5.43 * 5.43, 0)
|
|
expect(Number.isFinite(meleeDmg.average)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 3: Non-Sorceress Buff Recipient (Allies / Pets)
|
|
// ==========================================================================
|
|
describe('Suite 3: Non-Sorceress Buff Recipient (Allies / Pets)', () => {
|
|
it('verifies non-Sorceress melee ally receives single-multiplied buff damage without second Fire Mastery', () => {
|
|
// Sorceress with slvl 20 Enchant and slvl 20 Fire Mastery casts on an ally
|
|
const castDmg = calculateEnchantDamage(20, undefined, 20) // 180.2 - 235.4
|
|
|
|
// Ally (isSorceress: false, no Fire Mastery) strikes in melee
|
|
const allyMelee = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: false,
|
|
})
|
|
|
|
// Damage must match the single-multiplied cast buff exactly:
|
|
expect(allyMelee.min).toBe(180.2)
|
|
expect(allyMelee.max).toBe(235.4)
|
|
expect(allyMelee.average).toBe(207.8)
|
|
|
|
// Contrast with Sorceress attacking:
|
|
const sorcMelee = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
})
|
|
expect(sorcMelee.average).toBe(546.5)
|
|
expect(sorcMelee.average).toBeCloseTo(allyMelee.average * 2.63, 0)
|
|
})
|
|
|
|
it('verifies non-Sorceress ranged ally receives strictly 33% of single-multiplied buff damage', () => {
|
|
const castDmg = calculateEnchantDamage(20, undefined, 20) // 180.2 - 235.4
|
|
|
|
// Rogue mercenary or bow Amazon strikes at range
|
|
const allyRanged = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: true,
|
|
isSorceress: false,
|
|
})
|
|
|
|
expect(allyRanged.min).toBe(59.5) // 180.2 * 0.33 = 59.466 -> 59.5
|
|
expect(allyRanged.max).toBe(77.7) // 235.4 * 0.33 = 77.682 -> 77.7
|
|
expect(allyRanged.average).toBe(68.6)
|
|
})
|
|
|
|
it('verifies Party Sorceress 2 with lower Fire Mastery applies her own Fire Mastery upon melee strike', () => {
|
|
// Sorc 1 (slvl 20 Enchant, slvl 20 FM) casts on Sorc 2 (who has slvl 10 FM = +93% / 1.93x)
|
|
const castDmg = calculateEnchantDamage(20, undefined, 20) // 180.2 - 235.4
|
|
|
|
// Sorc 2 attacks in melee with her own FM slvl 10:
|
|
const sorc2Melee = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 10,
|
|
})
|
|
|
|
// Min: 180.2 * 1.93 = 347.786 -> 347.8
|
|
// Max: 235.4 * 1.93 = 454.322 -> 454.3
|
|
expect(sorc2Melee.min).toBe(347.8)
|
|
expect(sorc2Melee.max).toBe(454.3)
|
|
expect(sorc2Melee.average).toBe(401.1)
|
|
|
|
// When Sorc 2 attacks at range, her FM is not applied, strictly 33% of cast damage:
|
|
const sorc2Ranged = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: true,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 10,
|
|
})
|
|
expect(sorc2Ranged.min).toBe(59.5)
|
|
expect(sorc2Ranged.max).toBe(77.7)
|
|
})
|
|
|
|
it('verifies CombatWorld multi-player sandbox: Sorceress vs Barbarian ally dealing Enchant damage', () => {
|
|
const world: CombatWorld = createWorld(100, 100) // Player 0: Sorceress
|
|
const barbarianAlly = addPlayer(world, 105, 100) // Player 1: Barbarian
|
|
|
|
const castDmg = calculateEnchantDamage(20, undefined, 20) // 180.2 - 235.4
|
|
|
|
// Apply buff to both
|
|
applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 })
|
|
world.player.isSorceress = true
|
|
world.player.fireMasterySlvl = 20
|
|
world.player.isRanged = false
|
|
|
|
applyEnchantBuff(barbarianAlly, 20, { casterFireMasterySlvl: 20 })
|
|
barbarianAlly.isSorceress = false
|
|
barbarianAlly.fireMasterySlvl = 0
|
|
barbarianAlly.isRanged = false
|
|
|
|
// Spawn 2 target dummies
|
|
const dummySorc: Monster = {
|
|
index: 0,
|
|
stats: { id: 'dummy_s', name: 'Dummy Sorc', hp: 2000, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 10 },
|
|
x: 120, y: 100, hp: 2000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
}
|
|
const dummyBarb: Monster = {
|
|
index: 1,
|
|
stats: { id: 'dummy_b', name: 'Dummy Barb', hp: 2000, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 10 },
|
|
x: 105, y: 120, hp: 2000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
}
|
|
world.monsters.push(dummySorc, dummyBarb)
|
|
|
|
// Tick player 0 (Sorceress melee attack)
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
const sorcEvent = world.events.find(e => e.kind === 'monsterHit')
|
|
expect(sorcEvent).toBeDefined()
|
|
// Sorc damage = base (10) + double-dip fire average (546.5 -> 547) = 557
|
|
expect(sorcEvent!.amount).toBe(10 + Math.round(546.5))
|
|
|
|
// Now tick barbarian ally melee attack
|
|
world.events = []
|
|
// Let barbarian attack
|
|
barbarianAlly.cooldown = 0
|
|
// We simulate barbarian's tick via executeSUnitDmg directly or checking calculation
|
|
const barbHit = calculateEnchantAttackDamage({
|
|
enchantDamage: barbarianAlly.enchantDamage!,
|
|
isRanged: barbarianAlly.isRanged,
|
|
isSorceress: barbarianAlly.isSorceress,
|
|
fireMasterySlvl: barbarianAlly.fireMasterySlvl,
|
|
})
|
|
// Barbarian damage is single-dipped: 207.8 -> 208
|
|
expect(barbHit.average).toBe(207.8)
|
|
expect(sorcEvent!.amount! - 10).toBeGreaterThan(barbHit.average * 2.5)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 4: Fire Immunity Preservation & Resistance Handling
|
|
// ==========================================================================
|
|
describe('Suite 4: Fire Immunity Preservation & Resistance Handling', () => {
|
|
it('strictly preserves fire immunity at 100%, 110%, and 150% Fire Resist (0 fire damage dealt)', () => {
|
|
const castDmg = calculateEnchantDamage(20, undefined, 20)
|
|
|
|
for (const res of [100, 110, 125, 150, 200]) {
|
|
// Melee Sorceress with slvl 20 Fire Mastery vs immune monster
|
|
const immuneMelee = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: res,
|
|
})
|
|
expect(immuneMelee.min).toBe(0)
|
|
expect(immuneMelee.max).toBe(0)
|
|
expect(immuneMelee.average).toBe(0)
|
|
|
|
// Ranged Sorceress vs immune monster
|
|
const immuneRanged = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: true,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: res,
|
|
})
|
|
expect(immuneRanged.min).toBe(0)
|
|
expect(immuneRanged.max).toBe(0)
|
|
expect(immuneRanged.average).toBe(0)
|
|
|
|
// Extreme Fire Mastery slvl 40 (+303%) vs immune monster
|
|
const extremeFmMelee = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 40,
|
|
targetFireResist: res,
|
|
})
|
|
expect(extremeFmMelee.min).toBe(0)
|
|
expect(extremeFmMelee.max).toBe(0)
|
|
expect(extremeFmMelee.average).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('verifies combat pipeline executeSUnitDmg flags immuneToElem = true and elemDamage256 = 0 for res >= 100', () => {
|
|
const attacker = createCombatUnit('attacker_fm40', {
|
|
fireMasterySlvl: 40, // +303% FM!
|
|
hasEnchantState: true,
|
|
})
|
|
|
|
for (const res of [100, 110, 150]) {
|
|
const defender = createCombatUnit(`defender_res_${res}`, { fireResist: res })
|
|
const packet: SUnitDmgPacket = {
|
|
skillId: 52,
|
|
attackKind: 'melee',
|
|
isEnchant: true,
|
|
elemType: 'fire',
|
|
elemMin256: 50000,
|
|
elemMax256: 50000,
|
|
flatPhysMin256: 0,
|
|
flatPhysMax256: 0,
|
|
autoHit: true,
|
|
}
|
|
|
|
const outcome = executeSUnitDmg(attacker, defender, packet)
|
|
expect(outcome.hit).toBe(true)
|
|
expect(outcome.immuneToElem).toBe(true)
|
|
expect(outcome.elemDamage256).toBe(0)
|
|
// Physical damage is applied normally (weapon base 10)
|
|
expect(outcome.physDamage256).toBe(10 * FIXED_ONE)
|
|
expect(outcome.totalDamage).toBe(10)
|
|
}
|
|
})
|
|
|
|
it('verifies proportional mitigation at 50% and 0% Fire Resist without floor clamp violations', () => {
|
|
const castDmg = calculateEnchantDamage(20, undefined, 20)
|
|
|
|
// 0% Fire Resist: 100% damage unmitigated
|
|
const res0 = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: 0,
|
|
})
|
|
expect(res0.min).toBe(473.9)
|
|
expect(res0.max).toBe(619.1)
|
|
expect(res0.average).toBe(546.5)
|
|
|
|
// 50% Fire Resist: exactly 50% reduction
|
|
const res50 = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: 50,
|
|
})
|
|
expect(res50.min).toBe(Math.round(473.9 * 0.5 * 10) / 10) // 237.0
|
|
expect(res50.max).toBe(Math.round(619.1 * 0.5 * 10) / 10) // 309.6
|
|
expect(res50.average).toBe(Math.round(546.5 * 0.5 * 10) / 10) // 273.3
|
|
|
|
// 75% Fire Resist: exactly 75% reduction (25% remaining)
|
|
const res75 = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: 75,
|
|
})
|
|
expect(res75.min).toBe(118.5)
|
|
expect(res75.max).toBe(154.8)
|
|
expect(res75.average).toBe(136.7)
|
|
|
|
// 99% Fire Resist: non-immune boundary, exactly 1% remaining
|
|
const res99 = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: 99,
|
|
})
|
|
expect(res99.min).toBeGreaterThan(0)
|
|
expect(res99.average).toBe(Math.round(546.5 * 0.01 * 10) / 10) // 5.5
|
|
})
|
|
|
|
it('verifies computeEffectiveResistance 1.13c immunity breaking and -100% floor clamp rules', () => {
|
|
// 1. Natural immunity without Conviction / Lower Resist cannot be broken by passive pierce
|
|
const immuneRes = computeEffectiveResistance({
|
|
baseRes: 100,
|
|
passiveEnemyPierce: 50, // e.g. facets / gear
|
|
})
|
|
expect(immuneRes.isImmune).toBe(true)
|
|
expect(immuneRes.effectiveRes).toBe(100)
|
|
|
|
// 2. Negative resistance cannot go below authentic 1.13c -100% cap
|
|
const extremeNegRes = computeEffectiveResistance({
|
|
baseRes: -80,
|
|
convictionPierce: 50,
|
|
})
|
|
// -80 - 50 = -130, clamped to -100!
|
|
expect(extremeNegRes.effectiveRes).toBe(-100)
|
|
})
|
|
|
|
it('verifies CombatWorld sandbox tickCombat against immune monster (res=100) deals only physical damage', () => {
|
|
const world: CombatWorld = createWorld(100, 100)
|
|
const immuneMonster: Monster = {
|
|
index: 0,
|
|
stats: { id: 'fire_immune_pitlord', name: 'Pit Lord', hp: 500, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 50 },
|
|
x: 120, y: 100, hp: 500, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
}
|
|
;(immuneMonster as any).fireResist = 100 // Set fire resist to 100%
|
|
world.monsters.push(immuneMonster)
|
|
|
|
// Buff player with massive Enchant + Fire Mastery
|
|
applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 })
|
|
world.player.isSorceress = true
|
|
world.player.fireMasterySlvl = 20
|
|
world.player.isRanged = false
|
|
|
|
// Player attacks
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
|
|
const hitEvent = world.events.find(e => e.kind === 'monsterHit')
|
|
expect(hitEvent).toBeDefined()
|
|
// Because monster is fire immune, only baseOptions.playerDamage (10) is dealt!
|
|
expect(hitEvent!.amount).toBe(10)
|
|
expect(immuneMonster.hp).toBe(490)
|
|
})
|
|
|
|
it('ADVERSARIAL GAP CHECK: verifies whether tickPlayer handles monster.stats.resistances?.fire', () => {
|
|
const world: CombatWorld = createWorld(100, 100)
|
|
const monsterWithResistancesBag: Monster = {
|
|
index: 0,
|
|
stats: {
|
|
id: 'typed_res_monster',
|
|
name: 'Resistant Monster',
|
|
hp: 500,
|
|
damage: 0,
|
|
cooldownTicks: 25,
|
|
reach: 20,
|
|
aggroRadius: 0,
|
|
speed: 0,
|
|
xp: 50,
|
|
resistances: { fire: 100 },
|
|
resists: { fire: 100 },
|
|
},
|
|
x: 120, y: 100, hp: 500, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
}
|
|
world.monsters.push(monsterWithResistancesBag)
|
|
|
|
applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 })
|
|
world.player.isSorceress = true
|
|
world.player.fireMasterySlvl = 20
|
|
world.player.isRanged = false
|
|
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
const hitEvent = world.events.find(e => e.kind === 'monsterHit')
|
|
expect(hitEvent).toBeDefined()
|
|
|
|
// In combat.ts line 895: targetFireResist: (target as any).fireResist ?? (target.stats as any).fireResist
|
|
// Because target.stats.resistances?.fire is NOT inspected, targetFireResist resolves to undefined!
|
|
// This results in full unmitigated fire damage (557 instead of 10):
|
|
const actualDmg = hitEvent!.amount!
|
|
expect(actualDmg).toBe(557) // EMPIRICAL PROOF OF GAP: resistances bag ignored by combat.ts:895
|
|
})
|
|
|
|
it('proves empirically that Fire Mastery provides 0% enemy fire resistance pierce (unlike Cold Mastery)', () => {
|
|
// Monster with 50% fire resist
|
|
const castDmg = calculateEnchantDamage(20, undefined, 20)
|
|
|
|
// With FM slvl 20 (+163% damage bonus)
|
|
const withFm = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: 50,
|
|
})
|
|
|
|
// Without FM (e.g. ally)
|
|
const withoutFm = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: false,
|
|
targetFireResist: 50,
|
|
})
|
|
|
|
// Unresisted baselines
|
|
const unresistedWithFm = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: true,
|
|
fireMasterySlvl: 20,
|
|
targetFireResist: 0,
|
|
})
|
|
const unresistedWithoutFm = calculateEnchantAttackDamage({
|
|
enchantDamage: castDmg,
|
|
isRanged: false,
|
|
isSorceress: false,
|
|
targetFireResist: 0,
|
|
})
|
|
|
|
// The damage reduction ratio for both is EXACTLY (100 - 50)/100 = 0.5!
|
|
// This proves Fire Mastery does not pierce enemy resistance by even 1%!
|
|
expect(withFm.average / unresistedWithFm.average).toBeCloseTo(0.5, 2)
|
|
expect(withoutFm.average / unresistedWithoutFm.average).toBeCloseTo(0.5, 2)
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Suite 5: Adversarial Boundary & Lifecycle Stress Tests
|
|
// ==========================================================================
|
|
describe('Suite 5: Adversarial Boundary & Lifecycle Stress Tests', () => {
|
|
it('verifies buff expiration countdown: damage drops to 0 fire bonus after enchantTicks reaches 0', () => {
|
|
const world: CombatWorld = createWorld(100, 100)
|
|
const monster: Monster = {
|
|
index: 0,
|
|
stats: { id: 'dummy', name: 'Dummy', hp: 2000, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 50 },
|
|
x: 120, y: 100, hp: 2000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
}
|
|
world.monsters.push(monster)
|
|
|
|
applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 })
|
|
world.player.isSorceress = true
|
|
world.player.fireMasterySlvl = 20
|
|
// Set ticks to exactly 2 ticks left
|
|
world.player.enchantTicks = 2
|
|
|
|
// Tick 1: buff still active (enchantTicks decreases to 1)
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(10 + Math.round(546.5))
|
|
expect(world.player.enchantTicks).toBe(1)
|
|
|
|
// Tick 2: buff expires (enchantTicks decrements to 0, enchantDamage cleared)
|
|
world.player.cooldown = 0
|
|
world.events = []
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: false }, baseOptions, mockTerrain, [0, 100])
|
|
expect(world.player.enchantTicks).toBe(0)
|
|
expect(world.player.enchantDamage).toBeUndefined()
|
|
|
|
// Tick 3: player attacks with expired buff -> strictly physical damage only (10)
|
|
world.player.cooldown = 0
|
|
world.events = []
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
expect(world.events.find(e => e.kind === 'monsterHit')?.amount).toBe(10)
|
|
})
|
|
|
|
it('verifies Attack Rating bonus scaling across slvl 1..40 and non-positive inputs', () => {
|
|
expect(calculateEnchantAttackRating(1)).toBe(20)
|
|
expect(calculateEnchantAttackRating(10)).toBe(101)
|
|
expect(calculateEnchantAttackRating(20)).toBe(191)
|
|
expect(calculateEnchantAttackRating(30)).toBe(281)
|
|
expect(calculateEnchantAttackRating(40)).toBe(371)
|
|
|
|
expect(calculateEnchantAttackRating(0)).toBe(0)
|
|
expect(calculateEnchantAttackRating(-5)).toBe(0)
|
|
expect(calculateEnchantAttackRating(NaN)).toBe(0)
|
|
})
|
|
|
|
it('verifies Enchant duration scaling across slvl 1..40 and non-positive inputs', () => {
|
|
expect(calculateEnchantDuration(1)).toBe(3600) // 144s
|
|
expect(calculateEnchantDuration(10)).toBe(9000) // 360s
|
|
expect(calculateEnchantDuration(20)).toBe(15000) // 600s
|
|
expect(calculateEnchantDuration(30)).toBe(21000) // 840s
|
|
expect(calculateEnchantDuration(40)).toBe(27000) // 1080s
|
|
|
|
expect(calculateEnchantDuration(0)).toBe(0)
|
|
expect(calculateEnchantDuration(-1)).toBe(0)
|
|
expect(calculateEnchantDuration(NaN)).toBe(0)
|
|
})
|
|
|
|
it('verifies whiff event when attacking while no monster is within playerReach', () => {
|
|
const world: CombatWorld = createWorld(100, 100)
|
|
const distantMonster: Monster = {
|
|
index: 0,
|
|
stats: { id: 'far_monster', name: 'Far', hp: 100, damage: 0, cooldownTicks: 25, reach: 20, aggroRadius: 0, speed: 0, xp: 50 },
|
|
x: 300, y: 300, hp: 100, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
}
|
|
world.monsters.push(distantMonster)
|
|
applyEnchantBuff(world.player, 20, { casterFireMasterySlvl: 20 })
|
|
|
|
tickCombat(world, { movement: { x: 0, y: 0 }, attack: true }, baseOptions, mockTerrain, [0, 100])
|
|
const whiffEvent = world.events.find(e => e.kind === 'playerHit' && e.text === 'whiff')
|
|
expect(whiffEvent).toBeDefined()
|
|
expect(whiffEvent!.amount).toBe(0)
|
|
expect(distantMonster.hp).toBe(100) // unaffected
|
|
})
|
|
})
|
|
})
|