fix(sor-cold): remediate number overload contract and wire reactive combat hooks (Issue #194, #204, #214)
- Fix calculateShiverArmorDamage and calculateChillingArmorDamage number overload to treat number as allocated synergy points (synPoints) with 1.13c percentage scaling, supporting explicit options.synMultiplier override - Guard duration and chill/freeze length calculators against negative synergy inputs with Math.max(0, synPoints) - Wire reactive combat hooks in combat.ts (tickMonsters/updateMonsters and hitPlayerWithMissile): * Frozen Armor: reactively freezes attacking monster upon taking physical melee damage * Shiver Armor: proactively chills and damages attacking monster upon melee attack initiation * Chilling Armor: reflects retaliatory chillingarmorbolt on incoming ranged missiles with returnFire=false - Wire proactive and reactive combat events in combat-pipeline.ts (executeSUnitDmg): * Dispatches attackedinmelee proactively before hit resolution * Dispatches damagedinmelee post-damage on melee hits * Dispatches hitbymissile post-damage on missile hits * Exposes attackerStatesApplied, attackerColdDamage256, reflectedPhys256, and spawnedMissiles on SUnitDmgOutcome - Add comprehensive remediation test suite tests/skills/sor/cold-armors-remediation.test.ts
This commit is contained in:
parent
e7d105dce0
commit
2d6bbb93a2
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>): Monster {
|
||||
return {
|
||||
index: 0,
|
||||
x: 10,
|
||||
y: 10,
|
||||
hp: 100,
|
||||
cooldown: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
stats: {
|
||||
id: 'fallen',
|
||||
name: 'Fallen',
|
||||
hp: 100,
|
||||
damage: 15,
|
||||
cooldownTicks: 25,
|
||||
reach: 40, // melee
|
||||
aggroRadius: 200,
|
||||
speed: 100,
|
||||
xp: 10,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('verifies updateMonsters is alias of tickMonsters', () => {
|
||||
expect(updateMonsters).toBe(tickMonsters)
|
||||
})
|
||||
|
||||
it('proactively chills and damages monster when Shiver Armor player is attacked in melee', () => {
|
||||
const world = createWorld(10, 10, 500, 500)
|
||||
world.player.coldArmorType = 'shiverarmor'
|
||||
world.player.coldArmorSlvl = 20
|
||||
|
||||
const monster = makeMonster({ x: 10, y: 30 }) // distance = 20 <= reach 40
|
||||
world.monsters = [monster]
|
||||
|
||||
// tickMonsters: monster initiates attack on player
|
||||
tickMonsters(world, DEFAULT_OPTIONS, OPEN, world.players)
|
||||
|
||||
// Monster should be chilled and have taken cold damage
|
||||
expect(monster.chillTicks).toBeGreaterThan(0)
|
||||
expect(monster.hp).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('freezes monster reactively when hit lands on Frozen Armor player with physical damage', () => {
|
||||
const world = createWorld(10, 10, 500, 500)
|
||||
world.player.coldArmorType = 'frozenarmor'
|
||||
world.player.coldArmorSlvl = 20
|
||||
|
||||
const monster = makeMonster({ x: 10, y: 30 }) // within reach 40
|
||||
world.monsters = [monster]
|
||||
|
||||
tickMonsters(world, DEFAULT_OPTIONS, OPEN, world.players)
|
||||
expect(monster.frozenTicks).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('retaliates with chillingarmorbolt when Chilling Armor player is hit by missile', () => {
|
||||
const world = createWorld(10, 10, 500, 500)
|
||||
world.player.coldArmorType = 'chillingarmor'
|
||||
world.player.coldArmorSlvl = 20
|
||||
|
||||
const attacker = makeMonster({ index: 0, x: 100, y: 10 })
|
||||
world.monsters = [attacker]
|
||||
|
||||
const ret = hitPlayerWithMissile(
|
||||
world,
|
||||
{
|
||||
damage: 15,
|
||||
attackerIndex: 0,
|
||||
returnFire: true,
|
||||
},
|
||||
world.player,
|
||||
)
|
||||
|
||||
expect(ret.reflected).toBe(true)
|
||||
expect(ret.retaliatoryMissile).toBe('chillingarmorbolt')
|
||||
expect(world.events.some(e => e.kind === 'missileHit' && e.missileType === 'chillingarmorbolt')).toBe(true)
|
||||
expect(attacker.chillTicks).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does NOT retaliate if missile has returnFire = false', () => {
|
||||
const world = createWorld(10, 10, 500, 500)
|
||||
world.player.coldArmorType = 'chillingarmor'
|
||||
world.player.coldArmorSlvl = 20
|
||||
|
||||
const attacker = makeMonster({ index: 0, x: 100, y: 10 })
|
||||
world.monsters = [attacker]
|
||||
|
||||
const ret = hitPlayerWithMissile(
|
||||
world,
|
||||
{
|
||||
damage: 15,
|
||||
attackerIndex: 0,
|
||||
returnFire: false,
|
||||
},
|
||||
world.player,
|
||||
)
|
||||
|
||||
expect(ret.reflected).toBe(false)
|
||||
expect(ret.retaliatoryMissile).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Finding 3: Reactive Combat Pipeline Wiring in combat-pipeline.ts', () => {
|
||||
async function makeCombatant(id: string, name: string): Promise<CombatUnitContext> {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const statList = new UnitStatList(registry, {
|
||||
maxhp: 1000 * FIXED_ONE,
|
||||
hitpoints: 1000 * FIXED_ONE,
|
||||
tohit: 1000,
|
||||
armorclass: 100,
|
||||
level: 20,
|
||||
})
|
||||
const stateBus = new StateBus(statList, registry)
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
statList,
|
||||
stateBus,
|
||||
weaponMinPhys: 20,
|
||||
weaponMaxPhys: 40,
|
||||
}
|
||||
}
|
||||
|
||||
it('dispatches attackedinmelee proactively and damages attacker with Shiver Armor', async () => {
|
||||
const attacker = await makeCombatant('att1', 'Attacking Monster')
|
||||
const defender = await makeCombatant('def1', 'Sorceress')
|
||||
|
||||
// Apply Shiver Armor state 88 to defender
|
||||
defender.stateBus.applyState({
|
||||
stateNameOrId: 'shiverarmor',
|
||||
sourceSkillId: 50,
|
||||
slvl: 20,
|
||||
})
|
||||
|
||||
const packet: SUnitDmgPacket = {
|
||||
skillId: 0,
|
||||
attackKind: 'melee',
|
||||
physDamagePct: 0,
|
||||
flatPhysMin256: 20 * FIXED_ONE,
|
||||
flatPhysMax256: 40 * FIXED_ONE,
|
||||
}
|
||||
|
||||
const outcome = executeSUnitDmg(attacker, defender, packet)
|
||||
|
||||
// Attacker should receive chill state and cold damage
|
||||
expect(outcome.attackerColdDamage256).toBeDefined()
|
||||
expect(outcome.attackerColdDamage256).toBeGreaterThan(0)
|
||||
expect(outcome.attackerStatesApplied).toBeDefined()
|
||||
expect(outcome.attackerStatesApplied).toContain('cold')
|
||||
})
|
||||
|
||||
it('dispatches damagedinmelee post-damage and freezes attacker with Frozen Armor', async () => {
|
||||
const attacker = await makeCombatant('att2', 'Attacking Monster')
|
||||
const defender = await makeCombatant('def2', 'Sorceress')
|
||||
|
||||
// Apply Frozen Armor state 10 to defender
|
||||
defender.stateBus.applyState({
|
||||
stateNameOrId: 'frozenarmor',
|
||||
sourceSkillId: 40,
|
||||
slvl: 20,
|
||||
})
|
||||
|
||||
const packet: SUnitDmgPacket = {
|
||||
skillId: 0,
|
||||
attackKind: 'melee',
|
||||
flatPhysMin256: 20 * FIXED_ONE,
|
||||
flatPhysMax256: 40 * FIXED_ONE,
|
||||
autoHit: true,
|
||||
}
|
||||
|
||||
const outcome = executeSUnitDmg(attacker, defender, packet)
|
||||
|
||||
// Attacker should be frozen
|
||||
expect(outcome.attackerStatesApplied).toBeDefined()
|
||||
expect(outcome.attackerStatesApplied).toContain('freeze')
|
||||
expect(attacker.stateBus.hasState('freeze')).toBe(true)
|
||||
})
|
||||
|
||||
it('dispatches hitbymissile and spawns retaliatory chillingarmorbolt with Chilling Armor', async () => {
|
||||
const attacker = await makeCombatant('att3', 'Archer Monster')
|
||||
const defender = await makeCombatant('def3', 'Sorceress')
|
||||
|
||||
// Apply Chilling Armor state 20 to defender
|
||||
defender.stateBus.applyState({
|
||||
stateNameOrId: 'chillingarmor',
|
||||
sourceSkillId: 60,
|
||||
slvl: 20,
|
||||
})
|
||||
|
||||
const packet: SUnitDmgPacket = {
|
||||
skillId: 0,
|
||||
attackKind: 'missile',
|
||||
flatPhysMin256: 20 * FIXED_ONE,
|
||||
flatPhysMax256: 40 * FIXED_ONE,
|
||||
returnFire: true,
|
||||
autoHit: true,
|
||||
}
|
||||
|
||||
const outcome = executeSUnitDmg(attacker, defender, packet)
|
||||
|
||||
// Spawns retaliatory chillingarmorbolt
|
||||
expect(outcome.spawnedMissiles).toBeDefined()
|
||||
expect(outcome.spawnedMissiles?.length).toBe(1)
|
||||
expect(outcome.spawnedMissiles?.[0]).toBe('chillingarmorbolt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invariant Integrity', () => {
|
||||
it('strictly maintains BATCH1_SKILLS length of 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
expect(BATCH1_SKILLS).not.toHaveProperty('40')
|
||||
expect(BATCH1_SKILLS).not.toHaveProperty('50')
|
||||
expect(BATCH1_SKILLS).not.toHaveProperty('60')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue