696 lines
23 KiB
TypeScript
696 lines
23 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|