560 lines
22 KiB
TypeScript
560 lines
22 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Amazon Passive & Magic Stress & Adversarial Suite
|
|
*
|
|
* Comprehensive adversarial and E2E kinematics integration verification:
|
|
* 1. Defensive Evasion Triad:
|
|
* - Dodge: melee avoidance while standing still / attacking; triggers GH animation lock.
|
|
* - Avoid: missile avoidance while standing still / attacking; triggers GH animation lock.
|
|
* - Evade: avoidance while moving against both melee and missile; zero animation lock (uninterrupted).
|
|
* 2. Inner Sight:
|
|
* - Flat defense reduction applied directly before percentage modifiers.
|
|
* - 200px radius, 700 + 150*(slvl-1) duration, 5 mana.
|
|
* - Monster defense reduced, to-hit chance dramatically increased.
|
|
* 3. Slow Missiles:
|
|
* - 200px radius, 300 + 60*(slvl-1) duration, 5 mana.
|
|
* - Hostile missile projectile velocity reduced to 33% (67% speed damper) in 2:1 isometric space.
|
|
* 4. Critical Strike & Deadly Strike:
|
|
* - Sequential evaluation: CS roll first, DS roll second if CS fails; never quadrupling (max 2x physical).
|
|
* - Does not double elemental/magic damage.
|
|
* 5. Penetrate:
|
|
* - Linear +35% + 10%*(slvl-1) Attack Rating bonus correctly accrued to statList.
|
|
* 6. Decoy (Dopplezon):
|
|
* - Stationary posture (NU mode), cannot move or attack.
|
|
* - Draws threat, scales HP from Amazon life (50% + 10%*slvl), up to 85% elemental resistance.
|
|
* - Inherits Avoid and Evade passives from Amazon.
|
|
* 7. Valkyrie:
|
|
* - 150-frame (6.0s) casting delay.
|
|
* - Base HP 440 * (1 + 0.2*(slvl-1)), +20% life per Decoy hard point synergy.
|
|
* - Equipment progression tiers across slvl 1, 7, 11, 17, 27+.
|
|
* - Inherits Critical Strike, Dodge, Avoid, Evade, Penetrate at Amazon's slvl.
|
|
* 8. Pierce & Guided Arrow Invariant:
|
|
* - 1..4 pierces (up to 5 targets hit).
|
|
* - Guided Arrow (skillId === 22) strict 0% pierce override invariant.
|
|
* 9. System Invariant:
|
|
* - BATCH1_SKILLS length strictly equals 38; zero mocks (vi.mock strictly forbidden).
|
|
*
|
|
* Ground Truth:
|
|
* - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
BATCH1_SKILLS,
|
|
getSkillManaCost,
|
|
getSkillCooldownTicks,
|
|
calculateSkillDamage,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
calculateInnerSightStats,
|
|
calculateCriticalStrikeStats,
|
|
calculateDodgeStats,
|
|
calculateSlowMissilesStats,
|
|
calculateAvoidStats,
|
|
calculatePenetrateStats,
|
|
calculateDecoyStats,
|
|
calculateEvadeStats,
|
|
calculateValkyrieStats,
|
|
calculatePierceStats,
|
|
} from '../../../src/game/skills/amazon-passive.ts'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/game/engine/missile-engine.ts'
|
|
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
|
|
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
|
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
|
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import {
|
|
executeSUnitDmg,
|
|
evaluateAvoidanceAndBlock,
|
|
computeToHitChance,
|
|
type CombatUnitContext,
|
|
type SUnitDmgPacket,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import { evaluateCriticalAndDeadlyStrike, calculateCriticalChance } from '../../../src/game/formulas.ts'
|
|
|
|
function createTestCombatUnit(
|
|
id: string,
|
|
pos: { x: number; y: number },
|
|
registry: any,
|
|
opts?: {
|
|
hp?: number
|
|
isMoving?: boolean
|
|
def?: number
|
|
ar?: number
|
|
level?: number
|
|
isBoss?: boolean
|
|
}
|
|
): CombatUnitContext {
|
|
const statList = new UnitStatList(registry, {
|
|
level: opts?.level ?? 80,
|
|
hitpoints: (opts?.hp ?? 10000) * FIXED_ONE,
|
|
maxhp: (opts?.hp ?? 10000) * FIXED_ONE,
|
|
armorclass: opts?.def ?? 400,
|
|
tohit: opts?.ar ?? 1000,
|
|
fireresist: 0,
|
|
coldresist: 0,
|
|
lightresist: 0,
|
|
poisonresist: 0,
|
|
damageresist: 0,
|
|
magicresist: 0,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
})
|
|
const stateBus = new StateBus(statList, registry)
|
|
return {
|
|
id,
|
|
name: id,
|
|
statList,
|
|
stateBus,
|
|
isMoving: opts?.isMoving ?? false,
|
|
isBoss: opts?.isBoss ?? false,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
weaponMinPhys: 50,
|
|
weaponMaxPhys: 100,
|
|
}
|
|
}
|
|
|
|
describe('Amazon Passive & Magic Skills Adversarial Stress Suite (1.13c Ground Truth)', () => {
|
|
it('enforces System Invariants: BATCH1_SKILLS length strictly equals 38 and zero mocks utilized', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
expect(BATCH1_SKILLS[6]).toBeDefined() // Magic Arrow
|
|
expect(BATCH1_SKILLS[7]).toBeDefined() // Fire Arrow
|
|
expect(BATCH1_SKILLS[10]).toBeDefined() // Jab
|
|
expect(BATCH1_SKILLS[11]).toBeDefined() // Cold Arrow
|
|
expect(BATCH1_SKILLS[14]).toBeDefined() // Power Strike
|
|
})
|
|
|
|
describe('Defensive Evasion Triad: Dodge, Avoid, Evade & Animation Lock Invariants', () => {
|
|
it('verifies Dodge: triggers only against melee while stationary/attacking and applies GH animation lock', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
|
|
// Amazon standing still with slvl 20 Dodge (56% chance)
|
|
const amazonStationary = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false })
|
|
const dodgeStats20 = calculateDodgeStats(20)
|
|
amazonStationary.statList.setBaseSkillLevel(13, 20)
|
|
amazonStationary.statList.addStat('passive_dodge', dodgeStats20.chancePct)
|
|
|
|
// 1. Melee attack while stationary: roll 30 < 56% -> avoided via dodge
|
|
const resMeleeStill = evaluateAvoidanceAndBlock({
|
|
defender: amazonStationary,
|
|
attackKind: 'melee',
|
|
roll100: 30,
|
|
})
|
|
expect(resMeleeStill.avoided).toBe(true)
|
|
expect(resMeleeStill.reason).toBe('dodge')
|
|
expect(dodgeStats20.triggersAnimationLock).toBe(true)
|
|
expect(dodgeStats20.animationCode).toBe('GH')
|
|
|
|
// 2. Melee attack while stationary: roll 70 >= 56% -> not avoided
|
|
const resMeleeStillFail = evaluateAvoidanceAndBlock({
|
|
defender: amazonStationary,
|
|
attackKind: 'melee',
|
|
roll100: 70,
|
|
})
|
|
expect(resMeleeStillFail.avoided).toBe(false)
|
|
expect(resMeleeStillFail.reason).toBe('none')
|
|
|
|
// 3. Melee attack while moving: Dodge must NOT trigger when moving
|
|
const amazonMoving = createTestCombatUnit('amazon-run', { x: 0, y: 0 }, registry, { isMoving: true })
|
|
amazonMoving.statList.addStat('passive_dodge', dodgeStats20.chancePct)
|
|
const resMeleeMoving = evaluateAvoidanceAndBlock({
|
|
defender: amazonMoving,
|
|
attackKind: 'melee',
|
|
roll100: 30,
|
|
})
|
|
// Dodge does not activate while moving!
|
|
expect(resMeleeMoving.reason).not.toBe('dodge')
|
|
})
|
|
|
|
it('verifies Avoid: triggers only against missiles while stationary/attacking and applies GH animation lock', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
|
|
// Amazon standing still with slvl 20 Avoid (65% chance)
|
|
const amazonStill = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false })
|
|
const avoidStats20 = calculateAvoidStats(20)
|
|
amazonStill.statList.setBaseSkillLevel(18, 20)
|
|
amazonStill.statList.addStat('passive_avoid', avoidStats20.chancePct)
|
|
|
|
// 1. Ranged missile while stationary: roll 40 < 65% -> avoided via avoid
|
|
const resMissileStill = evaluateAvoidanceAndBlock({
|
|
defender: amazonStill,
|
|
attackKind: 'missile',
|
|
roll100: 40,
|
|
})
|
|
expect(resMissileStill.avoided).toBe(true)
|
|
expect(resMissileStill.reason).toBe('avoid')
|
|
expect(avoidStats20.triggersAnimationLock).toBe(true)
|
|
expect(avoidStats20.animationCode).toBe('GH')
|
|
|
|
// 2. Melee attack: Avoid must NOT trigger against melee attacks
|
|
const resMelee = evaluateAvoidanceAndBlock({
|
|
defender: amazonStill,
|
|
attackKind: 'melee',
|
|
roll100: 40,
|
|
})
|
|
expect(resMelee.reason).not.toBe('avoid')
|
|
|
|
// 3. Missile attack while moving: Avoid must NOT trigger when moving
|
|
const amazonMoving = createTestCombatUnit('amazon-run', { x: 0, y: 0 }, registry, { isMoving: true })
|
|
amazonMoving.statList.addStat('passive_avoid', avoidStats20.chancePct)
|
|
const resMissileMoving = evaluateAvoidanceAndBlock({
|
|
defender: amazonMoving,
|
|
attackKind: 'missile',
|
|
roll100: 40,
|
|
})
|
|
expect(resMissileMoving.reason).not.toBe('avoid')
|
|
})
|
|
|
|
it('verifies Evade: triggers while moving against BOTH melee and missiles with ZERO animation lock', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
|
|
// Amazon moving with slvl 20 Evade (56% chance)
|
|
const amazonMoving = createTestCombatUnit('amazon-moving', { x: 0, y: 0 }, registry, { isMoving: true })
|
|
const evadeStats20 = calculateEvadeStats(20)
|
|
amazonMoving.statList.setBaseSkillLevel(29, 20)
|
|
amazonMoving.statList.addStat('passive_evade', evadeStats20.chancePct)
|
|
|
|
// 1. Evade against melee while moving: roll 30 < 56% -> avoided via evade
|
|
const resMelee = evaluateAvoidanceAndBlock({
|
|
defender: amazonMoving,
|
|
attackKind: 'melee',
|
|
roll100: 30,
|
|
})
|
|
expect(resMelee.avoided).toBe(true)
|
|
expect(resMelee.reason).toBe('evade')
|
|
// Zero animation lock: uninterrupted motion
|
|
expect(evadeStats20.triggersAnimationLock).toBe(false)
|
|
expect(evadeStats20.uninterrupted).toBe(true)
|
|
|
|
// 2. Evade against missile while moving: roll 30 < 56% -> avoided via evade
|
|
const resMissile = evaluateAvoidanceAndBlock({
|
|
defender: amazonMoving,
|
|
attackKind: 'missile',
|
|
roll100: 30,
|
|
})
|
|
expect(resMissile.avoided).toBe(true)
|
|
expect(resMissile.reason).toBe('evade')
|
|
|
|
// 3. Evade while stationary: must NOT trigger when stationary
|
|
const amazonStill = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false })
|
|
amazonStill.statList.addStat('passive_evade', evadeStats20.chancePct)
|
|
const resStill = evaluateAvoidanceAndBlock({
|
|
defender: amazonStill,
|
|
attackKind: 'melee',
|
|
roll100: 30,
|
|
})
|
|
expect(resStill.reason).not.toBe('evade')
|
|
})
|
|
})
|
|
|
|
describe('Inner Sight: Flat Defense Reduction & Illumination Radius', () => {
|
|
it('applies flat defense reduction directly before percentage defense modifiers', async () => {
|
|
const isStats1 = calculateInnerSightStats(1)
|
|
expect(isStats1.flatDefenseReduction).toBe(-40)
|
|
expect(isStats1.durationFrames).toBe(700) // 28.0s
|
|
expect(isStats1.radiusPx).toBe(200)
|
|
expect(isStats1.manaCost).toBe(5)
|
|
|
|
const isStats20 = calculateInnerSightStats(20)
|
|
expect(isStats20.flatDefenseReduction).toBe(-515) // -40 - 25*19 = -515
|
|
expect(isStats20.durationFrames).toBe(3550) // 700 + 150*19 = 3550 frames (142.0s)
|
|
|
|
// Verification of defense reduction impact on hit chance:
|
|
// Base Monster: 600 Defense, Attacker: 800 AR, both level 80
|
|
const baseHitChance = computeToHitChance({
|
|
attackerAr: 800,
|
|
defenderDef: 600,
|
|
attackerLvl: 80,
|
|
defenderLvl: 80,
|
|
})
|
|
// Raw: (2 * 800 * 100 * 80) / ((800 + 600) * 160) = 12800000 / 224000 = 57%
|
|
expect(baseHitChance).toBe(57)
|
|
|
|
// Afflicted by slvl 20 Inner Sight: 600 - 515 = 85 defense remaining
|
|
const debuffedDef = Math.max(0, 600 + isStats20.flatDefenseReduction)
|
|
expect(debuffedDef).toBe(85)
|
|
const afflictedHitChance = computeToHitChance({
|
|
attackerAr: 800,
|
|
defenderDef: debuffedDef,
|
|
attackerLvl: 80,
|
|
defenderLvl: 80,
|
|
})
|
|
// Raw: (2 * 800 * 100 * 80) / ((800 + 85) * 160) = 12800000 / 141600 = 90%
|
|
expect(afflictedHitChance).toBe(90)
|
|
expect(afflictedHitChance).toBeGreaterThan(baseHitChance)
|
|
})
|
|
})
|
|
|
|
describe('Slow Missiles: Projectile Velocity Kinematics Dampening', () => {
|
|
it('scales hostile projectile velocity by 33% (67% speed reduction) in 2:1 isometric space', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const enemy = createTestCombatUnit('monster-caster', { x: 0, y: 0 }, registry)
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 0,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 50 * FIXED_ONE,
|
|
flatPhysMax256: 50 * FIXED_ONE,
|
|
}
|
|
|
|
// 1. Baseline normal projectile
|
|
const normalMsl = missileEngine.spawnMissile({
|
|
missileNameOrId: 'arrow',
|
|
sourceSkillId: 0,
|
|
slvl: 1,
|
|
owner: enemy,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 300,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
})!
|
|
expect(normalMsl).not.toBeNull()
|
|
const baseVx = normalMsl.vx
|
|
const baseVy = normalMsl.vy
|
|
|
|
// 2. Slowed projectile under Slow Missiles aura
|
|
const slowedMsl = missileEngine.spawnMissile({
|
|
missileNameOrId: 'arrow',
|
|
sourceSkillId: 0,
|
|
slvl: 1,
|
|
owner: enemy,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 300,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
isSlowed: true,
|
|
slowMultiplier: 0.33,
|
|
})!
|
|
expect(slowedMsl).not.toBeNull()
|
|
|
|
// Step both missiles for 5 ticks
|
|
for (let t = 0; t < 5; t++) {
|
|
missileEngine.tick(t, [], new Map())
|
|
}
|
|
|
|
// Under Slow Missiles, distance traveled must be ~33% of baseline
|
|
expect(slowedMsl.x).toBeCloseTo(normalMsl.x * 0.33, 0)
|
|
})
|
|
})
|
|
|
|
describe('Critical Strike & Deadly Strike: Sequential Doubling Invariant', () => {
|
|
it('rolls Critical Strike before Deadly Strike and strictly never quadruples physical damage', () => {
|
|
const csStats20 = calculateCriticalStrikeStats(20)
|
|
expect(csStats20.chancePct).toBe(68)
|
|
expect(csStats20.damageMultiplier).toBe(2.0)
|
|
|
|
// Test sequential doubling with mock PRNG rolls:
|
|
// Case 1: CS succeeds (roll 10 < 68%) -> critical_strike, DS skipped
|
|
const res1 = evaluateCriticalAndDeadlyStrike(68, 50, () => 0.10)
|
|
expect(res1.isCritical).toBe(true)
|
|
expect(res1.triggeredBy).toBe('critical_strike')
|
|
|
|
// Case 2: CS fails (roll 80 >= 68%), DS succeeds (roll 20 < 50%) -> deadly_strike
|
|
let rollCount = 0
|
|
const rolls = [0.80, 0.20]
|
|
const res2 = evaluateCriticalAndDeadlyStrike(68, 50, () => rolls[rollCount++] ?? 0.5)
|
|
expect(res2.isCritical).toBe(true)
|
|
expect(res2.triggeredBy).toBe('deadly_strike')
|
|
|
|
// Case 3: Combined chance formula check: P = CS + DS * (1 - CS)
|
|
// CS=68%, DS=50% -> 0.68 + 0.50 * 0.32 = 0.68 + 0.16 = 0.84 (84%)
|
|
const combined = calculateCriticalChance(68, 50)
|
|
expect(combined).toBe(84)
|
|
|
|
// Invariant: In combat pipeline, physical damage is doubled ONCE, never multiplied by 4
|
|
const baseDamage = 100
|
|
const finalCritDamage = baseDamage * csStats20.damageMultiplier
|
|
expect(finalCritDamage).toBe(200)
|
|
expect(finalCritDamage).not.toBe(400)
|
|
})
|
|
})
|
|
|
|
describe('Penetrate: Attack Rating Scaling & Stat Accrual', () => {
|
|
it('linearly accrues +35% + 10%*(slvl-1) Attack Rating to UnitStatList', () => {
|
|
const p1 = calculatePenetrateStats(1)
|
|
expect(p1.attackRatingBonusPct).toBe(35)
|
|
expect(p1.manaCost).toBe(0)
|
|
|
|
const p10 = calculatePenetrateStats(10)
|
|
expect(p10.attackRatingBonusPct).toBe(125)
|
|
|
|
const p20 = calculatePenetrateStats(20)
|
|
expect(p20.attackRatingBonusPct).toBe(225)
|
|
|
|
// StatList verification: base AR 1000 with +225% Penetrate -> 1000 * 3.25 = 3250
|
|
const statList = new UnitStatList(undefined, { tohit: 1000 })
|
|
statList.addStat('item_tohit_percent', p20.attackRatingBonusPct)
|
|
expect(statList.getAccruedStat('tohit')).toBe(3250)
|
|
})
|
|
})
|
|
|
|
describe('Decoy (Dopplezon): Stationary Posture, Threat Drawing & Passive Inheritance', () => {
|
|
it('creates stationary companion with life scaling, resistance cap, and inherited passives', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const summonManager = new SummonManager(registry)
|
|
|
|
const amazon = createTestCombatUnit('amazon-caster', { x: 100, y: 100 }, registry, {
|
|
hp: 1000,
|
|
def: 500,
|
|
})
|
|
// Allocate Avoid (slvl 10) and Evade (slvl 8) hard points on Amazon
|
|
amazon.statList.setBaseSkillLevel(18, 10)
|
|
amazon.statList.setBaseSkillLevel(29, 8)
|
|
|
|
const outcome = summonManager.createPet({
|
|
owner: amazon,
|
|
skillId: 28,
|
|
slvl: 10,
|
|
x: 150,
|
|
y: 120,
|
|
})
|
|
|
|
expect(outcome.created).toBe(true)
|
|
const decoy = outcome.pet!
|
|
expect(decoy).toBeDefined()
|
|
expect(decoy.kind).toBe('dopplezon')
|
|
expect(decoy.isStationary).toBe(true)
|
|
|
|
// Life scaling: 1000 * (0.5 + 0.1 * 10) = 1500 HP
|
|
expect(decoy.hp).toBe(1500)
|
|
expect(decoy.maxHp).toBe(1500)
|
|
|
|
// Defense matches Amazon's defense
|
|
expect(decoy.statList.getAccruedStat('armorclass')).toBe(500)
|
|
|
|
// Resistances: min(85, 4 * 10) = 40%
|
|
expect(decoy.statList.getAccruedStat('fireresist')).toBe(40)
|
|
expect(decoy.statList.getAccruedStat('lightresist')).toBe(40)
|
|
|
|
// Inherited Avoid & Evade passives
|
|
expect(decoy.statList.getAccruedStat('passive_avoid')).toBe(calculateAvoidStats(10).chancePct)
|
|
expect(decoy.statList.getAccruedStat('passive_evade')).toBe(calculateEvadeStats(8).chancePct)
|
|
|
|
// Ticking Decoy in combat: stationary Decoy does NOT attack
|
|
const enemy = createTestCombatUnit('enemy-target', { x: 150, y: 120 }, registry)
|
|
const missileEngine = new MissileEngine(registry)
|
|
const auraScanner = new AuraScanner(registry)
|
|
const tickRes = summonManager.tickPets({
|
|
currentTick: 10,
|
|
owner: amazon,
|
|
enemies: [enemy],
|
|
enemyPositions: new Map([['enemy-target', { x: 150, y: 120 }]]),
|
|
missileEngine,
|
|
auraScanner,
|
|
})
|
|
expect(tickRes.petAttacks).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('Valkyrie: 150-Frame Cooldown, Decoy Synergies & Equipment Tier Progression', () => {
|
|
it('enforces 150-frame casting delay, +20% life per Decoy point, and 5 equipment generation tiers', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const summonManager = new SummonManager(registry)
|
|
|
|
// 1. Casting Delay Invariant: 150 frames (6.0s)
|
|
expect(getSkillCooldownTicks(32)).toBe(150)
|
|
expect(calculateValkyrieStats(1).cooldownFrames).toBe(150)
|
|
expect(calculateValkyrieStats(1).cooldownSeconds).toBe(6.0)
|
|
|
|
// 2. Decoy Synergy: +20% HP per hard point in Decoy
|
|
const amazon = createTestCombatUnit('amazon-valk-caster', { x: 0, y: 0 }, registry)
|
|
amazon.statList.setBaseSkillLevel(28, 5) // 5 Decoy hard points = +100% life
|
|
amazon.statList.setBaseSkillLevel(9, 10) // CS slvl 10
|
|
amazon.statList.setBaseSkillLevel(13, 10) // Dodge slvl 10
|
|
amazon.statList.setBaseSkillLevel(18, 10) // Avoid slvl 10
|
|
amazon.statList.setBaseSkillLevel(29, 10) // Evade slvl 10
|
|
amazon.statList.setBaseSkillLevel(23, 10) // Penetrate slvl 10
|
|
|
|
const outcome = summonManager.createPet({
|
|
owner: amazon,
|
|
skillId: 32,
|
|
slvl: 10,
|
|
})
|
|
expect(outcome.created).toBe(true)
|
|
const valk = outcome.pet!
|
|
expect(valk.kind).toBe('valkyrie')
|
|
|
|
// Base HP at slvl 10: 440 * (1 + 0.2*9) = 440 * 2.8 = 1232
|
|
// Decoy Synergy (+100%): 1232 * 2.0 = 2464 HP
|
|
expect(valk.hp).toBe(2464)
|
|
|
|
// Resistances: min(75, 2 * 10) = 20%
|
|
expect(valk.statList.getAccruedStat('fireresist')).toBe(20)
|
|
|
|
// Inherited Amazon passives
|
|
expect(valk.statList.getAccruedStat('passive_critical_strike')).toBe(calculateCriticalStrikeStats(10).chancePct)
|
|
expect(valk.statList.getAccruedStat('passive_dodge')).toBe(calculateDodgeStats(10).chancePct)
|
|
expect(valk.statList.getAccruedStat('passive_avoid')).toBe(calculateAvoidStats(10).chancePct)
|
|
expect(valk.statList.getAccruedStat('passive_evade')).toBe(calculateEvadeStats(10).chancePct)
|
|
expect(valk.statList.getAccruedStat('item_tohit_percent')).toBe(calculatePenetrateStats(10).attackRatingBonusPct)
|
|
|
|
// 3. Equipment generation tiers
|
|
expect(calculateValkyrieStats(1).equipmentTier).toBe('tier1_basic')
|
|
expect(calculateValkyrieStats(7).equipmentTier).toBe('tier2_boots')
|
|
expect(calculateValkyrieStats(11).equipmentTier).toBe('tier3_gloves')
|
|
expect(calculateValkyrieStats(17).equipmentTier).toBe('tier4_war_pike')
|
|
expect(calculateValkyrieStats(27).equipmentTier).toBe('tier5_rare_tiara')
|
|
})
|
|
})
|
|
|
|
describe('Pierce: Penetration Limit & Guided Arrow Zero-Pierce Invariant', () => {
|
|
it('caps pierce at 4 iterations (5 targets hit max) and strictly clamps Guided Arrow to 0%', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
// 1. Pierce stats check
|
|
const pierceStats20 = calculatePierceStats(20)
|
|
expect(pierceStats20.chancePct).toBe(86)
|
|
expect(pierceStats20.maxPierceCount).toBe(4)
|
|
expect(pierceStats20.maxHitsPerMissile).toBe(5)
|
|
|
|
// 2. Guided Arrow strict 0% pierce override
|
|
const gaStats = calculatePierceStats(20, 22)
|
|
expect(gaStats.isGuidedArrow).toBe(true)
|
|
expect(gaStats.chancePct).toBe(0)
|
|
|
|
// 3. Runtime verification in MissileEngine: Guided Arrow NEVER pierces
|
|
const owner = createTestCombatUnit('amazon-ga', { x: 0, y: 0 }, registry)
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 22,
|
|
attackKind: 'missile',
|
|
srcDam: 128,
|
|
}
|
|
const gaMissile = missileEngine.spawnMissile({
|
|
missileNameOrId: 'guidedarrow',
|
|
sourceSkillId: 22,
|
|
slvl: 20,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 100,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
pierceChancePct: 100, // Adversarial input: attempt to force 100% pierce
|
|
})
|
|
expect(gaMissile!.canPierce).toBe(false)
|
|
expect(gaMissile!.pierceChancePct).toBe(0)
|
|
|
|
// 4. Normal arrow with Pierce skill penetrates enemies
|
|
const normalArrow = missileEngine.spawnMissile({
|
|
missileNameOrId: 'arrow',
|
|
sourceSkillId: 0,
|
|
slvl: 1,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 200,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
pierceChancePct: 86,
|
|
})
|
|
expect(normalArrow!.canPierce).toBe(true)
|
|
expect(normalArrow!.pierceChancePct).toBe(86)
|
|
})
|
|
})
|
|
})
|