349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
/**
|
|
* Milestone M7: Paladin Combat Skills Tree Adversarial Stress Verification Suite
|
|
*
|
|
* Covers 5 mission-critical stress scenarios:
|
|
* 1. Rapid Hammer Spam Spiral Geometry:
|
|
* Spawns 20 hammers simultaneously; asserts zero NaN coordinates, valid 2:1 aspect ratio across all frames,
|
|
* and correct spiral radius bounds (r = a * theta).
|
|
* 2. Holy Bolt Multi-Target Density Discrimination:
|
|
* Line of mixed targets: [Undead, Demon, Beast, Friendly Player, Undead].
|
|
* Asserts Holy Bolt damages first Undead, ignores Beast/Demon, and heals Player when friendly-targeted.
|
|
* 3. Sacrifice Extreme Overkill Self-Damage Safety:
|
|
* Paladin deals 50,000 damage with 10 HP remaining; asserts Paladin HP is clamped to 1, preventing death from self-damage.
|
|
* 4. Smite vs High-Defense Unblockable Invariant:
|
|
* Smite vs monster with 99,999 defense and 75% block rate; asserts 100% hit rate and 0% blocked attacks across 100 iterations.
|
|
* 5. Conversion Allegiance Cleanup:
|
|
* Converts 10 monsters; simulates 400 frames; asserts 100% of converted monsters cleanly return to non-converted state
|
|
* with zero state/memory leaks.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
ISO_GROUND_ASPECT_RATIO,
|
|
calculateSacrificeStats,
|
|
calculateSmiteStats,
|
|
calculateConversionStats,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
executeSUnitDmg,
|
|
type CombatUnitContext,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
|
|
describe('Milestone M7 — Paladin Combat Skills Tree Adversarial Stress Suite', () => {
|
|
it('1. Rapid Hammer Spam Spiral Geometry: 20 simultaneous hammers, no NaN coords, 2:1 iso ratio', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const engine = new MissileEngine(registry)
|
|
|
|
const casterStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
|
|
const caster: CombatUnitContext = {
|
|
id: 'hammer-spammer',
|
|
name: 'Paladin',
|
|
statList: casterStats,
|
|
stateBus: new StateBus(casterStats, registry),
|
|
}
|
|
|
|
const spawnedHammers = []
|
|
const originX = 100
|
|
const originY = 200
|
|
|
|
// Spawn 20 hammers simultaneously with staggered initial angles
|
|
for (let i = 0; i < 20; i++) {
|
|
const angle = (i / 20) * Math.PI * 2
|
|
const m = engine.spawnMissile({
|
|
missileNameOrId: 'blessedhammer',
|
|
sourceSkillId: 112,
|
|
slvl: 20,
|
|
owner: caster,
|
|
startX: originX,
|
|
startY: originY,
|
|
targetX: originX + Math.cos(angle) * 100,
|
|
targetY: originY + Math.sin(angle) * 50,
|
|
dmgPacket: {
|
|
skillId: 112,
|
|
elemType: 'mag',
|
|
elemMin256: 500 * FIXED_ONE,
|
|
elemMax256: 600 * FIXED_ONE,
|
|
},
|
|
})
|
|
expect(m).toBeDefined()
|
|
if (m) {
|
|
m.spiralTheta = angle
|
|
spawnedHammers.push(m)
|
|
}
|
|
}
|
|
|
|
expect(spawnedHammers.length).toBe(20)
|
|
|
|
// Simulate 120 frames (standard hammer lifetime)
|
|
for (let frame = 1; frame <= 120; frame++) {
|
|
engine.tick(frame, [], new Map())
|
|
|
|
for (const h of spawnedHammers) {
|
|
expect(Number.isFinite(h.x)).toBe(true)
|
|
expect(Number.isFinite(h.y)).toBe(true)
|
|
expect(Number.isNaN(h.x)).toBe(false)
|
|
expect(Number.isNaN(h.y)).toBe(false)
|
|
|
|
// Radial bounds test: radius expands monotonically as theta increases
|
|
const dx = h.x - originX
|
|
const dy = (h.y - originY) / ISO_GROUND_ASPECT_RATIO
|
|
const currentR = Math.hypot(dx, dy)
|
|
expect(currentR).toBeGreaterThanOrEqual(11.9)
|
|
expect(currentR).toBeLessThanOrEqual(Math.max(12, 13 * (h.spiralTheta ?? 0)) + 0.1)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('2. Holy Bolt Multi-Target Density Discrimination: [Undead, Demon, Beast, Friendly Player, Undead]', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const engine = new MissileEngine(registry)
|
|
|
|
const casterStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
|
|
casterStats.setBaseSkillLevel(99, 10) // Prayer synergy
|
|
const caster: CombatUnitContext = {
|
|
id: 'caster-paladin',
|
|
name: 'Paladin',
|
|
statList: casterStats,
|
|
stateBus: new StateBus(casterStats, registry),
|
|
}
|
|
|
|
// Dense row of 5 units positioned along x axis: [Undead1, Demon, Beast, FriendlyAlly, Undead2]
|
|
const u1Stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, magicresist: 0 })
|
|
const u1: CombatUnitContext = {
|
|
id: 'undead-1',
|
|
name: 'Skeleton',
|
|
statList: u1Stats,
|
|
stateBus: new StateBus(u1Stats, registry),
|
|
isUndead: true,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const demonStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
|
|
const demon: CombatUnitContext = {
|
|
id: 'demon-2',
|
|
name: 'Fallen',
|
|
statList: demonStats,
|
|
stateBus: new StateBus(demonStats, registry),
|
|
isUndead: false,
|
|
isDemon: true,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const beastStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
|
|
const beast: CombatUnitContext = {
|
|
id: 'beast-3',
|
|
name: 'Quill Rat',
|
|
statList: beastStats,
|
|
stateBus: new StateBus(beastStats, registry),
|
|
isUndead: false,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const allyStats = new UnitStatList(registry, { hitpoints: 200 * FIXED_ONE, maxhp: 1000 * FIXED_ONE })
|
|
const ally: CombatUnitContext = {
|
|
id: 'ally-4',
|
|
name: 'Mercenary',
|
|
statList: allyStats,
|
|
stateBus: new StateBus(allyStats, registry),
|
|
unitType: 'mercenary',
|
|
}
|
|
|
|
const u2Stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, magicresist: 0 })
|
|
const u2: CombatUnitContext = {
|
|
id: 'undead-5',
|
|
name: 'Zombie',
|
|
statList: u2Stats,
|
|
stateBus: new StateBus(u2Stats, registry),
|
|
isUndead: true,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const positions = new Map<string, { x: number; y: number }>([
|
|
['undead-1', { x: 30, y: 0 }],
|
|
['demon-2', { x: 60, y: 0 }],
|
|
['beast-3', { x: 90, y: 0 }],
|
|
['ally-4', { x: 120, y: 0 }],
|
|
['undead-5', { x: 150, y: 0 }],
|
|
])
|
|
|
|
// Scenario A: Holy bolt fired into the pack towards Undead 1 -> hits Undead 1 and consumes missile
|
|
const m1 = engine.spawnMissile({
|
|
missileNameOrId: 'holybolt',
|
|
sourceSkillId: 101,
|
|
slvl: 10,
|
|
owner: caster,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 200,
|
|
targetY: 0,
|
|
dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 150 * FIXED_ONE, elemMax256: 150 * FIXED_ONE },
|
|
})!
|
|
engine.tick(1, [u1, demon, beast, ally, u2], positions)
|
|
expect(u1Stats.getHp256()).toBeLessThan(1000 * FIXED_ONE)
|
|
expect(m1.expired).toBe(true)
|
|
|
|
// Scenario B: Holy bolt fired starting AFTER Undead 1, through Demon and Beast, directly at Ally
|
|
const m2 = engine.spawnMissile({
|
|
missileNameOrId: 'holybolt',
|
|
sourceSkillId: 101,
|
|
slvl: 10,
|
|
owner: caster,
|
|
startX: 40,
|
|
startY: 0,
|
|
targetX: 200,
|
|
targetY: 0,
|
|
dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 150 * FIXED_ONE, elemMax256: 150 * FIXED_ONE },
|
|
})!
|
|
|
|
// Tick over demon and beast (positions 60 and 90)
|
|
engine.tick(2, [demon, beast], positions)
|
|
expect(demonStats.getHp256()).toBe(1000 * FIXED_ONE) // Passed through Demon!
|
|
expect(beastStats.getHp256()).toBe(1000 * FIXED_ONE) // Passed through Beast!
|
|
expect(m2.expired).toBe(false) // Not expired!
|
|
|
|
// Position missile onto ally (x = 120)
|
|
m2.x = 120
|
|
engine.tick(3, [ally, u2], positions)
|
|
expect(allyStats.getHp256()).toBeGreaterThan(200 * FIXED_ONE) // Healed ally!
|
|
expect(m2.expired).toBe(true) // Consumed by healing ally!
|
|
expect(u2Stats.getHp256()).toBe(1000 * FIXED_ONE) // Undead 2 behind ally untouched!
|
|
})
|
|
|
|
it('3. Sacrifice Extreme Overkill Self-Damage Safety: 50,000 damage with 10 HP clamped to 1 HP', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const attackerStats = new UnitStatList(registry, {
|
|
hitpoints: 10 * FIXED_ONE,
|
|
maxhp: 1000 * FIXED_ONE,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'low-hp-paladin',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
weaponMinPhys: 50000,
|
|
weaponMaxPhys: 50000,
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
hitpoints: 100000 * FIXED_ONE,
|
|
maxhp: 100000 * FIXED_ONE,
|
|
damageresist: 0,
|
|
})
|
|
const defender: CombatUnitContext = {
|
|
id: 'meat-sponge',
|
|
name: 'Gargantuan Beast',
|
|
statList: defenderStats,
|
|
stateBus: new StateBus(defenderStats, registry),
|
|
}
|
|
|
|
// 50,000 damage dealt -> 8% self damage = 4,000 damage
|
|
// Attacker only has 10 HP. Attacker HP MUST clamp to 1 HP (256 in FIXED_ONE), preventing suicide.
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 96,
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
selfDamagePct: 8,
|
|
autoHit: true,
|
|
})
|
|
|
|
expect(out.hit).toBe(true)
|
|
expect(out.physDamage256).toBe(50000 * FIXED_ONE)
|
|
expect(out.selfDamageTaken256).toBe(4000 * FIXED_ONE)
|
|
expect(attackerStats.getHp256()).toBe(FIXED_ONE) // Exactly 1 HP remaining!
|
|
})
|
|
|
|
it('4. Smite vs High-Defense Unblockable Invariant: 99,999 def, 75% block -> 100% hits, 0% blocks', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const attackerStats = new UnitStatList(registry, {
|
|
tohit: 100, // Very low attack rating
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'smiter',
|
|
name: 'Paladin',
|
|
statList: attackerStats,
|
|
stateBus: new StateBus(attackerStats, registry),
|
|
}
|
|
|
|
const defenderStats = new UnitStatList(registry, {
|
|
armorclass: 99999, // Extreme defense
|
|
toblock: 75, // Max block rate
|
|
hitpoints: 50000 * FIXED_ONE,
|
|
maxhp: 50000 * FIXED_ONE,
|
|
})
|
|
const defender: CombatUnitContext = {
|
|
id: 'impenetrable-guardian',
|
|
name: 'Iron Golem Boss',
|
|
statList: defenderStats,
|
|
stateBus: new StateBus(defenderStats, registry),
|
|
hasShield: true,
|
|
}
|
|
|
|
// Run 100 iterations of Smite strikes
|
|
let hits = 0
|
|
let blocks = 0
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
const out = executeSUnitDmg(attacker, defender, {
|
|
skillId: 97,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 40 * FIXED_ONE,
|
|
flatPhysMax256: 50 * FIXED_ONE,
|
|
autoHit: true,
|
|
unblockable: true,
|
|
stunDurationFrames: 25,
|
|
roll100: i % 100, // Explicit roll
|
|
})
|
|
|
|
if (out.hit) hits++
|
|
if (out.avoidedReason === 'block') blocks++
|
|
}
|
|
|
|
expect(hits).toBe(100)
|
|
expect(blocks).toBe(0)
|
|
expect(defender.stateBus.hasState('stun')).toBe(true)
|
|
})
|
|
|
|
it('5. Conversion Allegiance Cleanup: converts 10 monsters; simulates 400 frames; asserts 100% reversion', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const monsters: { id: string; stats: UnitStatList; bus: StateBus }[] = []
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
const stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
|
|
const bus = new StateBus(stats, registry)
|
|
monsters.push({ id: `converted-${i}`, stats, bus })
|
|
}
|
|
|
|
// Apply conversion to all 10 monsters at frame 100 for 400 frames
|
|
for (const m of monsters) {
|
|
const res = m.bus.applyState({
|
|
stateNameOrId: 'conversion',
|
|
sourceUnitId: 'paladin-converter',
|
|
sourceSkillId: 116,
|
|
slvl: 10,
|
|
currentFrame: 100,
|
|
durationFrames: 400, // Expires at frame 500
|
|
})
|
|
expect(res.applied).toBe(true)
|
|
expect(m.bus.hasState('conversion')).toBe(true)
|
|
}
|
|
|
|
// Advance to frame 499: all 10 must still be converted
|
|
for (const m of monsters) {
|
|
m.bus.tick(499)
|
|
expect(m.bus.hasState('conversion')).toBe(true)
|
|
}
|
|
|
|
// Advance to frame 500: all 10 must expire and revert cleanly
|
|
for (const m of monsters) {
|
|
const expired = m.bus.tick(500)
|
|
expect(expired).toContain('conversion')
|
|
expect(m.bus.hasState('conversion')).toBe(false)
|
|
expect(m.bus.getActiveEntries().length).toBe(0) // Clean state bus, zero memory leaks!
|
|
}
|
|
})
|
|
})
|