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