diablo2-web/tests/skills/uni/adv-uni-parity-stress.test.ts

696 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II: Lord of Destruction v1.13c — Universal Skills (Cohort 8) Stress & Parity Suite.
*
* Covers all 11 Universal Skills (`charclass = ""` in `Skills.txt`):
* - Skill #000: Attack (melee physical/durability, ranged bow/crossbow/magicarrow/explosivearrow & ammo depletion)
* - Skill #001: Kick (1.13c STR+DEX+boot damage formula, srcDam=0, knockback=true)
* - Skill #002: Throw (thrown weapon missile resolution, stack quantity decrement, Throwing Mastery #135 AR/%ED/Crit)
* - Skill #003: Unsummon (pet ownership check, PetType.txt unsummon=0 Raven rejection, pet dismissal)
* - Skill #004: Left Hand Throw (off-hand throwing weapon missile, off-hand stack decrement, off-hand damage)
* - Skill #005: Left Hand Swing (requires equipped off-hand weapon, off-hand physical damage, durability drain)
* - Skill #217: Scroll of Identify (targetItem.identified, scroll consumption, already_identified guard, InTown=1)
* - Skill #218: Book of Identify (targetItem.identified, tome quantity consumption, empty_tome guard, InTown=1)
* - Skill #219: Scroll of Townportal (townPortalSlot.cast, InTown=0 guard, portal replacement, act town resolution)
* - Skill #220: Book of Townportal (townPortalSlot.cast, tome quantity consumption, InTown=0 guard, portal replacement)
* - Skill #350: Delerium Change (States.txt id=177 delerium morph +33% FRW/IAS/FCR, 1500f duration, toggle unmorph, restrict=2 form guard)
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.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 { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
import { evaluateSkill113c, executeSkill113c } from '../../../src/game/skills/registry.ts'
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
import { TownPortalSlot } from '../../../src/game/portal.ts'
function createUnit(
registry: Awaited<ReturnType<typeof getSharedDataRegistry>>,
id: string,
overrides: Partial<CombatUnitContext> = {},
): CombatUnitContext {
const statList = overrides.statList ?? new UnitStatList(registry, {
level: 80,
strength: 100,
dexterity: 100,
tohit: 5000,
armorclass: 100,
maxhp: 2000 * FIXED_ONE,
hitpoints: 2000 * FIXED_ONE,
maxmana: 500 * FIXED_ONE,
mana: 500 * FIXED_ONE,
})
const stateBus = overrides.stateBus ?? new StateBus(statList, registry)
return {
id,
name: id,
statList,
stateBus,
weaponMinPhys: 40,
weaponMaxPhys: 60,
...overrides,
}
}
describe('Cohort 8: Universal Skills (0-5, 217-220, 350) 1.13c Ground Truth Stress Suite', () => {
it('Skill #000 (Attack): executes melee strike with durability drain, and ranged bow/crossbow/magic-arrow with ammo management', async () => {
const registry = await getSharedDataRegistry()
const skill = registry.getSkillById(0)!
const missileEngine = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const summonManager = new SummonManager(registry)
// 1. Melee Attack drains weapon durability on hit
const meleeCaster = createUnit(registry, 'player-melee', { weaponItemType: 'sword' })
meleeCaster.statList.setBaseStat('durability', 20)
meleeCaster.statList.setBaseStat('maxdurability', 40)
const target = createUnit(registry, 'enemy-1')
const evalRes = evaluateSkill113c({ registry, skill, slvl: 1, blvl: 1, statList: meleeCaster.statList })
const meleeOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster: meleeCaster,
targets: [target],
targetPositions: new Map([[target.id, { x: 20, y: 0 }]]),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 1,
targetX: 20,
targetY: 0,
})
expect(meleeOut.executed).toBe(true)
expect(meleeOut.totalDamageDealt).toBeGreaterThan(0)
expect(meleeCaster.statList.getBaseStat('durability')).toBe(19)
// 2. Ranged Bow Attack consumes ammo and spawns 'arrow'
const bowCaster = createUnit(registry, 'player-bow', {
weaponItemType: 'bow',
isRanged: true,
ammoQuantity: 5,
})
const bowOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster: bowCaster,
targets: [target],
targetPositions: new Map([[target.id, { x: 60, y: 0 }]]),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 2,
targetX: 60,
targetY: 0,
})
expect(bowOut.executed).toBe(true)
expect(bowOut.missilesSpawned).toContain('arrow')
expect(bowCaster.ammoQuantity).toBe(4)
// 3. Ranged Crossbow with 0 ammo is rejected with 'no_ammo'
const xbowEmpty = createUnit(registry, 'player-xbow-empty', {
weaponItemType: 'crossbow',
isRanged: true,
ammoQuantity: 0,
})
const noAmmoOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster: xbowEmpty,
targets: [target],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 3,
targetX: 60,
targetY: 0,
})
expect(noAmmoOut.executed).toBe(false)
expect(noAmmoOut.error).toBe('no_ammo')
// 4. Fires Magic Arrows bypasses empty quiver and spawns 'magicarrow'
const magicBowCaster = createUnit(registry, 'player-magicbow', {
weaponItemType: 'bow',
isRanged: true,
ammoQuantity: 0,
firesMagicArrows: true,
})
const magicArrowOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster: magicBowCaster,
targets: [target],
targetPositions: new Map([[target.id, { x: 60, y: 0 }]]),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 4,
targetX: 60,
targetY: 0,
})
expect(magicArrowOut.executed).toBe(true)
expect(magicArrowOut.missilesSpawned).toContain('magicarrow')
})
it('Skill #001 (Kick): computes 1.13c STR+DEX+boot damage with srcDam=0 and applies knockback', async () => {
const registry = await getSharedDataRegistry()
const skill = registry.getSkillById(1)!
const missileEngine = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const summonManager = new SummonManager(registry)
// STR=100, DEX=100 -> (100+100-20)/4 = 45 min, (100+100-20)/3 = 60 max + boot 15..25 = 60..85
const kicker = createUnit(registry, 'kicker', {
weaponMinPhys: 500, // high weapon damage must NOT be used because srcDam=0 for Kick
weaponMaxPhys: 600,
bootMinDam: 15,
bootMaxDam: 25,
})
const target = createUnit(registry, 'kick-target')
const evalRes = evaluateSkill113c({ registry, skill, slvl: 1, blvl: 1, statList: kicker.statList })
const out = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster: kicker,
targets: [target],
targetPositions: new Map([[target.id, { x: 16, y: 0 }]]),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 10,
targetX: 16,
targetY: 0,
})
expect(out.executed).toBe(true)
expect(out.notes).toContain('kick_damage:60-85')
expect(out.notes).toContain('knockback')
// Average of 60..85 is 72 (well below weapon 500..600)
expect(out.totalDamageDealt).toBeGreaterThanOrEqual(60)
expect(out.totalDamageDealt).toBeLessThan(150)
})
it('Skill #002 (Throw) & Skill #004 (Left Hand Throw): resolves missile, decrements stack, and applies Throwing Mastery (#135)', async () => {
const registry = await getSharedDataRegistry()
const throwSkill = registry.getSkillById(2)!
const leftThrowSkill = registry.getSkillById(4)!
const missileEngine = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const summonManager = new SummonManager(registry)
const barb = createUnit(registry, 'throw-barb', {
isThrowing: true,
weaponItemType: 'taxe',
weaponQuantity: 3,
weaponMinPhys: 50,
weaponMaxPhys: 70,
offHandIsThrowing: true,
offHandWeaponItemType: 'tkni',
offHandWeaponQuantity: 2,
offHandMinPhys: 30,
offHandMaxPhys: 50,
})
barb.statList.setBaseSkillLevel(135, 10) // Throwing Mastery slvl 10
const target = createUnit(registry, 'throw-target')
const evalThrow = evaluateSkill113c({ registry, skill: throwSkill, slvl: 1, blvl: 1, statList: barb.statList })
expect(evalThrow.missileA).toBe('javelin')
const outRight = executeSkill113c({
registry,
skill: throwSkill,
evalResult: evalThrow,
caster: barb,
targets: [target],
targetPositions: new Map([[target.id, { x: 40, y: 0 }]]),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 20,
targetX: 40,
targetY: 0,
})
expect(outRight.executed).toBe(true)
expect(outRight.missilesSpawned).toContain('throwingaxe')
expect(barb.weaponQuantity).toBe(2)
expect(outRight.notes.some(n => n.startsWith('throwing_mastery:slvl=10'))).toBe(true)
expect(outRight.totalDamageDealt).toBeGreaterThan(60) // Boosted by Throwing Mastery %ED
// Left Hand Throw (#4)
const evalLeftThrow = evaluateSkill113c({ registry, skill: leftThrowSkill, slvl: 1, blvl: 1, statList: barb.statList })
const outLeft = executeSkill113c({
registry,
skill: leftThrowSkill,
evalResult: evalLeftThrow,
caster: barb,
targets: [target],
targetPositions: new Map([[target.id, { x: 40, y: 0 }]]),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 21,
targetX: 40,
targetY: 0,
})
expect(outLeft.executed).toBe(true)
expect(outLeft.missilesSpawned).toContain('throwingknife')
expect(barb.offHandWeaponQuantity).toBe(1)
// Rejects when stack quantity hits 0
barb.weaponQuantity = 0
const outEmpty = executeSkill113c({
registry,
skill: throwSkill,
evalResult: evalThrow,
caster: barb,
targets: [target],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 22,
targetX: 40,
targetY: 0,
})
expect(outEmpty.executed).toBe(false)
expect(outEmpty.error).toBe('invalid_throw_or_no_quantity')
})
it('Skill #003 (Unsummon): enforces pet ownership, rejects Raven (PetType.txt unsummon=0), and dismisses valid pets', async () => {
const registry = await getSharedDataRegistry()
const skill = registry.getSkillById(3)!
const missileEngine = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const summonManager = new SummonManager(registry)
const caster = createUnit(registry, 'druid-owner')
const evalRes = evaluateSkill113c({ registry, skill, slvl: 1, blvl: 1, statList: caster.statList })
// 1. Summon a Raven (skill 221) — cannot be unsummoned
const ravenRes = summonManager.createPet({ owner: caster, skillId: 221, slvl: 5 })
expect(ravenRes.created).toBe(true)
expect(ravenRes.pet?.unsummonable).toBe(false)
// Attempt to unsummon when only Raven exists -> no_pet_to_unsummon
const noPetOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 30,
targetX: 0,
targetY: 0,
})
expect(noPetOut.executed).toBe(false)
expect(noPetOut.error).toBe('no_pet_to_unsummon')
// Direct target on Raven -> pet_not_unsummonable
const ravenTargetUnit = createUnit(registry, ravenRes.pet!.uid, {
petType: 'raven',
petOwnerId: caster.id,
unsummonable: false,
})
const rejectRavenOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster,
targets: [ravenTargetUnit],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 31,
targetX: 0,
targetY: 0,
})
expect(rejectRavenOut.executed).toBe(false)
expect(rejectRavenOut.error).toBe('pet_not_unsummonable')
// Foreign owner pet -> invalid_pet_owner
const foreignPetTarget = createUnit(registry, 'pet-999', {
petType: 'golem',
petOwnerId: 'other-player',
unsummonable: true,
})
const rejectOwnerOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster,
targets: [foreignPetTarget],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 32,
targetX: 0,
targetY: 0,
})
expect(rejectOwnerOut.executed).toBe(false)
expect(rejectOwnerOut.error).toBe('invalid_pet_owner')
// Summon Spirit Wolf (skill 227) and unsummon it
const wolfRes = summonManager.createPet({ owner: caster, skillId: 227, slvl: 5 })
expect(wolfRes.created).toBe(true)
const dismissOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 33,
targetX: 0,
targetY: 0,
})
expect(dismissOut.executed).toBe(true)
expect(dismissOut.notes.some(n => n.startsWith('unsummoned_'))).toBe(true)
expect(wolfRes.pet?.alive).toBe(false)
expect(wolfRes.pet?.hp).toBe(0)
})
it('Skill #005 (Left Hand Swing): enforces off-hand weapon presence and uses off-hand physical damage', async () => {
const registry = await getSharedDataRegistry()
const skill = registry.getSkillById(5)!
const missileEngine = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const summonManager = new SummonManager(registry)
const singleWielder = createUnit(registry, 'single-wield', { hasOffHandWeapon: false })
const target = createUnit(registry, 'lhs-target')
const evalRes = evaluateSkill113c({ registry, skill, slvl: 1, blvl: 1, statList: singleWielder.statList })
const failOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster: singleWielder,
targets: [target],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 40,
targetX: 16,
targetY: 0,
})
expect(failOut.executed).toBe(false)
expect(failOut.error).toBe('no_offhand_weapon')
const dualWielder = createUnit(registry, 'dual-wield', {
hasOffHandWeapon: true,
weaponMinPhys: 10,
weaponMaxPhys: 12,
offHandMinPhys: 80,
offHandMaxPhys: 100,
})
dualWielder.statList.setBaseStat('durability', 30)
dualWielder.statList.setBaseStat('maxdurability', 50)
const okOut = executeSkill113c({
registry,
skill,
evalResult: evalRes,
caster: dualWielder,
targets: [target],
targetPositions: new Map([[target.id, { x: 16, y: 0 }]]),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 41,
targetX: 16,
targetY: 0,
})
expect(okOut.executed).toBe(true)
expect(okOut.totalDamageDealt).toBeGreaterThanOrEqual(80)
expect(dualWielder.statList.getBaseStat('durability')).toBe(29)
})
it('Skills #217–#220 (Scroll & Book of Identify / Townportal): enforces scroll/tome quantity, InTown guard, and TownPortalSlot replacement', async () => {
const registry = await getSharedDataRegistry()
const scrollId = registry.getSkillById(217)!
const bookId = registry.getSkillById(218)!
const scrollTp = registry.getSkillById(219)!
const bookTp = registry.getSkillById(220)!
const missileEngine = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const summonManager = new SummonManager(registry)
const caster = createUnit(registry, 'caster-scrolls', {
scrollCount: 1,
tomeQuantity: 2,
})
const unidItem = { id: 'item-1', code: 'rin', identified: false }
const eval217 = evaluateSkill113c({ registry, skill: scrollId, slvl: 1, blvl: 1, statList: caster.statList })
// 1. Scroll of Identify (217) works in town (InTown=1), identifies item, decrements scrollCount
const idOut = executeSkill113c({
registry,
skill: scrollId,
evalResult: eval217,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 50,
targetX: 0,
targetY: 0,
isTown: true,
targetItem: unidItem,
})
expect(idOut.executed).toBe(true)
expect(unidItem.identified).toBe(true)
expect(caster.scrollCount).toBe(0)
expect(idOut.statesApplied).toEqual([])
// 2. Book of Identify (218) rejects already_identified item without consuming tome charge
const eval218 = evaluateSkill113c({ registry, skill: bookId, slvl: 1, blvl: 1, statList: caster.statList })
const alreadyIdOut = executeSkill113c({
registry,
skill: bookId,
evalResult: eval218,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 51,
targetX: 0,
targetY: 0,
targetItem: unidItem,
})
expect(alreadyIdOut.executed).toBe(false)
expect(alreadyIdOut.error).toBe('already_identified')
expect(caster.tomeQuantity).toBe(2)
// 3. Scroll of Townportal (219) is rejected in town (InTown=0)
caster.scrollCount = 1
const eval219 = evaluateSkill113c({ registry, skill: scrollTp, slvl: 1, blvl: 1, statList: caster.statList })
const tpInTownOut = executeSkill113c({
registry,
skill: scrollTp,
evalResult: eval219,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 52,
targetX: 0,
targetY: 0,
isTown: true,
})
expect(tpInTownOut.executed).toBe(false)
expect(tpInTownOut.error).toBe('cannot_cast_in_town')
expect(caster.scrollCount).toBe(1)
// 4. Scroll of Townportal (219) & Book of Townportal (220) outside town open and replace portal in TownPortalSlot
const tpSlot = new TownPortalSlot()
const tp1Out = executeSkill113c({
registry,
skill: scrollTp,
evalResult: eval219,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 53,
targetX: 100,
targetY: 200,
isTown: false,
actNo: 2,
levelId: 42,
casterPos: { x: 100, y: 200 },
townPortalSlot: tpSlot,
})
expect(tp1Out.executed).toBe(true)
expect(caster.scrollCount).toBe(0)
expect(tpSlot.current()?.fromLevelId).toBe(42)
expect(tpSlot.current()?.townLevelId).toBe(40)
const eval220 = evaluateSkill113c({ registry, skill: bookTp, slvl: 1, blvl: 1, statList: caster.statList })
const tp2Out = executeSkill113c({
registry,
skill: bookTp,
evalResult: eval220,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 54,
targetX: 300,
targetY: 400,
isTown: false,
actNo: 5,
levelId: 111,
casterPos: { x: 300, y: 400 },
townPortalSlot: tpSlot,
})
expect(tp2Out.executed).toBe(true)
expect(caster.tomeQuantity).toBe(1)
expect(tpSlot.current()?.fromLevelId).toBe(111)
expect(tpSlot.current()?.townLevelId).toBe(109)
})
it('Skill #350 (Delerium Change): applies delerium state (+33% FRW/IAS/FCR for 1500f), blocks non-restrict=1 skills, and toggles off on recast', async () => {
const registry = await getSharedDataRegistry()
const deleriumSkill = registry.getSkillById(350)!
const attackSkill = registry.getSkillById(0)!
const fireballSkill = registry.getSkillById(47)!
const missileEngine = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const summonManager = new SummonManager(registry)
const caster = createUnit(registry, 'delerium-caster')
const target = createUnit(registry, 'delerium-target')
const eval350 = evaluateSkill113c({ registry, skill: deleriumSkill, slvl: 1, blvl: 1, statList: caster.statList })
// 1. First cast morphs into Bone Fetish ('delerium') with +33% FRW/IAS/FCR for 1500 frames
const morphOut = executeSkill113c({
registry,
skill: deleriumSkill,
evalResult: eval350,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 100,
targetX: 0,
targetY: 0,
})
expect(morphOut.executed).toBe(true)
expect(morphOut.statesApplied).toContain('delerium')
const st = caster.stateBus.getState('delerium')
expect(st).toBeDefined()
expect(st?.stats?.['velocitypercent']).toBe(33)
expect(st?.stats?.['attackrate']).toBe(33)
expect(st?.stats?.['other_animrate']).toBe(33)
expect(st?.expireFrame).toBe(1600) // 100 + 1500
// 2. While morphed, normal spell (Fire Ball #47, restrict != 1) is blocked
const eval47 = evaluateSkill113c({ registry, skill: fireballSkill, slvl: 10, blvl: 10, statList: caster.statList })
const blockedSpell = executeSkill113c({
registry,
skill: fireballSkill,
evalResult: eval47,
caster,
targets: [target],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 101,
targetX: 50,
targetY: 0,
})
expect(blockedSpell.executed).toBe(false)
expect(blockedSpell.error).toBe('invalid_form')
// 3. While morphed, basic Attack (#0) is permitted
const eval0 = evaluateSkill113c({ registry, skill: attackSkill, slvl: 1, blvl: 1, statList: caster.statList })
const allowedAttack = executeSkill113c({
registry,
skill: attackSkill,
evalResult: eval0,
caster,
targets: [target],
targetPositions: new Map([[target.id, { x: 16, y: 0 }]]),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 102,
targetX: 16,
targetY: 0,
})
expect(allowedAttack.executed).toBe(true)
// 4. Second cast of Delerium Change (#350) toggles off morph back to human form
const unmorphOut = executeSkill113c({
registry,
skill: deleriumSkill,
evalResult: eval350,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager,
currentTick: 103,
targetX: 0,
targetY: 0,
})
expect(unmorphOut.executed).toBe(true)
expect(unmorphOut.notes).toContain('state_toggled_off:delerium')
expect(caster.stateBus.hasState('delerium')).toBe(false)
})
})