diff --git a/src/game/combat.ts b/src/game/combat.ts index 8a7042f..6d5fbb9 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -30,6 +30,13 @@ import { PACK_RADIUS_PX, } from './monster-rooms.ts' import type { MapRoom } from './monster-rooms.ts' +import { + calculateFrozenArmorFreezeLength, + calculateShiverArmorDamage, + calculateShiverArmorChillLength, + calculateChillingArmorDamage, + calculateChillingArmorChillLength, +} from './engine/missile-engine.ts' export { allocatePacksToRooms, @@ -875,7 +882,7 @@ function tickPlayer( * @param terrain - collision predicate. * @param targets - the players that were alive when the tick began. */ -function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void { +export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void { for (const monster of world.monsters) { if (monster.sleeping) continue if (monster.state === 'dead') { @@ -923,6 +930,40 @@ function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: Comba if (monster.cooldown === 0) { monster.cooldown = monster.stats.cooldownTicks let dmg = monster.stats.damage + const isRanged = monster.stats.reach > 40 + + // Cold Armor: Shiver Armor (Skill 50) proactive melee defense + if (!isRanged && player.coldArmorType === 'shiverarmor') { + const slvl = player.coldArmorSlvl ?? 1 + const dmgRange = calculateShiverArmorDamage(slvl) + const coldDmg = Math.round((dmgRange.min + dmgRange.max) / 2) + damageMonster(world, monster.index, coldDmg, player) + const chillFrames = calculateShiverArmorChillLength(slvl) + monster.chillTicks = Math.max(monster.chillTicks ?? 0, chillFrames) + } + + // Cold Armor: Chilling Armor (Skill 60) ranged retaliatory reflection + if (isRanged && player.coldArmorType === 'chillingarmor') { + const slvl = player.coldArmorSlvl ?? 1 + const dmgRange = calculateChillingArmorDamage(slvl) + const boltDmg = Math.round((dmgRange.min + dmgRange.max) / 2) + world.events.push({ + kind: 'missileHit', + x: player.x, + y: player.y, + missileType: 'chillingarmorbolt', + skillId: '60', + amount: boltDmg, + }) + damageMonster(world, monster.index, boltDmg, player) + const chillFrames = calculateChillingArmorChillLength(slvl) + monster.chillTicks = Math.max(monster.chillTicks ?? 0, chillFrames) + } + + if (monster.hp <= 0) { + continue + } + if (player.energyshieldTicks && player.energyshieldTicks > 0 && player.mana > 0) { const absorbPct = player.energyshieldPct ?? 20 const absorbed = Math.floor((dmg * absorbPct) / 100) @@ -940,6 +981,14 @@ function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: Comba } player.hp -= dmg world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: dmg }) + + // Cold Armor: Frozen Armor (Skill 40) reactive melee freeze on physical damage + if (!isRanged && player.coldArmorType === 'frozenarmor' && dmg > 0) { + const slvl = player.coldArmorSlvl ?? 1 + const freezeFrames = calculateFrozenArmorFreezeLength(slvl) + monster.frozenTicks = Math.max(monster.frozenTicks ?? 0, freezeFrames) + } + if (player.hp <= 0) { player.hp = 0 player.alive = false @@ -957,3 +1006,93 @@ function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: Comba monster.y = movedMonster.y } } + +export const updateMonsters = tickMonsters + +export interface PlayerMissileHitOptions { + readonly damage?: number + readonly missileType?: string + readonly attackerIndex?: number + readonly attacker?: Monster | CombatPlayer + readonly returnFire?: boolean + readonly sourceX?: number + readonly sourceY?: number +} + +/** + * Handle a ranged missile hit on the player. + * When player has Chilling Armor active and returnFire !== false, + * reflects a retaliatory ice bolt missile ('chillingarmorbolt') toward the attacker with returnFire: false. + */ +export function hitPlayerWithMissile( + world: CombatWorld, + options: PlayerMissileHitOptions, + player: CombatPlayer = world.player, +): { reflected: boolean; retaliatoryMissile?: string; damageDealt: number } { + let dmg = options.damage ?? 10 + const returnFire = options.returnFire !== false + let reflected = false + let retaliatoryMissile: string | undefined + + if (player.coldArmorType === 'chillingarmor' && returnFire) { + reflected = true + retaliatoryMissile = 'chillingarmorbolt' + const slvl = player.coldArmorSlvl ?? 1 + const dmgRange = calculateChillingArmorDamage(slvl) + const boltDmg = Math.round((dmgRange.min + dmgRange.max) / 2) + const chillFrames = calculateChillingArmorChillLength(slvl) + + world.events.push({ + kind: 'missileHit', + x: player.x, + y: player.y, + missileType: 'chillingarmorbolt', + skillId: '60', + amount: boltDmg, + }) + + if (options.attackerIndex !== undefined) { + damageMonster(world, options.attackerIndex, boltDmg, player) + const m = world.monsters[options.attackerIndex] + if (m) { + m.chillTicks = Math.max(m.chillTicks ?? 0, chillFrames) + } + } else if (options.attacker && 'stats' in options.attacker) { + const m = options.attacker as Monster + damageMonster(world, m.index, boltDmg, player) + m.chillTicks = Math.max(m.chillTicks ?? 0, chillFrames) + } + } + + // Energy shield absorption + if (player.energyshieldTicks && player.energyshieldTicks > 0 && player.mana > 0) { + const absorbPct = player.energyshieldPct ?? 20 + const absorbed = Math.floor((dmg * absorbPct) / 100) + const ratio = 2.0 + const manaNeeded = Math.ceil(absorbed * ratio) + if (player.mana >= manaNeeded) { + player.mana -= manaNeeded + dmg -= absorbed + } else { + const actualAbsorbed = Math.floor(player.mana / ratio) + player.mana = 0 + dmg -= actualAbsorbed + player.energyshieldTicks = 0 + } + } + + player.hp -= dmg + world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: dmg }) + if (player.hp <= 0) { + player.hp = 0 + player.alive = false + player.respawnIn = 100 + world.events.push({ kind: 'playerDeath', x: player.x, y: player.y, text: 'you died' }) + } + + return { + reflected, + ...(retaliatoryMissile !== undefined ? { retaliatoryMissile } : {}), + damageDealt: dmg, + } +} diff --git a/src/game/engine/combat-pipeline.ts b/src/game/engine/combat-pipeline.ts index 2798bc3..3c08d92 100644 --- a/src/game/engine/combat-pipeline.ts +++ b/src/game/engine/combat-pipeline.ts @@ -300,6 +300,7 @@ export interface SUnitDmgPacket { readonly roll100?: number readonly isPoison?: boolean readonly isOpenWounds?: boolean + readonly returnFire?: boolean } export interface SUnitDmgOutcome { @@ -314,6 +315,10 @@ export interface SUnitDmgOutcome { readonly targetHpBefore256: number readonly targetHpAfter256: number readonly targetKilled: boolean + readonly attackerStatesApplied?: readonly string[] + readonly attackerColdDamage256?: number + readonly reflectedPhys256?: number + readonly spawnedMissiles?: readonly string[] } /** @@ -328,6 +333,29 @@ export function executeSUnitDmg( const attackKind = packet.attackKind ?? (packet.srcDam && packet.srcDam > 0 ? 'melee' : 'spell') const roll = packet.roll100 ?? 25 + const attackerStatesApplied: string[] = [] + let attackerColdDamage256 = 0 + let reflectedPhys256 = 0 + const spawnedMissiles: string[] = [] + + // If melee attack, trigger 'attackedinmelee' proactively on attack initiation + if (attackKind === 'melee') { + const initialPhys = packet.flatPhysMax256 ?? ((attacker.weaponMaxPhys ?? 45) * FIXED_ONE) + const reactiveAttack = defender.stateBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: initialPhys, + attackerStateBus: attacker.stateBus, + }) + if (reactiveAttack.attackerStatesApplied.length > 0) { + attackerStatesApplied.push(...reactiveAttack.attackerStatesApplied) + } + if (reactiveAttack.attackerColdDamage256 > 0) { + attackerColdDamage256 += reactiveAttack.attackerColdDamage256 + const attackerHp = attacker.statList.getHp256() + attacker.statList.setHp256(Math.max(0, attackerHp - reactiveAttack.attackerColdDamage256)) + } + } + // 1. RollToHit check for weapon attacks (unless autoHit, e.g. Smite 97 / Guided Arrow 22 / Spells) const isAutoHit = packet.autoHit ?? (packet.skillId === 97 || packet.skillId === 22 || attackKind === 'spell') if (!isAutoHit) { @@ -350,6 +378,8 @@ export function executeSUnitDmg( targetHpBefore256: hpBefore256, targetHpAfter256: hpBefore256, targetKilled: false, + ...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}), + ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), } } } @@ -374,6 +404,8 @@ export function executeSUnitDmg( targetHpBefore256: hpBefore256, targetHpAfter256: hpBefore256, targetKilled: false, + ...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}), + ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), } } @@ -538,6 +570,45 @@ export function executeSUnitDmg( const hpAfter256 = Math.max(0, hpBefore256 - totalDamage256) defender.statList.setHp256(hpAfter256) + // 9. Reactive events post-damage (damagedinmelee for melee, hitbymissile for missile) + if (attackKind === 'melee') { + const reactiveDamage = defender.stateBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: finalPhys256, + incomingElem256: finalElem256, + elemType, + attackerStateBus: attacker.stateBus, + }) + if (reactiveDamage.attackerStatesApplied.length > 0) { + attackerStatesApplied.push(...reactiveDamage.attackerStatesApplied) + } + if (reactiveDamage.attackerColdDamage256 > 0) { + attackerColdDamage256 += reactiveDamage.attackerColdDamage256 + const curHp = attacker.statList.getHp256() + attacker.statList.setHp256(Math.max(0, curHp - reactiveDamage.attackerColdDamage256)) + } + if (reactiveDamage.reflectedPhys256 > 0) { + reflectedPhys256 += reactiveDamage.reflectedPhys256 + const curHp = attacker.statList.getHp256() + attacker.statList.setHp256(Math.max(0, curHp - reactiveDamage.reflectedPhys256)) + } + } else if (attackKind === 'missile') { + const reactiveMissile = defender.stateBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: finalPhys256, + incomingElem256: finalElem256, + elemType, + ...(packet.returnFire !== undefined ? { returnFire: packet.returnFire } : {}), + attackerStateBus: attacker.stateBus, + }) + if (reactiveMissile.spawnedMissiles.length > 0) { + spawnedMissiles.push(...reactiveMissile.spawnedMissiles) + } + if (reactiveMissile.attackerStatesApplied.length > 0) { + attackerStatesApplied.push(...reactiveMissile.attackerStatesApplied) + } + } + return { hit: true, avoidedReason: 'none', @@ -550,5 +621,9 @@ export function executeSUnitDmg( targetHpBefore256: hpBefore256, targetHpAfter256: hpAfter256, targetKilled: hpAfter256 === 0 && hpBefore256 > 0, + ...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}), + ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), + ...(reflectedPhys256 > 0 ? { reflectedPhys256 } : {}), + ...(spawnedMissiles.length > 0 ? { spawnedMissiles } : {}), } } diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index d1c9f2f..42391df 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -1792,10 +1792,12 @@ export function calculateFrozenArmorDuration( options?: { base1200?: boolean }, ): number { const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 - const synPoints = + const synPoints = Math.max( + 0, typeof synergies === 'number' ? synergies - : (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0) + : (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0), + ) if (options?.base1200) { return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100)) } @@ -1813,10 +1815,12 @@ export function calculateFrozenArmorFreezeLength( synergies?: { shiverArmor?: number; chillingArmor?: number } | number, ): number { const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 - const synPoints = + const synPoints = Math.max( + 0, typeof synergies === 'number' ? synergies - : (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0) + : (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0), + ) const baseFrames = 30 + (lvl - 1) * 3 const synFactor = 100 + synPoints * 5 return Math.floor((baseFrames * synFactor) / 100) @@ -1844,10 +1848,12 @@ export function calculateShiverArmorDuration( options?: { base1200?: boolean }, ): number { const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 - const synPoints = + const synPoints = Math.max( + 0, typeof synergies === 'number' ? synergies - : (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0) + : (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0), + ) if (options?.base1200) { return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100)) } @@ -1863,7 +1869,7 @@ export function calculateShiverArmorDuration( export function calculateShiverArmorDamage( slvl: number, synergies?: { frozenArmor?: number; chillingArmor?: number; synPctPerLvl?: number } | number, - options?: { synPctPerLvl?: number }, + options?: { synPctPerLvl?: number; synMultiplier?: number }, ): { min: number; max: number } { const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 let rawMin = 12 @@ -1898,10 +1904,14 @@ export function calculateShiverArmorDamage( let max = rawMax / 2 let synMultiplier = 1.0 - if (typeof synergies === 'number') { - synMultiplier = synergies > 0 && Number.isFinite(synergies) ? synergies : 1.0 + if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) { + synMultiplier = Math.max(0, options.synMultiplier) + } else if (typeof synergies === 'number') { + const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0 + const pctPerLvl = options?.synPctPerLvl ?? 9 + synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100 } else if (typeof synergies === 'object' && synergies !== null) { - const synPoints = (synergies.frozenArmor ?? 0) + (synergies.chillingArmor ?? 0) + const synPoints = Math.max(0, (synergies.frozenArmor ?? 0) + (synergies.chillingArmor ?? 0)) const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 9 synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100 } @@ -1931,10 +1941,12 @@ export function calculateShiverArmorChillLength( const b3 = lvl - 16 frames += b3 * 50 } - const synPoints = + const synPoints = Math.max( + 0, typeof synergies === 'number' ? synergies - : (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0) + : (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0), + ) if (synPoints > 0) { frames = Math.floor((frames * (100 + synPoints * 7)) / 100) } @@ -1963,10 +1975,12 @@ export function calculateChillingArmorDuration( options?: { base1200?: boolean }, ): number { const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 - const synPoints = + const synPoints = Math.max( + 0, typeof synergies === 'number' ? synergies - : (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0) + : (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0), + ) if (options?.base1200) { return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100)) } @@ -1982,7 +1996,7 @@ export function calculateChillingArmorDuration( export function calculateChillingArmorDamage( slvl: number, synergies?: { frozenArmor?: number; shiverArmor?: number; synPctPerLvl?: number } | number, - options?: { synPctPerLvl?: number }, + options?: { synPctPerLvl?: number; synMultiplier?: number }, ): { min: number; max: number } { const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 let rawMin = 8 @@ -2017,10 +2031,14 @@ export function calculateChillingArmorDamage( let max = rawMax / 2 let synMultiplier = 1.0 - if (typeof synergies === 'number') { - synMultiplier = synergies > 0 && Number.isFinite(synergies) ? synergies : 1.0 + if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) { + synMultiplier = Math.max(0, options.synMultiplier) + } else if (typeof synergies === 'number') { + const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0 + const pctPerLvl = options?.synPctPerLvl ?? 7 + synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100 } else if (typeof synergies === 'object' && synergies !== null) { - const synPoints = (synergies.frozenArmor ?? 0) + (synergies.shiverArmor ?? 0) + const synPoints = Math.max(0, (synergies.frozenArmor ?? 0) + (synergies.shiverArmor ?? 0)) const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 7 synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100 } @@ -2050,10 +2068,12 @@ export function calculateChillingArmorChillLength( const b3 = lvl - 16 frames += b3 * 25 } - const synPoints = + const synPoints = Math.max( + 0, typeof synergies === 'number' ? synergies - : (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0) + : (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0), + ) if (synPoints > 0) { frames = Math.floor((frames * (100 + synPoints * 10)) / 100) } @@ -2071,7 +2091,8 @@ export function calculateColdArmorDuration( synPoints = 0, ): number { const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 - return Math.floor((baseFrames + (lvl - 1) * perLvlFrames) * (1 + (synPoints * synBonusPct) / 100)) + const safeSyn = Math.max(0, synPoints) + return Math.floor((baseFrames + (lvl - 1) * perLvlFrames) * (1 + (safeSyn * synBonusPct) / 100)) } diff --git a/tests/challenger-cold-armors-stress.test.ts b/tests/challenger-cold-armors-stress.test.ts new file mode 100644 index 0000000..7feb5b2 --- /dev/null +++ b/tests/challenger-cold-armors-stress.test.ts @@ -0,0 +1,849 @@ +/** + * Adversarial Stress & Correctness Verification Suite — Milestone 1: Three Sorceress Cold Armors + * Skills Under Test: + * - Frozen Armor (Skill #040, State 10, Group 1) + * - Shiver Armor (Skill #050, State 88, Group 1) + * - Chilling Armor (Skill #060, State 20, Group 1) + * + * Target Commit: e7d105d + * Author: Challenger 1 (teamwork_preview_challenger) + * Ground Truth Reference: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt, Overlay.txt, States.txt) + * + * Verification Dimensions: + * 1. Exhaustive Mathematical Boundary Sweeps (slvl 1..99 x synergies 0..20 / 0..40): + * - Monotonicity of defense bonus, duration, cold damage, and freeze/chill lengths. + * - Zero NaN, zero undefined, and positive non-zero outputs for all valid inputs. + * - Exact formula validation against 1.13c ground truth analytical models. + * 2. Degenerate, Extreme & Adversarial Inputs: + * - Non-finite, negative, zero, and float slvl handling. + * - Missing, malformed, negative, and mixed synergy structures. + * 3. State Group 1 Mutual Exclusion Stress Testing: + * - Rapid successive casting bursts & 100-step randomized permutations. + * - Invariant: strictly 1 active Group 1 state at all times. + * - Level-override rules (different armor overwrites regardless of slvl; same armor rejects lower slvl). + * - Clean eviction of StatList defense modifiers upon armor transition. + * 4. Reactive Trigger Adversarial Edge Cases: + * - Frozen Armor: damagedinmelee with physical damage = 0 must NOT freeze. + * - Chilling Armor: hitbymissile with returnFire = false must NOT reflect. + * - Shiver Armor: attackedinmelee triggers proactively before attack connects regardless of damage. + * - Event cross-trigger isolation (no cross-firing between melee and missile events). + * 5. Full Invariant & System Integrity Checks: + * - BATCH1_SKILLS length strictly 38. + * - Mana costs: flat 7 (40), flat 11 (50), flat 17 (60). + * - Cooldown ticks: 0 for all three armors. + * - calculateSkillDamage routing parity. + */ + +import { describe, expect, it } from 'vitest' +import { + calculateFrozenArmorDefense, + calculateFrozenArmorDuration, + calculateFrozenArmorFreezeLength, + calculateShiverArmorDefense, + calculateShiverArmorDuration, + calculateShiverArmorDamage, + calculateShiverArmorChillLength, + calculateChillingArmorDefense, + calculateChillingArmorDuration, + calculateChillingArmorDamage, + calculateChillingArmorChillLength, + calculateColdArmorDuration, + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + BATCH1_SKILLS, +} from '../src/game/skills.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' +import { OVERLAY_METAS } from '../src/render/overlays-meta.ts' +import { MISSILE_METAS } from '../src/render/missiles-meta.ts' + +describe('Challenger 1 Stress Suite — Three Sorceress Cold Armors (M1: #040, #050, #060)', () => { + // ========================================================================= + // Dimension 1: Exhaustive Mathematical Boundary Sweeps (slvl 1..99 x syn 0..40) + // ========================================================================= + describe('Dimension 1: Exhaustive Mathematical Boundary Sweeps & Monotonicity', () => { + describe('Frozen Armor (Skill 40)', () => { + it('evaluates % Enhanced Defense across slvl 1..99 with strict monotonicity and zero NaN', () => { + let prevDef = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const def = calculateFrozenArmorDefense(slvl) + expect(Number.isFinite(def)).toBe(true) + expect(Number.isNaN(def)).toBe(false) + expect(def).toBeGreaterThan(0) + // 1.13c formula: 30 + (slvl - 1) * 5 + expect(def).toBe(30 + (slvl - 1) * 5) + expect(def).toBeGreaterThan(prevDef) + prevDef = def + } + expect(calculateFrozenArmorDefense(1)).toBe(30) + expect(calculateFrozenArmorDefense(10)).toBe(75) + expect(calculateFrozenArmorDefense(20)).toBe(125) + expect(calculateFrozenArmorDefense(99)).toBe(520) + }) + + it('evaluates Duration across slvl 1..99 and syn 0..40 with strict monotonicity and zero NaN', () => { + // Sweep 99 * 41 = 4059 test cases + for (let syn = 0; syn <= 40; syn++) { + let prevDur = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const dur = calculateFrozenArmorDuration(slvl, syn) + expect(Number.isFinite(dur)).toBe(true) + expect(Number.isNaN(dur)).toBe(false) + expect(dur).toBeGreaterThan(0) + // 1.13c formula: 3000 + (slvl - 1) * 300 + syn * 250 + const expected = 3000 + (slvl - 1) * 300 + syn * 250 + expect(dur).toBe(expected) + expect(dur).toBeGreaterThan(prevDur) + prevDur = dur + } + } + + // Synergy monotonicity at fixed slvl + for (let slvl = 1; slvl <= 20; slvl++) { + let prevDur = 0 + for (let syn = 0; syn <= 40; syn++) { + const dur = calculateFrozenArmorDuration(slvl, syn) + expect(dur).toBeGreaterThan(prevDur) + prevDur = dur + } + } + + // Spot-checks + expect(calculateFrozenArmorDuration(1, 0)).toBe(3000) // 120s + expect(calculateFrozenArmorDuration(10, 0)).toBe(5700) // 228s + expect(calculateFrozenArmorDuration(20, 0)).toBe(8700) // 348s + expect(calculateFrozenArmorDuration(20, 40)).toBe(18700) // 748s + expect(calculateFrozenArmorDuration(99, 40)).toBe(42400) // 1696s + }) + + it('evaluates Freeze Length across slvl 1..99 and syn 0..40 with monotonicity and zero NaN', () => { + for (let syn = 0; syn <= 40; syn++) { + let prevFreeze = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const frz = calculateFrozenArmorFreezeLength(slvl, syn) + expect(Number.isFinite(frz)).toBe(true) + expect(Number.isNaN(frz)).toBe(false) + expect(frz).toBeGreaterThan(0) + // 1.13c formula: Math.floor((30 + (slvl - 1) * 3) * (100 + syn * 5) / 100) + const expected = Math.floor((30 + (slvl - 1) * 3) * (100 + syn * 5) / 100) + expect(frz).toBe(expected) + expect(frz).toBeGreaterThan(prevFreeze) + prevFreeze = frz + } + } + + // Synergy monotonicity at fixed slvl + for (let slvl = 1; slvl <= 20; slvl++) { + let prevFreeze = 0 + for (let syn = 0; syn <= 40; syn++) { + const frz = calculateFrozenArmorFreezeLength(slvl, syn) + expect(frz).toBeGreaterThanOrEqual(prevFreeze) + prevFreeze = frz + } + } + + // Spot-checks + expect(calculateFrozenArmorFreezeLength(1, 0)).toBe(30) // 1.20s + expect(calculateFrozenArmorFreezeLength(10, 0)).toBe(57) // 2.28s + expect(calculateFrozenArmorFreezeLength(20, 0)).toBe(87) // 3.48s + expect(calculateFrozenArmorFreezeLength(20, 40)).toBe(261) // 10.44s + expect(calculateFrozenArmorFreezeLength(99, 40)).toBe(972) // 38.88s + }) + + it('asserts Frozen Armor deals strictly 0 damage across slvl 1..99', () => { + for (let slvl = 1; slvl <= 99; slvl++) { + const dmg = calculateSkillDamage(40, slvl) + expect(dmg).toEqual({ min: 0, max: 0 }) + } + }) + }) + + describe('Shiver Armor (Skill 50)', () => { + it('evaluates % Enhanced Defense across slvl 1..99 with strict monotonicity and zero NaN', () => { + let prevDef = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const def = calculateShiverArmorDefense(slvl) + expect(Number.isFinite(def)).toBe(true) + expect(Number.isNaN(def)).toBe(false) + expect(def).toBeGreaterThan(0) + // 1.13c formula: 45 + (slvl - 1) * 6 + expect(def).toBe(45 + (slvl - 1) * 6) + expect(def).toBeGreaterThan(prevDef) + prevDef = def + } + expect(calculateShiverArmorDefense(1)).toBe(45) + expect(calculateShiverArmorDefense(10)).toBe(99) + expect(calculateShiverArmorDefense(20)).toBe(159) + expect(calculateShiverArmorDefense(99)).toBe(633) + }) + + it('evaluates Duration across slvl 1..99 and syn 0..40 with strict monotonicity and zero NaN', () => { + for (let syn = 0; syn <= 40; syn++) { + let prevDur = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const dur = calculateShiverArmorDuration(slvl, syn) + expect(Number.isFinite(dur)).toBe(true) + expect(Number.isNaN(dur)).toBe(false) + expect(dur).toBeGreaterThan(0) + const expected = 3000 + (slvl - 1) * 300 + syn * 250 + expect(dur).toBe(expected) + expect(dur).toBeGreaterThan(prevDur) + prevDur = dur + } + } + expect(calculateShiverArmorDuration(1, 0)).toBe(3000) + expect(calculateShiverArmorDuration(20, 0)).toBe(8700) + expect(calculateShiverArmorDuration(20, 40)).toBe(18700) + }) + + it('evaluates Cold Damage across slvl 1..99 and syn 0..40 with strict monotonicity and zero NaN', () => { + for (let syn = 0; syn <= 40; syn += 5) { + let prevMin = 0 + let prevMax = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const dmg = calculateShiverArmorDamage(slvl, { frozenArmor: syn, chillingArmor: 0 }) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(Number.isNaN(dmg.min)).toBe(false) + expect(Number.isNaN(dmg.max)).toBe(false) + expect(dmg.min).toBeGreaterThan(0) + expect(dmg.max).toBeGreaterThanOrEqual(dmg.min) + expect(dmg.min).toBeGreaterThan(prevMin) + expect(dmg.max).toBeGreaterThan(prevMax) + prevMin = dmg.min + prevMax = dmg.max + } + } + + // Synergy monotonicity at fixed slvl + for (let slvl = 1; slvl <= 20; slvl += 5) { + let prevMin = 0 + for (let syn = 0; syn <= 40; syn++) { + const dmg = calculateShiverArmorDamage(slvl, syn) + expect(dmg.min).toBeGreaterThanOrEqual(prevMin) + prevMin = dmg.min + } + } + + // Authentic 1.13c spot-checks (HitShift = 7 -> divide by 2) + expect(calculateShiverArmorDamage(1, 0)).toEqual({ min: 6, max: 8 }) + expect(calculateShiverArmorDamage(10, 0)).toEqual({ min: 26, max: 32.5 }) + expect(calculateShiverArmorDamage(20, 0)).toEqual({ min: 60, max: 71.5 }) + // +40 syn points via object (+360% -> multiplier 4.6x): 60 * 4.6 = 276.0, 71.5 * 4.6 = 328.9 + expect(calculateShiverArmorDamage(20, { frozenArmor: 20, chillingArmor: 20 })).toEqual({ min: 276.0, max: 328.9 }) + // +40 syn points via numeric synergy points (40 pts -> 1 + 40*0.09 = 4.6x) + expect(calculateShiverArmorDamage(20, 40)).toEqual({ min: 276.0, max: 328.9 }) + // +40 syn points via options.synMultiplier (4.6x) + expect(calculateShiverArmorDamage(20, undefined, { synMultiplier: 4.6 })).toEqual({ min: 276.0, max: 328.9 }) + }) + + it('evaluates Chill Length across slvl 1..99 and syn 0..40 with piecewise monotonicity and zero NaN', () => { + for (let syn = 0; syn <= 40; syn += 10) { + let prevChill = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const chill = calculateShiverArmorChillLength(slvl, syn) + expect(Number.isFinite(chill)).toBe(true) + expect(Number.isNaN(chill)).toBe(false) + expect(chill).toBeGreaterThan(0) + expect(chill).toBeGreaterThanOrEqual(prevChill) + prevChill = chill + } + } + + // Level band step checks without synergy: + // slvl 1-8: flat 100 frames + for (let lvl = 1; lvl <= 8; lvl++) { + expect(calculateShiverArmorChillLength(lvl, 0)).toBe(100) + } + // slvl 9-16: +25 frames / level + expect(calculateShiverArmorChillLength(9, 0)).toBe(125) + expect(calculateShiverArmorChillLength(10, 0)).toBe(150) + expect(calculateShiverArmorChillLength(16, 0)).toBe(300) + // slvl 17+: +50 frames / level + expect(calculateShiverArmorChillLength(17, 0)).toBe(350) + expect(calculateShiverArmorChillLength(20, 0)).toBe(500) + expect(calculateShiverArmorChillLength(99, 0)).toBe(4450) + + // With +40 syn (+280% factor = 3.8x): 500 * 3.8 = 1900 + expect(calculateShiverArmorChillLength(20, 40)).toBe(1900) + expect(calculateShiverArmorChillLength(20, { frozenArmor: 20, chillingArmor: 20 })).toBe(1900) + }) + }) + + describe('Chilling Armor (Skill 60)', () => { + it('evaluates % Enhanced Defense across slvl 1..99 with strict monotonicity and zero NaN', () => { + let prevDef = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const def = calculateChillingArmorDefense(slvl) + expect(Number.isFinite(def)).toBe(true) + expect(Number.isNaN(def)).toBe(false) + expect(def).toBeGreaterThan(0) + expect(def).toBe(45 + (slvl - 1) * 5) + expect(def).toBeGreaterThan(prevDef) + prevDef = def + } + expect(calculateChillingArmorDefense(1)).toBe(45) + expect(calculateChillingArmorDefense(10)).toBe(90) + expect(calculateChillingArmorDefense(20)).toBe(140) + expect(calculateChillingArmorDefense(99)).toBe(535) + }) + + it('evaluates Duration across slvl 1..99 and syn 0..40 with strict monotonicity and zero NaN', () => { + for (let syn = 0; syn <= 40; syn++) { + let prevDur = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const dur = calculateChillingArmorDuration(slvl, syn) + expect(Number.isFinite(dur)).toBe(true) + expect(Number.isNaN(dur)).toBe(false) + expect(dur).toBeGreaterThan(0) + const expected = 3600 + (slvl - 1) * 150 + syn * 250 + expect(dur).toBe(expected) + expect(dur).toBeGreaterThan(prevDur) + prevDur = dur + } + } + expect(calculateChillingArmorDuration(1, 0)).toBe(3600) // 144s + expect(calculateChillingArmorDuration(10, 0)).toBe(4950) // 198s + expect(calculateChillingArmorDuration(20, 0)).toBe(6450) // 258s + expect(calculateChillingArmorDuration(20, 40)).toBe(16450) // 658s + expect(calculateChillingArmorDuration(99, 40)).toBe(28300) // 1132s + }) + + it('evaluates Retaliatory Bolt Cold Damage across slvl 1..99 and syn 0..40 with monotonicity and zero NaN', () => { + for (let syn = 0; syn <= 40; syn += 5) { + let prevMin = 0 + let prevMax = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const dmg = calculateChillingArmorDamage(slvl, { frozenArmor: syn, shiverArmor: 0 }) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + expect(Number.isNaN(dmg.min)).toBe(false) + expect(Number.isNaN(dmg.max)).toBe(false) + expect(dmg.min).toBeGreaterThan(0) + expect(dmg.max).toBeGreaterThanOrEqual(dmg.min) + expect(dmg.min).toBeGreaterThan(prevMin) + expect(dmg.max).toBeGreaterThan(prevMax) + prevMin = dmg.min + prevMax = dmg.max + } + } + + // Spot-checks + expect(calculateChillingArmorDamage(1, 0)).toEqual({ min: 4, max: 6 }) + expect(calculateChillingArmorDamage(10, 0)).toEqual({ min: 15, max: 21.5 }) + expect(calculateChillingArmorDamage(20, 0)).toEqual({ min: 39, max: 50.5 }) + // +40 syn points via object (+280% -> multiplier 3.8x): 39 * 3.8 = 148.2, 50.5 * 3.8 = 191.9 + expect(calculateChillingArmorDamage(20, { frozenArmor: 20, shiverArmor: 20 })).toEqual({ min: 148.2, max: 191.9 }) + // +40 syn points via numeric synergy points (40 pts -> 1 + 40*0.07 = 3.8x) + expect(calculateChillingArmorDamage(20, 40)).toEqual({ min: 148.2, max: 191.9 }) + // +40 syn points via options.synMultiplier (3.8x) + expect(calculateChillingArmorDamage(20, undefined, { synMultiplier: 3.8 })).toEqual({ min: 148.2, max: 191.9 }) + }) + + it('evaluates Chill Length across slvl 1..99 and syn 0..40 with piecewise monotonicity and zero NaN', () => { + for (let syn = 0; syn <= 40; syn += 10) { + let prevChill = 0 + for (let slvl = 1; slvl <= 99; slvl++) { + const chill = calculateChillingArmorChillLength(slvl, syn) + expect(Number.isFinite(chill)).toBe(true) + expect(Number.isNaN(chill)).toBe(false) + expect(chill).toBeGreaterThan(0) + expect(chill).toBeGreaterThanOrEqual(prevChill) + prevChill = chill + } + } + + // Band step checks without synergy: + for (let lvl = 1; lvl <= 8; lvl++) { + expect(calculateChillingArmorChillLength(lvl, 0)).toBe(100) + } + expect(calculateChillingArmorChillLength(9, 0)).toBe(125) + expect(calculateChillingArmorChillLength(10, 0)).toBe(150) + expect(calculateChillingArmorChillLength(16, 0)).toBe(300) + // slvl 17+: +25 frames / level + expect(calculateChillingArmorChillLength(17, 0)).toBe(325) + expect(calculateChillingArmorChillLength(20, 0)).toBe(400) + expect(calculateChillingArmorChillLength(99, 0)).toBe(2375) + + // With +40 syn (+400% factor = 5.0x): 400 * 5.0 = 2000 + expect(calculateChillingArmorChillLength(20, 40)).toBe(2000) + expect(calculateChillingArmorChillLength(20, { frozenArmor: 20, shiverArmor: 20 })).toBe(2000) + }) + }) + }) + + // ========================================================================= + // Dimension 2: Degenerate, Extreme & Adversarial Inputs + // ========================================================================= + describe('Dimension 2: Degenerate, Extreme & Adversarial Inputs', () => { + it('clamps negative, zero, and non-integer slvl gracefully without throwing or returning NaN', () => { + const dirtyLvls = [0, -1, -99, 0.5, 12.8, NaN, Infinity, -Infinity] + for (const slvl of dirtyLvls) { + // Frozen Armor + const fDef = calculateFrozenArmorDefense(slvl) + const fDur = calculateFrozenArmorDuration(slvl) + const fFrz = calculateFrozenArmorFreezeLength(slvl) + expect(Number.isFinite(fDef)).toBe(true) + expect(Number.isFinite(fDur)).toBe(true) + expect(Number.isFinite(fFrz)).toBe(true) + + // Shiver Armor + const sDef = calculateShiverArmorDefense(slvl) + const sDur = calculateShiverArmorDuration(slvl) + const sDmg = calculateShiverArmorDamage(slvl) + const sChl = calculateShiverArmorChillLength(slvl) + expect(Number.isFinite(sDef)).toBe(true) + expect(Number.isFinite(sDur)).toBe(true) + expect(Number.isFinite(sDmg.min)).toBe(true) + expect(Number.isFinite(sDmg.max)).toBe(true) + expect(Number.isFinite(sChl)).toBe(true) + + // Chilling Armor + const cDef = calculateChillingArmorDefense(slvl) + const cDur = calculateChillingArmorDuration(slvl) + const cDmg = calculateChillingArmorDamage(slvl) + const cChl = calculateChillingArmorChillLength(slvl) + expect(Number.isFinite(cDef)).toBe(true) + expect(Number.isFinite(cDur)).toBe(true) + expect(Number.isFinite(cDmg.min)).toBe(true) + expect(Number.isFinite(cDmg.max)).toBe(true) + expect(Number.isFinite(cChl)).toBe(true) + } + }) + + it('handles degenerate synergy input shapes (undefined, empty, invalid objects)', () => { + const benignSynergies = [undefined, {}, { fake: 99 }, null as any] + for (const syn of benignSynergies) { + expect(() => calculateFrozenArmorDuration(20, syn)).not.toThrow() + expect(() => calculateShiverArmorDamage(20, syn)).not.toThrow() + expect(() => calculateChillingArmorDamage(20, syn)).not.toThrow() + expect(calculateFrozenArmorDuration(20, syn)).toBe(8700) + expect(calculateShiverArmorDamage(20, syn)).toEqual({ min: 60, max: 71.5 }) + expect(calculateChillingArmorDamage(20, syn)).toEqual({ min: 39, max: 50.5 }) + } + + // Damage functions safely default to multiplier 1.0 when negative or non-finite numbers are passed + expect(calculateShiverArmorDamage(20, -10)).toEqual({ min: 60, max: 71.5 }) + expect(calculateShiverArmorDamage(20, NaN)).toEqual({ min: 60, max: 71.5 }) + expect(calculateShiverArmorDamage(20, Infinity)).toEqual({ min: 60, max: 71.5 }) + + expect(calculateChillingArmorDamage(20, -10)).toEqual({ min: 39, max: 50.5 }) + expect(calculateChillingArmorDamage(20, NaN)).toEqual({ min: 39, max: 50.5 }) + expect(calculateChillingArmorDamage(20, Infinity)).toEqual({ min: 39, max: 50.5 }) + }) + }) + + // ========================================================================= + // Dimension 3: State Group 1 Mutual Exclusion Stress Testing + // ========================================================================= + describe('Dimension 3: State Group 1 Mutual Exclusion Stress Testing', () => { + it('strictly maintains exactly 1 active Cold Armor during rapid alternating casts', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + const armors: { name: string; id: number }[] = [ + { name: 'frozenarmor', id: 40 }, + { name: 'shiverarmor', id: 50 }, + { name: 'chillingarmor', id: 60 }, + ] + + // 30 cycles of Frozen -> Shiver -> Chilling (90 casts total) + for (let cycle = 0; cycle < 30; cycle++) { + for (const armor of armors) { + const res = bus.applyState({ + stateNameOrId: armor.name, + sourceSkillId: armor.id, + slvl: 20, + stats: { skill_armor_percent: 100 + armor.id }, + }) + expect(res.applied).toBe(true) + + // Invariant 1: Exactly 1 state with group === 1 + const activeGroup1 = bus.getActiveEntries().filter(e => e.group === 1) + expect(activeGroup1.length).toBe(1) + expect(activeGroup1[0]!.stateName).toBe(armor.name) + + // Invariant 2: hasState confirms only current armor is active + for (const other of armors) { + if (other.name === armor.name) { + expect(bus.hasState(other.name)).toBe(true) + } else { + expect(bus.hasState(other.name)).toBe(false) + } + } + + // Invariant 3: statList modifiers from previous cold armor are completely removed + const activeMods = stats.getActiveModifiers() + const armorMods = activeMods.filter(m => armors.some(a => a.name === m.stateName)) + expect(armorMods.length).toBe(1) + expect(armorMods[0]!.stateName).toBe(armor.name) + expect(armorMods[0]!.stats['skill_armor_percent']).toBe(100 + armor.id) + } + } + }) + + it('preserves single-state invariant across 100 randomized rapid casting bursts', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + const armors = ['frozenarmor', 'shiverarmor', 'chillingarmor'] + let lastCast = '' + + for (let i = 0; i < 100; i++) { + const pick = armors[Math.floor(Math.random() * armors.length)]! + bus.applyState({ + stateNameOrId: pick, + slvl: 10 + (i % 20), + }) + lastCast = pick + + const group1Entries = bus.getActiveEntries().filter(e => e.group === 1) + expect(group1Entries.length).toBe(1) + expect(group1Entries[0]!.stateName).toBe(lastCast) + } + }) + + it('enforces cross-level eviction: lower-slvl different armor evicts higher-slvl armor', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + // Start with Chilling Armor slvl 50 + bus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 50 }) + expect(bus.hasState('chillingarmor')).toBe(true) + expect(bus.getState('chillingarmor')?.slvl).toBe(50) + + // Cast Frozen Armor slvl 1 -> must EVIC Chilling Armor + bus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 1 }) + expect(bus.hasState('chillingarmor')).toBe(false) + expect(bus.hasState('frozenarmor')).toBe(true) + expect(bus.getState('frozenarmor')?.slvl).toBe(1) + + // Cast Shiver Armor slvl 1 -> must EVICT Frozen Armor + bus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 1 }) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('shiverarmor')).toBe(true) + expect(bus.getState('shiverarmor')?.slvl).toBe(1) + }) + + it('enforces same-armor level rules: higher slvl overwrites; lower slvl is rejected', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + // Apply Frozen Armor slvl 20 with 3000 frames duration + bus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20, durationFrames: 3000 }) + expect(bus.getState('frozenarmor')?.slvl).toBe(20) + + // Attempt to cast Frozen Armor slvl 5 while slvl 20 is active -> rejected + const lowerRes = bus.applyState({ + stateNameOrId: 'frozenarmor', + sourceSkillId: 40, + slvl: 5, + durationFrames: 3000, + }) + expect(lowerRes.applied).toBe(false) + expect(bus.getState('frozenarmor')?.slvl).toBe(20) + + // Cast Frozen Armor slvl 30 -> accepted and upgraded + const higherRes = bus.applyState({ + stateNameOrId: 'frozenarmor', + sourceSkillId: 40, + slvl: 30, + durationFrames: 6000, + }) + expect(higherRes.applied).toBe(true) + expect(bus.getState('frozenarmor')?.slvl).toBe(30) + }) + + it('expires cold armor cleanly on clock tick and clears group 1 state', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + bus.applyState({ + stateNameOrId: 'frozenarmor', + sourceSkillId: 40, + slvl: 1, + durationFrames: 3000, + }) + expect(bus.hasState('frozenarmor')).toBe(true) + + // Advance frame clock past 3000 + const expired = bus.tick(3001) + expect(expired).toContain('frozenarmor') + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.getActiveEntries().filter(e => e.group === 1).length).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 4: Reactive Trigger Adversarial Edge Cases + // ========================================================================= + describe('Dimension 4: Reactive Trigger Adversarial Edge Cases', () => { + describe('Frozen Armor (Skill 40) Edge Cases', () => { + it('freezes attacker when damagedinmelee with physical damage > 0', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + + const outcome = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 100 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + + expect(outcome.attackerStatesApplied).toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(true) + // Frozen armor deals 0 damage to attacker + expect(outcome.attackerColdDamage256).toBe(0) + expect(outcome.reflectedPhys256).toBe(0) + }) + + it('does NOT freeze attacker if physical damage is 0 (e.g. 100% absorbed/miss/blocked)', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + + const outcome = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 0, + attackerStateBus: attackerBus, + }) + + expect(outcome.attackerStatesApplied).not.toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(false) + }) + + it('does NOT freeze attacker if melee damage is 100% elemental with 0 physical', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + + const outcome = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 0, + incomingElem256: 500 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + + expect(outcome.attackerStatesApplied).not.toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(false) + }) + + it('does NOT trigger Frozen Armor on hitbymissile', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + + const outcome = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 200 * FIXED_ONE, + returnFire: true, + attackerStateBus: attackerBus, + }) + + expect(outcome.attackerStatesApplied).not.toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(false) + }) + }) + + describe('Shiver Armor (Skill 50) Edge Cases', () => { + it('proactively triggers on attackedinmelee BEFORE hit resolution, even if damage is 0', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 20 }) + + // Attacker begins melee swing (attackedinmelee) with 0 incoming damage + const outcome = defenderBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: 0, + attackerStateBus: attackerBus, + }) + + expect(outcome.attackerStatesApplied).toContain('cold') + expect(attackerBus.hasState('cold')).toBe(true) + expect(outcome.attackerColdDamage256).toBeGreaterThan(0) + }) + + it('does NOT trigger Shiver Armor on hitbymissile or damagedbymissile', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 20 }) + + const out1 = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 100 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + expect(out1.attackerStatesApplied).not.toContain('cold') + expect(out1.attackerColdDamage256).toBe(0) + + const out2 = defenderBus.triggerReactiveEvents({ + event: 'damagedbymissile', + incomingPhys256: 100 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + expect(out2.attackerStatesApplied).not.toContain('cold') + expect(out2.attackerColdDamage256).toBe(0) + }) + }) + + describe('Chilling Armor (Skill 60) Edge Cases', () => { + it('reflects chillingarmorbolt when struck by missile with returnFire: true', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + const outcome = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 100 * FIXED_ONE, + returnFire: true, + attackerStateBus: attackerBus, + }) + + expect(outcome.spawnedMissiles).toContain('chillingarmorbolt') + }) + + it('does NOT reflect chillingarmorbolt if incoming missile has returnFire: false', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + const outcome = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 100 * FIXED_ONE, + returnFire: false, + attackerStateBus: attackerBus, + }) + + expect(outcome.spawnedMissiles).not.toContain('chillingarmorbolt') + }) + + it('does NOT reflect chillingarmorbolt if returnFire is 0', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + + defenderBus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + const outcome = defenderBus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 100 * FIXED_ONE, + returnFire: 0, + }) + + expect(outcome.spawnedMissiles).not.toContain('chillingarmorbolt') + }) + + it('does NOT trigger Chilling Armor on damagedinmelee or attackedinmelee', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + + defenderBus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + const out1 = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 100 * FIXED_ONE, + }) + expect(out1.spawnedMissiles).not.toContain('chillingarmorbolt') + + const out2 = defenderBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: 100 * FIXED_ONE, + }) + expect(out2.spawnedMissiles).not.toContain('chillingarmorbolt') + }) + }) + }) + + // ========================================================================= + // Dimension 5: Full Invariant & System Integrity Checks + // ========================================================================= + describe('Dimension 5: Full Invariant & System Integrity Checks', () => { + it('enforces BATCH1_SKILLS length strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('verifies constant mana costs across slvl 1..99 for all three armors', () => { + for (let slvl = 1; slvl <= 99; slvl++) { + expect(getSkillManaCost(40, slvl)).toBe(7) + expect(getSkillManaCost(50, slvl)).toBe(11) + expect(getSkillManaCost(60, slvl)).toBe(17) + } + }) + + it('verifies zero cooldown ticks across all three armors', () => { + expect(getSkillCooldownTicks(40)).toBe(0) + expect(getSkillCooldownTicks(50)).toBe(0) + expect(getSkillCooldownTicks(60)).toBe(0) + }) + + it('verifies calculateSkillDamage routing consistency', () => { + for (let slvl = 1; slvl <= 30; slvl++) { + // Frozen Armor: always 0 + expect(calculateSkillDamage(40, slvl)).toEqual({ min: 0, max: 0 }) + + // Shiver Armor: matches calculateShiverArmorDamage + const expectedShiver = calculateShiverArmorDamage(slvl) + expect(calculateSkillDamage(50, slvl)).toEqual(expectedShiver) + + // Chilling Armor: matches calculateChillingArmorDamage + const expectedChilling = calculateChillingArmorDamage(slvl) + expect(calculateSkillDamage(60, slvl)).toEqual(expectedChilling) + } + }) + + it('verifies overlay and missile registrations', () => { + expect(OVERLAY_METAS['frozenarmor']).toBeDefined() + expect(OVERLAY_METAS['shiverarmor']).toBeDefined() + expect(OVERLAY_METAS['chillingarmor']).toBeDefined() + expect(OVERLAY_METAS['frozenarmor_hit']).toBeDefined() + expect(OVERLAY_METAS['shiverarmor_hit']).toBeDefined() + expect(OVERLAY_METAS['chillingarmor_hit']).toBeDefined() + expect(MISSILE_METAS['chillingarmorbolt']).toBeDefined() + + expect(getSkillCastOverlay(40)).toBe('ice_cast_1') + expect(getSkillCastOverlay(50)).toBe('ice_cast_2') + expect(getSkillCastOverlay(60)).toBe('ice_cast_3') + }) + }) +}) diff --git a/tests/skills/sor/adv-cold-m1-armors-stress.test.ts b/tests/skills/sor/adv-cold-m1-armors-stress.test.ts new file mode 100644 index 0000000..18da69f --- /dev/null +++ b/tests/skills/sor/adv-cold-m1-armors-stress.test.ts @@ -0,0 +1,683 @@ +/** + * Adversarial Test Coverage & Boundary Stress Suite — Milestone 1: Three Sorceress Cold Armors + * Skills: #040 Frozen Armor, #050 Shiver Armor, #060 Chilling Armor + * + * Playbook: test_coverage_audit (Omega Teamwork) + * Mode: Whitebox Adversarial Verification + * + * Focus Areas: + * 1. 7-Dimensional Full-Spectrum Coverage Audit: + * - D1: 1.13c Data Table Parity (Skills.txt, Missiles.txt, States.txt, Overlay.txt) + * - D2: Baked Visual Assets & Zero-Runtime MPQ Invariance + * - D3: Kinematics & Physics Simulation + * - D4: Collision, Damage Filtering & Obstacle Handling + * - D5: Synergy & Mastery Calculations (1.13c blvl hard points rule) + * - D6: Spellcasting Lifecycle & Resource Management (Mana, Cooldown, Group 1 Mutual Eviction) + * - D7: Regression & WorldArena Invariants (BATCH1_SKILLS length 38) + * + * 2. Deep Adversarial Probes: + * - Probe A: Infinite Reflection Loop Protection (Chilling Armor vs Chilling Armor, returnFire=false) + * - Probe B: Mid-Duration Armor Switching & Lingering Effect Eviction (Defense % clearing, Reactive handler replacement) + * - Probe C: Fixed-Point 256-Precision Bitrate Cold Damage & HP Arithmetic (HitShift 7 parity, 256th integer exactness) + */ + +import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +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 { WorldArena } from '../../../src/game/engine/world-arena.ts' +import { + calculateFrozenArmorDefense, + calculateFrozenArmorDuration, + calculateFrozenArmorFreezeLength, + calculateShiverArmorDefense, + calculateShiverArmorDuration, + calculateShiverArmorDamage, + calculateShiverArmorChillLength, + calculateChillingArmorDefense, + calculateChillingArmorDuration, + calculateChillingArmorDamage, + calculateChillingArmorChillLength, + calculateColdArmorDuration, + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + getSkillCastOverlay, + BATCH1_SKILLS, +} from '../../../src/game/skills.ts' +import { + FROZEN_ARMOR_OVERLAY_META, + FROZEN_ARMOR_HIT_OVERLAY_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' +import { + ICEBOLT_META, + MISSILE_METAS, +} from '../../../src/render/missiles-meta.ts' + +describe('Adversarial Test Coverage & Boundary Stress Suite — Milestone 1 Cold Armors (Skills 40, 50, 60)', () => { + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt') + const overlayTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Overlay.txt') + const statesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/States.txt') + + // ========================================================================= + // Dimension 1: 1.13c Data Table Parity & Cross-Table Relational Integrity + // ========================================================================= + describe('Dimension 1: Data Table Parity & Cross-Table Relational Integrity', () => { + it('verifies State Group 1 definition in States.txt for all 3 cold armors via registry', async () => { + const registry = await getSharedDataRegistry() + const coldStates = ['frozenarmor', 'shiverarmor', 'chillingarmor'] + for (const st of coldStates) { + const stateRec = registry.getStateByName(st) + expect(stateRec, `State ${st} must exist in registry`).toBeDefined() + expect(stateRec!.group, `State ${st} must belong to State Group 1`).toBe(1) + } + }) + + it('verifies Missiles.txt row 264 (chillingarmorbolt) vs row 59 (icebolt) ReturnFire contract', () => { + const content = fs.readFileSync(missilesTxtPath, 'latin1') + const lines = content.split('\n') + const headers = lines[0]!.split('\t').map(h => h.trim()) + const missileIdx = headers.indexOf('Missile') + const idIdx = headers.indexOf('Id') + const returnFireIdx = headers.indexOf('ReturnFire') + const hitShiftIdx = headers.indexOf('HitShift') + const velIdx = headers.indexOf('Vel') + + // icebolt (ID 59) allows return fire + const iceboltRow = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '59') + expect(iceboltRow).toBeDefined() + expect(iceboltRow![missileIdx]?.trim()).toBe('icebolt') + expect(Number(iceboltRow![returnFireIdx])).toBe(1) + + // chillingarmorbolt (ID 264) forbids return fire to prevent infinite loops + const boltRow = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '264') + expect(boltRow).toBeDefined() + expect(boltRow![missileIdx]?.trim()).toBe('chillingarmorbolt') + const rf = boltRow![returnFireIdx]?.trim() + expect(rf === '' || rf === '0' || rf === undefined).toBe(true) + expect(Number(boltRow![velIdx])).toBe(18) // Faster velocity than base icebolt (12) + expect(Number(boltRow![hitShiftIdx])).toBe(8) + }) + + it('verifies HitShift contracts across Skills 40, 50, and 60', () => { + const content = fs.readFileSync(skillsTxtPath, 'latin1') + const lines = content.split('\n') + const headers = lines[0]!.split('\t').map(h => h.trim()) + const idIdx = headers.indexOf('Id') + const hitShiftIdx = headers.indexOf('HitShift') + + const getRow = (id: string) => lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === id) + + // Skill 40 (Frozen Armor): HitShift = 8 + expect(Number(getRow('40')![hitShiftIdx])).toBe(8) + // Skill 50 (Shiver Armor): HitShift = 7 -> divide by 2 + expect(Number(getRow('50')![hitShiftIdx])).toBe(7) + // Skill 60 (Chilling Armor): HitShift = 7 -> divide by 2 + expect(Number(getRow('60')![hitShiftIdx])).toBe(7) + }) + }) + + // ========================================================================= + // Dimension 2: Baked Visual Assets & Zero-Runtime MPQ Invariance + // ========================================================================= + describe('Dimension 2: Baked Visual Assets & Zero-Runtime MPQ Invariance', () => { + it('verifies pre-baked overlay files have valid PNG magic bytes and JSON sprite atlases', () => { + const publicDir = path.resolve(__dirname, '../../../public') + const assetFiles = [ + 'overlays/FrozenArmor.png', + 'overlays/FrozenArmor.json', + 'overlays/AuraResistColdCast.png', + 'overlays/AuraResistColdCast.json', + 'missiles/icebolt.png', + 'missiles/icebolt.json', + ] + + for (const rel of assetFiles) { + const full = path.join(publicDir, rel) + expect(fs.existsSync(full), `Asset file ${rel} must exist`).toBe(true) + const stat = fs.statSync(full) + expect(stat.size, `Asset file ${rel} must not be empty`).toBeGreaterThan(100) + + if (rel.endsWith('.png')) { + const buf = fs.readFileSync(full) + // PNG magic bytes: 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A + expect(buf[0]).toBe(0x89) + expect(buf[1]).toBe(0x50) // P + expect(buf[2]).toBe(0x4E) // N + expect(buf[3]).toBe(0x47) // G + } else if (rel.endsWith('.json')) { + const content = JSON.parse(fs.readFileSync(full, 'utf8')) + expect(content.frames || content.framesPerDirection || content.directions).toBeTruthy() + } + } + }) + + it('verifies typed metadata registration in OVERLAY_METAS and MISSILE_METAS', () => { + // Overlays + expect(OVERLAY_METAS['frozenarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) + expect(OVERLAY_METAS['shiverarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) + expect(OVERLAY_METAS['chillingarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) + expect(OVERLAY_METAS['frozenarmor_hit']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) + expect(OVERLAY_METAS['shiverarmor_hit']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) + expect(OVERLAY_METAS['chillingarmor_hit']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) + + // Missiles + expect(MISSILE_METAS['chillingarmorbolt']).toBe(ICEBOLT_META) + + // Cast overlays + expect(getSkillCastOverlay(40)).toBe('ice_cast_1') + expect(getSkillCastOverlay(50)).toBe('ice_cast_2') + expect(getSkillCastOverlay(60)).toBe('ice_cast_3') + }) + }) + + // ========================================================================= + // Dimension 3 & 4: Kinematics, Physics & Damage Filtering Stress + // ========================================================================= + describe('Dimension 3 & 4: Kinematics, Physics & Damage Filtering Stress', () => { + it('Frozen Armor: strictly requires physical damage > 0 to freeze', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + + // Case 1: 0 phys damage, 1000 fire damage -> NO freeze + let out = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 0, + incomingElem256: 1000 * FIXED_ONE, + elemType: 'fire', + attackerStateBus: attackerBus, + }) + expect(out.attackerStatesApplied).not.toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(false) + + // Case 2: 1 unit of physical damage (1/256) -> DOES freeze + out = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 1, // 1/256th of 1 point of damage + incomingElem256: 0, + attackerStateBus: attackerBus, + }) + expect(out.attackerStatesApplied).toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(true) + }) + + it('Shiver Armor: triggers proactively on attack declaration regardless of attack success', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 10 }) + + // Attacker initiates swing that deals 0 damage (missed / dodged / blocked) + const out = defenderBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: 0, + attackerStateBus: attackerBus, + }) + expect(out.attackerStatesApplied).toContain('cold') + expect(attackerBus.hasState('cold')).toBe(true) + expect(out.attackerColdDamage256).toBeGreaterThan(0) + }) + + it('Chilling Armor: ignores melee damage and melee attacks completely', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + // 1. Struck in melee with massive damage + const outMelee = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 10000 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + expect(outMelee.spawnedMissiles.length).toBe(0) + + // 2. Attacked in melee + const outAttack = defenderBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: 10000 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + expect(outAttack.spawnedMissiles.length).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 5: Synergy & 1.13c Blvl Invariant + // ========================================================================= + describe('Dimension 5: Synergy & 1.13c Blvl Invariant', () => { + it('strictly enforces hard points rule: +allskills and charges give zero synergy bonus', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(50)! // Shiver Armor + + // Hard points only (20 in Frozen Armor, 20 in Chilling Armor) + const statsHard = new UnitStatList() + statsHard.setBaseSkillLevel(50, 20) + statsHard.setBaseSkillLevel(40, 20) + statsHard.setBaseSkillLevel(60, 20) + const resHard = skillRec ? skillRec : null + expect(resHard).toBeDefined() + + const dmgHard = calculateShiverArmorDamage(20, { frozenArmor: 20, chillingArmor: 20 }) + expect(dmgHard.min).toBe(276) + expect(dmgHard.max).toBe(328.9) + + // Soft skills (+20 all skills, +33 charged skill levels) + const statsSoft = new UnitStatList() + statsSoft.setBaseSkillLevel(50, 20) + statsSoft.setBonusSkillLevel(40, 20) + statsSoft.setBonusSkillLevel(60, 20) + statsSoft.setChargedSkillLevel(40, 33) + statsSoft.setChargedSkillLevel(60, 33) + + // In 1.13c, soft skills must NOT contribute to synergies + const dmgSoft = calculateShiverArmorDamage(20, { + frozenArmor: statsSoft.getBaseSkillLevel(40), + chillingArmor: statsSoft.getBaseSkillLevel(60), + }) + // Without hard points, base damage only: 60 - 71.5 + expect(dmgSoft.min).toBe(60) + expect(dmgSoft.max).toBe(71.5) + }) + + it('validates mathematical epsilon parity (+1e-9) prevents IEEE-754 floor truncation', () => { + // 50.5 * 3.8 = 191.89999999999998 in standard JS IEEE-754 floating point arithmetic! + const rawProduct = 50.5 * 3.8 + expect(rawProduct).toBeLessThan(191.9) + // Without epsilon, Math.floor(rawProduct * 10) / 10 would erroneously yield 191.8 + expect(Math.floor(rawProduct * 10) / 10).toBe(191.8) + + // With authentic epsilon guard (+ 1e-9), calculateChillingArmorDamage produces authentic 191.9 + const dmg = calculateChillingArmorDamage(20, { frozenArmor: 20, shiverArmor: 20 }) + expect(dmg.max).toBe(191.9) + }) + }) + + // ========================================================================= + // Dimension 6 & 7: Lifecycle, Resource Management & BATCH1 Invariants + // ========================================================================= + describe('Dimension 6 & 7: Lifecycle, Resource Management & BATCH1 Invariants', () => { + it('enforces constant mana costs and zero cooldowns for all 3 armors', () => { + expect(getSkillManaCost(40, 1)).toBe(7) + expect(getSkillManaCost(40, 99)).toBe(7) + expect(getSkillCooldownTicks(40)).toBe(0) + + expect(getSkillManaCost(50, 1)).toBe(11) + expect(getSkillManaCost(50, 99)).toBe(11) + expect(getSkillCooldownTicks(50)).toBe(0) + + expect(getSkillManaCost(60, 1)).toBe(17) + expect(getSkillManaCost(60, 99)).toBe(17) + expect(getSkillCooldownTicks(60)).toBe(0) + }) + + it('enforces BATCH1_SKILLS length invariant strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + }) + + // ========================================================================= + // Deep Adversarial Probe A: Infinite Reflection Loop Protection + // ========================================================================= + describe('Deep Adversarial Probe A: Infinite Reflection Loop Protection', () => { + it('terminates Chilling Armor retaliation loop after exactly 1 cycle (depth <= 1)', async () => { + const registry = await getSharedDataRegistry() + // Entity A (Player Sorceress) with Chilling Armor + const statsA = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const busA = new StateBus(statsA, registry) + busA.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + // Entity B (Enemy Sorceress / Monster with Chilling Armor) + const statsB = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const busB = new StateBus(statsB, registry) + busB.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + // Step 1: An initial arrow (returnFire: true) hits Entity B + const initialHit = busB.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 100 * FIXED_ONE, + returnFire: true, + attackerStateBus: busA, + }) + expect(initialHit.spawnedMissiles).toContain('chillingarmorbolt') + expect(initialHit.spawnedMissiles.length).toBe(1) + + // Step 2: The spawned chillingarmorbolt travels and hits Entity A. + // Lookup chillingarmorbolt in data registry: + const boltRec = registry.getMissileByName('chillingarmorbolt')! + expect(boltRec).toBeDefined() + expect(boltRec.returnFire).toBe(false) // Must be false per 1.13c Missiles.txt! + + // Step 3: chillingarmorbolt impacts Entity A with returnFire: boltRec.returnFire (false) + const retaliatoryHit = busA.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 150 * FIXED_ONE, + returnFire: boltRec.returnFire, + attackerStateBus: busB, + }) + + // Must NOT spawn another missile — loop terminates immediately! + expect(retaliatoryHit.spawnedMissiles.length).toBe(0) + }) + + it('simulates a 100-cycle ping-pong loop test verifying zero missile explosion cascades', async () => { + const registry = await getSharedDataRegistry() + const statsA = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE }) + const statsB = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE }) + const busA = new StateBus(statsA, registry) + const busB = new StateBus(statsB, registry) + busA.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + busB.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + let totalMissilesSpawned = 0 + const boltRec = registry.getMissileByName('chillingarmorbolt')! + + // Simulate 100 consecutive incoming missiles with returnFire: false + for (let i = 0; i < 100; i++) { + const outA = busA.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 50 * FIXED_ONE, + returnFire: boltRec.returnFire, // false + attackerStateBus: busB, + }) + totalMissilesSpawned += outA.spawnedMissiles.length + + const outB = busB.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 50 * FIXED_ONE, + returnFire: boltRec.returnFire, // false + attackerStateBus: busA, + }) + totalMissilesSpawned += outB.spawnedMissiles.length + } + + // Zero missiles must be spawned from non-returnFire impacts + expect(totalMissilesSpawned).toBe(0) + }) + + it('handles concurrent barrage of 100 missiles with mixed returnFire flags deterministically', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 10000 * FIXED_ONE, hitpoints: 10000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + bus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + let retTrueCount = 0 + let retFalseCount = 0 + let missilesProduced = 0 + + // 100 mixed missiles: even indices have returnFire: true, odd have false + for (let i = 0; i < 100; i++) { + const rf = i % 2 === 0 + if (rf) retTrueCount++ + else retFalseCount++ + + const out = bus.triggerReactiveEvents({ + event: 'hitbymissile', + incomingPhys256: 10 * FIXED_ONE, + returnFire: rf, + }) + missilesProduced += out.spawnedMissiles.length + } + + expect(retTrueCount).toBe(50) + expect(retFalseCount).toBe(50) + expect(missilesProduced).toBe(50) // Exactly 50 retaliations for 50 returnFire: true missiles + }) + }) + + // ========================================================================= + // Deep Adversarial Probe B: Mid-Duration Armor Switching & Lingering Effects + // ========================================================================= + describe('Deep Adversarial Probe B: Mid-Duration Armor Switching & Lingering Effects', () => { + it('instantly removes previous armor defense modifiers upon switching armors', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { armorclass: 100, maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + // Step 1: Base defense is 100 + expect(stats.getAccruedStat('armorclass')).toBe(100) + + // Step 2: Cast Frozen Armor (slvl 20) -> +125% defense + bus.applyState({ + stateNameOrId: 'frozenarmor', + sourceSkillId: 40, + slvl: 20, + stats: { skill_armor_percent: calculateFrozenArmorDefense(20) }, // 125 + }) + expect(bus.hasState('frozenarmor')).toBe(true) + expect(stats.getModifierBonus('skill_armor_percent')).toBe(125) + // 100 * (100 + 125) / 100 = 225 + expect(stats.getAccruedStat('armorclass')).toBe(225) + + // Step 3: Mid-duration switch to Shiver Armor (slvl 1) -> +45% defense + // Must evict Frozen Armor completely: defense must become 145, NOT 100 + 125 + 45 = 270! + bus.applyState({ + stateNameOrId: 'shiverarmor', + sourceSkillId: 50, + slvl: 1, + stats: { skill_armor_percent: calculateShiverArmorDefense(1) }, // 45 + }) + expect(bus.hasState('frozenarmor')).toBe(false) + expect(bus.hasState('shiverarmor')).toBe(true) + expect(stats.getModifierBonus('skill_armor_percent')).toBe(45) + expect(stats.getAccruedStat('armorclass')).toBe(145) + + // Step 4: Mid-duration switch to Chilling Armor (slvl 10) -> +90% defense + bus.applyState({ + stateNameOrId: 'chillingarmor', + sourceSkillId: 60, + slvl: 10, + stats: { skill_armor_percent: calculateChillingArmorDefense(10) }, // 90 + }) + expect(bus.hasState('shiverarmor')).toBe(false) + expect(bus.hasState('chillingarmor')).toBe(true) + expect(stats.getModifierBonus('skill_armor_percent')).toBe(90) + expect(stats.getAccruedStat('armorclass')).toBe(190) + + // Step 5: Remove armor -> defense resets to baseline 100 + bus.removeState('chillingarmor') + expect(bus.hasState('chillingarmor')).toBe(false) + expect(stats.getModifierBonus('skill_armor_percent')).toBe(0) + expect(stats.getAccruedStat('armorclass')).toBe(100) + }) + + it('ensures no lingering reactive event triggers from previous armors after switching', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + // Start with Frozen Armor + defenderBus.applyState({ stateNameOrId: 'frozenarmor', sourceSkillId: 40, slvl: 20 }) + + // Immediately switch to Chilling Armor (Skill 60) + defenderBus.applyState({ stateNameOrId: 'chillingarmor', sourceSkillId: 60, slvl: 20 }) + + // Attacker strikes with melee physical damage -> Chilling Armor does NOT react to melee! + // If Frozen Armor lingered, attacker would be frozen solid. + const out = defenderBus.triggerReactiveEvents({ + event: 'damagedinmelee', + incomingPhys256: 200 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + + expect(out.attackerStatesApplied).not.toContain('freeze') + expect(attackerBus.hasState('freeze')).toBe(false) + expect(out.spawnedMissiles.length).toBe(0) + }) + + it('stress tests 100 rapid armor switches in tight loop verifying zero orphaned state entries', async () => { + const registry = await getSharedDataRegistry() + const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + + const armors = [ + { name: 'frozenarmor', id: 40, def: 125 }, + { name: 'shiverarmor', id: 50, def: 159 }, + { name: 'chillingarmor', id: 60, def: 140 }, + ] + + for (let i = 0; i < 100; i++) { + const armor = armors[i % 3]! + bus.applyState({ + stateNameOrId: armor.name, + sourceSkillId: armor.id, + slvl: 20, + stats: { skill_armor_percent: armor.def }, + }) + + // Invariant: at any point, exactly 1 Group 1 armor state can exist in entries + const group1Entries = bus.getActiveEntries().filter(e => e.group === 1 || ['frozenarmor', 'shiverarmor', 'chillingarmor'].includes(e.stateName)) + expect(group1Entries.length).toBe(1) + expect(group1Entries[0]!.stateName).toBe(armor.name) + expect(stats.getModifierBonus('skill_armor_percent')).toBe(armor.def) + } + }) + }) + + // ========================================================================= + // Deep Adversarial Probe C: Fixed-Point 256-Precision Bitrate Cold Damage + // ========================================================================= + describe('Deep Adversarial Probe C: Fixed-Point 256-Precision Bitrate Cold Damage', () => { + it('verifies exact integer 256-bitrate scaling across all 60 skill levels and synergies for Shiver Armor', () => { + // EMin: 12, EMax: 16, HitShift: 7 + for (let slvl = 1; slvl <= 60; slvl++) { + for (const synPoints of [0, 10, 20, 40]) { + const dmg = calculateShiverArmorDamage(slvl, synPoints) + expect(dmg.min).toBeGreaterThan(0) + expect(dmg.max).toBeGreaterThanOrEqual(dmg.min) + expect(Number.isFinite(dmg.min)).toBe(true) + expect(Number.isFinite(dmg.max)).toBe(true) + + // In 256-bitrate integer arithmetic: + const min256 = Math.round(dmg.min * FIXED_ONE) + const max256 = Math.round(dmg.max * FIXED_ONE) + expect(Number.isInteger(min256)).toBe(true) + expect(Number.isInteger(max256)).toBe(true) + } + } + }) + + it('verifies exact HitShift 7 bitrate conversion for base Shiver Armor (Skill 50)', () => { + // Level 1: EMin=12, EMax=16. HitShift 7 -> 12 * 128 = 1536 (6.0), 16 * 128 = 2048 (8.0) + const dmg1 = calculateShiverArmorDamage(1, 0) + expect(dmg1.min * 256).toBe(12 << 7) + expect(dmg1.max * 256).toBe(16 << 7) + + // Level 10: rawMin = 52, rawMax = 65 -> 52 << 7 = 6656 (26.0), 65 << 7 = 8320 (32.5) + const dmg10 = calculateShiverArmorDamage(10, 0) + expect(dmg10.min * 256).toBe(52 << 7) + expect(dmg10.max * 256).toBe(65 << 7) + + // Level 20: rawMin = 120, rawMax = 143 -> 120 << 7 = 15360 (60.0), 143 << 7 = 18304 (71.5) + const dmg20 = calculateShiverArmorDamage(20, 0) + expect(dmg20.min * 256).toBe(120 << 7) + expect(dmg20.max * 256).toBe(143 << 7) + }) + + it('verifies exact HitShift 7 bitrate conversion for base Chilling Armor (Skill 60)', () => { + // Level 1: EMin=8, EMax=12. HitShift 7 -> 8 * 128 = 1024 (4.0), 12 * 128 = 1536 (6.0) + const dmg1 = calculateChillingArmorDamage(1, 0) + expect(dmg1.min * 256).toBe(8 << 7) + expect(dmg1.max * 256).toBe(12 << 7) + + // Level 10: rawMin = 30, rawMax = 43 -> 30 << 7 = 3840 (15.0), 43 << 7 = 5504 (21.5) + const dmg10 = calculateChillingArmorDamage(10, 0) + expect(dmg10.min * 256).toBe(30 << 7) + expect(dmg10.max * 256).toBe(43 << 7) + + // Level 20: rawMin = 78, rawMax = 101 -> 78 << 7 = 9984 (39.0), 101 << 7 = 12928 (50.5) + const dmg20 = calculateChillingArmorDamage(20, 0) + expect(dmg20.min * 256).toBe(78 << 7) + expect(dmg20.max * 256).toBe(101 << 7) + }) + + it('simulates 100 consecutive Shiver Armor hits verifying zero floating point HP drift', async () => { + const registry = await getSharedDataRegistry() + const defenderStats = new UnitStatList(registry, { maxhp: 10000 * FIXED_ONE, hitpoints: 10000 * FIXED_ONE }) + const attackerStats = new UnitStatList(registry, { maxhp: 500000 * FIXED_ONE, hitpoints: 500000 * FIXED_ONE }) + const defenderBus = new StateBus(defenderStats, registry) + const attackerBus = new StateBus(attackerStats, registry) + + defenderBus.applyState({ stateNameOrId: 'shiverarmor', sourceSkillId: 50, slvl: 20 }) + + let totalDamageDeducted256 = 0 + const initialHp256 = attackerStats.getHp256() + + for (let i = 0; i < 100; i++) { + const out = defenderBus.triggerReactiveEvents({ + event: 'attackedinmelee', + incomingPhys256: 100 * FIXED_ONE, + attackerStateBus: attackerBus, + }) + const dmg256 = out.attackerColdDamage256 + expect(Number.isInteger(dmg256)).toBe(true) + attackerStats.setHp256(attackerStats.getHp256() - dmg256) + totalDamageDeducted256 += dmg256 + } + + // Assert HP arithmetic is exact to the 256th bit + expect(attackerStats.getHp256()).toBe(initialHp256 - totalDamageDeducted256) + }) + }) + + // ========================================================================= + // Fuzzing & Degenerate Input Boundary Testing + // ========================================================================= + describe('Fuzzing & Degenerate Input Boundary Testing', () => { + it('gracefully handles boundary and degenerate slvl values without crashes or NaN', () => { + const degenerateSlvls = [-10, 0, 0.5, 1, 99, 500, NaN, Infinity, -Infinity] + + for (const slvl of degenerateSlvls) { + expect(() => calculateFrozenArmorDefense(slvl)).not.toThrow() + expect(() => calculateFrozenArmorDuration(slvl)).not.toThrow() + expect(() => calculateFrozenArmorFreezeLength(slvl)).not.toThrow() + + expect(() => calculateShiverArmorDefense(slvl)).not.toThrow() + expect(() => calculateShiverArmorDuration(slvl)).not.toThrow() + expect(() => calculateShiverArmorDamage(slvl)).not.toThrow() + expect(() => calculateShiverArmorChillLength(slvl)).not.toThrow() + + expect(() => calculateChillingArmorDefense(slvl)).not.toThrow() + expect(() => calculateChillingArmorDuration(slvl)).not.toThrow() + expect(() => calculateChillingArmorDamage(slvl)).not.toThrow() + expect(() => calculateChillingArmorChillLength(slvl)).not.toThrow() + + const dmgS = calculateShiverArmorDamage(slvl) + expect(Number.isFinite(dmgS.min)).toBe(true) + expect(Number.isFinite(dmgS.max)).toBe(true) + + const dmgC = calculateChillingArmorDamage(slvl) + expect(Number.isFinite(dmgC.min)).toBe(true) + expect(Number.isFinite(dmgC.max)).toBe(true) + } + }) + + it('gracefully handles degenerate synergy inputs', () => { + const degenerateSyns = [-50, 0, 1000, NaN, undefined, null, {} as any] + + for (const syn of degenerateSyns) { + expect(() => calculateShiverArmorDamage(20, syn)).not.toThrow() + expect(() => calculateChillingArmorDamage(20, syn)).not.toThrow() + expect(() => calculateFrozenArmorDuration(20, syn)).not.toThrow() + expect(() => calculateFrozenArmorFreezeLength(20, syn)).not.toThrow() + } + }) + }) +}) diff --git a/tests/skills/sor/cold-armors-remediation.test.ts b/tests/skills/sor/cold-armors-remediation.test.ts new file mode 100644 index 0000000..d913238 --- /dev/null +++ b/tests/skills/sor/cold-armors-remediation.test.ts @@ -0,0 +1,336 @@ +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 { + 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') + }) + }) +})