import { describe, expect, it } from 'vitest' import { calculateFrozenArmorDefense, calculateFrozenArmorDuration, calculateFrozenArmorFreezeLength, calculateShiverArmorDefense, calculateShiverArmorDuration, calculateShiverArmorDamage, calculateShiverArmorChillLength, calculateChillingArmorDefense, calculateChillingArmorDuration, calculateChillingArmorDamage, calculateChillingArmorChillLength, calculateColdArmorDuration, BATCH1_SKILLS, } from '../../../src/server/skills/skills.ts' import { createWorld, tickMonsters, updateMonsters, hitPlayerWithMissile, type Monster, type CombatTerrain, type CombatOptions, } from '../../../src/server/engine/combat.ts' import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket, } from '../../../src/server/engine/combat-pipeline.ts' import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' import { UnitStatList, FIXED_ONE } from '../../../src/server/engine/stat-list.ts' import { StateBus } from '../../../src/server/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 { 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 { 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') }) }) })