diablo2-web/tests/skills/sor/cold-armors-remediation.tes...

337 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
calculateFrozenArmorDefense,
calculateFrozenArmorDuration,
calculateFrozenArmorFreezeLength,
calculateShiverArmorDefense,
calculateShiverArmorDuration,
calculateShiverArmorDamage,
calculateShiverArmorChillLength,
calculateChillingArmorDefense,
calculateChillingArmorDuration,
calculateChillingArmorDamage,
calculateChillingArmorChillLength,
calculateColdArmorDuration,
BATCH1_SKILLS,
} from '../../../src/game/skills.ts'
import {
createWorld,
tickMonsters,
updateMonsters,
hitPlayerWithMissile,
type Monster,
type CombatTerrain,
type CombatOptions,
} from '../../../src/game/combat.ts'
import {
executeSUnitDmg,
type CombatUnitContext,
type SUnitDmgPacket,
} from '../../../src/game/engine/combat-pipeline.ts'
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'
const OPEN: CombatTerrain = { overlap: () => 0 }
const DEFAULT_OPTIONS: CombatOptions = {
playerSpeed: 100,
playerReach: 40,
playerCooldownTicks: 25,
playerDamage: 10,
playerManaPerAttack: 0,
respawnTicks: 100,
}
describe('Cold Armors Remediation Suite (Reviewer 2 Findings)', () => {
describe('Finding 1: Number Overload Contract & Multiplier Options', () => {
it('interprets number synergy as allocated skill points for Shiver Armor', () => {
// Base at slvl 20 is min 60, max 71.5
// 0 points -> 1.0x -> { min: 60, max: 71.5 }
expect(calculateShiverArmorDamage(20, 0)).toEqual({ min: 60, max: 71.5 })
// 20 points -> 1 + (20 * 9)/100 = 2.8x -> 60*2.8=168, 71.5*2.8=200.2
expect(calculateShiverArmorDamage(20, 20)).toEqual({ min: 168.0, max: 200.2 })
// 40 points -> 1 + (40 * 9)/100 = 4.6x -> 60*4.6=276, 71.5*4.6=328.9
expect(calculateShiverArmorDamage(20, 40)).toEqual({ min: 276.0, max: 328.9 })
// Parity between object synergy { frozenArmor: 20, chillingArmor: 20 } and number 40
const objDmg = calculateShiverArmorDamage(20, { frozenArmor: 20, chillingArmor: 20 })
const numDmg = calculateShiverArmorDamage(20, 40)
expect(numDmg).toEqual(objDmg)
})
it('interprets number synergy as allocated skill points for Chilling Armor', () => {
// Base at slvl 20 is min 39, max 50.5
// 0 points -> 1.0x -> { min: 39, max: 50.5 }
expect(calculateChillingArmorDamage(20, 0)).toEqual({ min: 39, max: 50.5 })
// 20 points -> 1 + (20 * 7)/100 = 2.4x -> 39*2.4=93.6, 50.5*2.4=121.2
expect(calculateChillingArmorDamage(20, 20)).toEqual({ min: 93.6, max: 121.2 })
// 40 points -> 1 + (40 * 7)/100 = 3.8x -> 39*3.8=148.2, 50.5*3.8=191.9
expect(calculateChillingArmorDamage(20, 40)).toEqual({ min: 148.2, max: 191.9 })
// Parity between object synergy { frozenArmor: 20, shiverArmor: 20 } and number 40
const objDmg = calculateChillingArmorDamage(20, { frozenArmor: 20, shiverArmor: 20 })
const numDmg = calculateChillingArmorDamage(20, 40)
expect(numDmg).toEqual(objDmg)
})
it('supports options.synMultiplier to explicitly override multiplier', () => {
// Override multiplier directly to 4.6x on Shiver Armor
expect(calculateShiverArmorDamage(20, undefined, { synMultiplier: 4.6 })).toEqual({
min: 276.0,
max: 328.9,
})
// Override multiplier directly to 3.8x on Chilling Armor
expect(calculateChillingArmorDamage(20, undefined, { synMultiplier: 3.8 })).toEqual({
min: 148.2,
max: 191.9,
})
})
it('clamps negative synergy points to 0 without degrading below base', () => {
expect(calculateShiverArmorDamage(20, -10)).toEqual(calculateShiverArmorDamage(20, 0))
expect(calculateChillingArmorDamage(20, -10)).toEqual(calculateChillingArmorDamage(20, 0))
})
})
describe('Finding 4: Negative Synergy Clamping in Duration & Length Calculators', () => {
it('clamps negative synergy in duration calculators', () => {
expect(calculateFrozenArmorDuration(10, -5)).toBe(calculateFrozenArmorDuration(10, 0))
expect(calculateShiverArmorDuration(10, -5)).toBe(calculateShiverArmorDuration(10, 0))
expect(calculateChillingArmorDuration(10, -5)).toBe(calculateChillingArmorDuration(10, 0))
expect(calculateColdArmorDuration(10, 1200, 120, 10, -5)).toBe(calculateColdArmorDuration(10, 1200, 120, 10, 0))
})
it('clamps negative synergy in freeze and chill length calculators', () => {
expect(calculateFrozenArmorFreezeLength(10, -5)).toBe(calculateFrozenArmorFreezeLength(10, 0))
expect(calculateShiverArmorChillLength(10, -5)).toBe(calculateShiverArmorChillLength(10, 0))
expect(calculateChillingArmorChillLength(10, -5)).toBe(calculateChillingArmorChillLength(10, 0))
})
})
describe('Finding 2: Active Combat Hooks in combat.ts', () => {
function makeMonster(overrides?: Partial<Monster>): Monster {
return {
index: 0,
x: 10,
y: 10,
hp: 100,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
stats: {
id: 'fallen',
name: 'Fallen',
hp: 100,
damage: 15,
cooldownTicks: 25,
reach: 40, // melee
aggroRadius: 200,
speed: 100,
xp: 10,
},
...overrides,
}
}
it('verifies updateMonsters is alias of tickMonsters', () => {
expect(updateMonsters).toBe(tickMonsters)
})
it('proactively chills and damages monster when Shiver Armor player is attacked in melee', () => {
const world = createWorld(10, 10, 500, 500)
world.player.coldArmorType = 'shiverarmor'
world.player.coldArmorSlvl = 20
const monster = makeMonster({ x: 10, y: 30 }) // distance = 20 <= reach 40
world.monsters = [monster]
// tickMonsters: monster initiates attack on player
tickMonsters(world, DEFAULT_OPTIONS, OPEN, world.players)
// Monster should be chilled and have taken cold damage
expect(monster.chillTicks).toBeGreaterThan(0)
expect(monster.hp).toBeLessThan(100)
})
it('freezes monster reactively when hit lands on Frozen Armor player with physical damage', () => {
const world = createWorld(10, 10, 500, 500)
world.player.coldArmorType = 'frozenarmor'
world.player.coldArmorSlvl = 20
const monster = makeMonster({ x: 10, y: 30 }) // within reach 40
world.monsters = [monster]
tickMonsters(world, DEFAULT_OPTIONS, OPEN, world.players)
expect(monster.frozenTicks).toBeGreaterThan(0)
})
it('retaliates with chillingarmorbolt when Chilling Armor player is hit by missile', () => {
const world = createWorld(10, 10, 500, 500)
world.player.coldArmorType = 'chillingarmor'
world.player.coldArmorSlvl = 20
const attacker = makeMonster({ index: 0, x: 100, y: 10 })
world.monsters = [attacker]
const ret = hitPlayerWithMissile(
world,
{
damage: 15,
attackerIndex: 0,
returnFire: true,
},
world.player,
)
expect(ret.reflected).toBe(true)
expect(ret.retaliatoryMissile).toBe('chillingarmorbolt')
expect(world.events.some(e => e.kind === 'missileHit' && e.missileType === 'chillingarmorbolt')).toBe(true)
expect(attacker.chillTicks).toBeGreaterThan(0)
})
it('does NOT retaliate if missile has returnFire = false', () => {
const world = createWorld(10, 10, 500, 500)
world.player.coldArmorType = 'chillingarmor'
world.player.coldArmorSlvl = 20
const attacker = makeMonster({ index: 0, x: 100, y: 10 })
world.monsters = [attacker]
const ret = hitPlayerWithMissile(
world,
{
damage: 15,
attackerIndex: 0,
returnFire: false,
},
world.player,
)
expect(ret.reflected).toBe(false)
expect(ret.retaliatoryMissile).toBeUndefined()
})
})
describe('Finding 3: Reactive Combat Pipeline Wiring in combat-pipeline.ts', () => {
async function makeCombatant(id: string, name: string): Promise<CombatUnitContext> {
const registry = await getSharedDataRegistry()
const statList = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
tohit: 1000,
armorclass: 100,
level: 20,
})
const stateBus = new StateBus(statList, registry)
return {
id,
name,
statList,
stateBus,
weaponMinPhys: 20,
weaponMaxPhys: 40,
}
}
it('dispatches attackedinmelee proactively and damages attacker with Shiver Armor', async () => {
const attacker = await makeCombatant('att1', 'Attacking Monster')
const defender = await makeCombatant('def1', 'Sorceress')
// Apply Shiver Armor state 88 to defender
defender.stateBus.applyState({
stateNameOrId: 'shiverarmor',
sourceSkillId: 50,
slvl: 20,
})
const packet: SUnitDmgPacket = {
skillId: 0,
attackKind: 'melee',
physDamagePct: 0,
flatPhysMin256: 20 * FIXED_ONE,
flatPhysMax256: 40 * FIXED_ONE,
}
const outcome = executeSUnitDmg(attacker, defender, packet)
// Attacker should receive chill state and cold damage
expect(outcome.attackerColdDamage256).toBeDefined()
expect(outcome.attackerColdDamage256).toBeGreaterThan(0)
expect(outcome.attackerStatesApplied).toBeDefined()
expect(outcome.attackerStatesApplied).toContain('cold')
})
it('dispatches damagedinmelee post-damage and freezes attacker with Frozen Armor', async () => {
const attacker = await makeCombatant('att2', 'Attacking Monster')
const defender = await makeCombatant('def2', 'Sorceress')
// Apply Frozen Armor state 10 to defender
defender.stateBus.applyState({
stateNameOrId: 'frozenarmor',
sourceSkillId: 40,
slvl: 20,
})
const packet: SUnitDmgPacket = {
skillId: 0,
attackKind: 'melee',
flatPhysMin256: 20 * FIXED_ONE,
flatPhysMax256: 40 * FIXED_ONE,
autoHit: true,
}
const outcome = executeSUnitDmg(attacker, defender, packet)
// Attacker should be frozen
expect(outcome.attackerStatesApplied).toBeDefined()
expect(outcome.attackerStatesApplied).toContain('freeze')
expect(attacker.stateBus.hasState('freeze')).toBe(true)
})
it('dispatches hitbymissile and spawns retaliatory chillingarmorbolt with Chilling Armor', async () => {
const attacker = await makeCombatant('att3', 'Archer Monster')
const defender = await makeCombatant('def3', 'Sorceress')
// Apply Chilling Armor state 20 to defender
defender.stateBus.applyState({
stateNameOrId: 'chillingarmor',
sourceSkillId: 60,
slvl: 20,
})
const packet: SUnitDmgPacket = {
skillId: 0,
attackKind: 'missile',
flatPhysMin256: 20 * FIXED_ONE,
flatPhysMax256: 40 * FIXED_ONE,
returnFire: true,
autoHit: true,
}
const outcome = executeSUnitDmg(attacker, defender, packet)
// Spawns retaliatory chillingarmorbolt
expect(outcome.spawnedMissiles).toBeDefined()
expect(outcome.spawnedMissiles?.length).toBe(1)
expect(outcome.spawnedMissiles?.[0]).toBe('chillingarmorbolt')
})
})
describe('Invariant Integrity', () => {
it('strictly maintains BATCH1_SKILLS length of 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
expect(BATCH1_SKILLS).not.toHaveProperty('40')
expect(BATCH1_SKILLS).not.toHaveProperty('50')
expect(BATCH1_SKILLS).not.toHaveProperty('60')
})
})
})