diablo2-web/tests/skills/sor/issue-432-buff-visual-persi...

358 lines
15 KiB
TypeScript

/**
* Issue #432: Cold Armors & Energy Shield Buff Visual Persistence & Group 1 Mutual Exclusion
*
* Requirements:
* 1. Cast overlay and state overlay coexist upon initial cast.
* 2. Casting secondary spells (Fire Bolt, Ice Bolt, Frost Nova, Teleport) does NOT destroy active buff overlays.
* 3. Group 1 mutual exclusion between Cold Armors (casting Shiver Armor evicts Frozen Armor).
* 4. Cross-buff coexistence: Frozen Armor + Energy Shield + Enchant + Thunder Storm all active simultaneously.
* 5. Individual buff timer expiration cleans up only the corresponding overlay.
*/
import { describe, expect, it } from 'vitest'
import { GameEngine, type EngineInput } from '../../../src/game/engine.ts'
import {
CANONICAL_113C_OVERLAYS,
createActiveOverlay,
getOverlayTxtData,
} from '../../../src/game/skills.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../../../src/game/demo-data.ts'
const DEFAULT_INPUT: EngineInput = {
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
}
function createTestEngine() {
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
return new GameEngine(terrain, {
spawn: { x: 200, y: 200 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 0,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 0,
})
}
describe('Issue #432 — Cold Armors & Buff Visual Persistence (1.13c Parity)', () => {
describe('Dimension 1: Canonical 1.13c State Overlay Data & Classification', () => {
it('provides authentic 1.13c metadata in CANONICAL_113C_OVERLAYS for all Sorceress state overlays', () => {
// 1. Frozen Armor
const frozenArmor = CANONICAL_113C_OVERLAYS['frozenarmor']
expect(frozenArmor).toBeDefined()
expect(frozenArmor.frames).toBe(24)
expect(frozenArmor.animRate).toBe(16)
expect(frozenArmor.trans).toBe(3)
expect(frozenArmor.radius).toBe(13)
// 2. Shiver Armor
const shiverArmor = CANONICAL_113C_OVERLAYS['shiverarmor']
expect(shiverArmor).toBeDefined()
expect(shiverArmor.frames).toBe(24)
expect(shiverArmor.animRate).toBe(16)
expect(shiverArmor.trans).toBe(3)
expect(shiverArmor.radius).toBe(13)
// 3. Chilling Armor
const chillingArmor = CANONICAL_113C_OVERLAYS['chillingarmor']
expect(chillingArmor).toBeDefined()
expect(chillingArmor.frames).toBe(24)
expect(chillingArmor.animRate).toBe(16)
expect(chillingArmor.trans).toBe(3)
expect(chillingArmor.radius).toBe(13)
// 4. Energy Shield
const energyShield = CANONICAL_113C_OVERLAYS['energyshield']
expect(energyShield).toBeDefined()
expect(energyShield.frames).toBe(8)
expect(energyShield.animRate).toBe(8)
expect(energyShield.trans).toBe(5)
expect(energyShield.filename).toBe('ManaShieldBall')
// 5. Enchant
const enchant = CANONICAL_113C_OVERLAYS['enchant']
expect(enchant).toBeDefined()
expect(enchant.frames).toBe(17)
expect(enchant.animRate).toBe(16)
expect(enchant.trans).toBe(3)
expect(enchant.radius).toBe(9)
// 6. Thunder Storm (Ground Aura)
const thunderStormBack = CANONICAL_113C_OVERLAYS['thunderstormback']
expect(thunderStormBack).toBeDefined()
expect(thunderStormBack.frames).toBe(19)
expect(thunderStormBack.animRate).toBe(16)
expect(thunderStormBack.trans).toBe(3)
expect(thunderStormBack.preDraw).toBe(true)
expect(thunderStormBack.radius).toBe(8)
})
it('correctly classifies overlays and infers isCastOverlay, isStateOverlay, and stateGroup in createActiveOverlay', () => {
// Cast Overlays
const castOv = createActiveOverlay('ice_cast_1', { x: 100, y: 100 })
expect(castOv.isCastOverlay).toBe(true)
expect(castOv.isStateOverlay).toBe(false)
expect(castOv.stateGroup).toBe(0)
// Cold Armors (Group 1 State Overlays)
const frozenOv = createActiveOverlay('frozenarmor', { x: 100, y: 100 })
expect(frozenOv.isStateOverlay).toBe(true)
expect(frozenOv.isCastOverlay).toBe(false)
expect(frozenOv.stateGroup).toBe(1)
const shiverOv = createActiveOverlay('shiverarmor', { x: 100, y: 100 })
expect(shiverOv.isStateOverlay).toBe(true)
expect(shiverOv.isCastOverlay).toBe(false)
expect(shiverOv.stateGroup).toBe(1)
const chillingOv = createActiveOverlay('chillingarmor', { x: 100, y: 100 })
expect(chillingOv.isStateOverlay).toBe(true)
expect(chillingOv.isCastOverlay).toBe(false)
expect(chillingOv.stateGroup).toBe(1)
// Group 0 State Overlays
const esOv = createActiveOverlay('energyshield', { x: 100, y: 100 })
expect(esOv.isStateOverlay).toBe(true)
expect(esOv.isCastOverlay).toBe(false)
expect(esOv.stateGroup).toBe(0)
const enchOv = createActiveOverlay('enchant', { x: 100, y: 100 })
expect(enchOv.isStateOverlay).toBe(true)
expect(enchOv.isCastOverlay).toBe(false)
expect(enchOv.stateGroup).toBe(0)
const tsOv = createActiveOverlay('thunderstormback', { x: 100, y: 100 })
expect(tsOv.isStateOverlay).toBe(true)
expect(tsOv.isCastOverlay).toBe(false)
expect(tsOv.stateGroup).toBe(0)
})
})
describe('Dimension 2: Cast Overlay and State Overlay Coexistence', () => {
it('allows cast overlay and state overlay to coexist simultaneously on initial cast', () => {
const engine = createTestEngine()
// Step 5: Spawning cast overlay
const castOv = engine.spawnOverlay('ice_cast_1', { x: 200, y: 200 }, 'player', { isCastOverlay: true })
// Step 6: Spawning buff state overlay
const stateOv = engine.spawnOverlay('frozenarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
expect(engine.overlays.length).toBe(2)
expect(engine.overlays).toContain(castOv)
expect(engine.overlays).toContain(stateOv)
expect(castOv.expired).toBe(false)
expect(stateOv.expired).toBe(false)
})
})
describe('Dimension 3: Secondary Spell Casting Does Not Destroy Active Buff Overlays', () => {
it('preserves active state overlays across consecutive secondary spell casts', () => {
const engine = createTestEngine()
// Player activates Frozen Armor
const frozenArmorOv = engine.spawnOverlay('frozenarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
expect(engine.overlays).toContain(frozenArmorOv)
// 1. Cast Fire Bolt (spawns fire_cast_1)
const fireCast1 = engine.spawnOverlay('fire_cast_1', { x: 200, y: 200 }, 'player', { isCastOverlay: true })
expect(engine.overlays).toContain(frozenArmorOv)
expect(engine.overlays).toContain(fireCast1)
expect(frozenArmorOv.expired).toBe(false)
// 2. Cast Ice Bolt (spawns ice_cast_1)
// Rapid cast replaces fire_cast_1, but leaves frozenArmorOv intact
const iceCast1 = engine.spawnOverlay('ice_cast_1', { x: 200, y: 200 }, 'player', { isCastOverlay: true })
expect(engine.overlays).toContain(frozenArmorOv)
expect(engine.overlays).toContain(iceCast1)
expect(engine.overlays).not.toContain(fireCast1)
expect(fireCast1.expired).toBe(true)
expect(frozenArmorOv.expired).toBe(false)
// 3. Cast Frost Nova (spawns ice_cast_2)
const iceCast2 = engine.spawnOverlay('ice_cast_2', { x: 200, y: 200 }, 'player', { isCastOverlay: true })
expect(engine.overlays).toContain(frozenArmorOv)
expect(engine.overlays).toContain(iceCast2)
expect(iceCast1.expired).toBe(true)
expect(frozenArmorOv.expired).toBe(false)
// 4. Cast Teleport (spawns teleport)
const teleOv = engine.spawnOverlay('teleport', { x: 200, y: 200 }, 'player', { isCastOverlay: true })
expect(engine.overlays).toContain(frozenArmorOv)
expect(engine.overlays).toContain(teleOv)
expect(iceCast2.expired).toBe(true)
expect(frozenArmorOv.expired).toBe(false)
})
})
describe('Dimension 4: Group 1 Cold Armor Mutual Exclusion', () => {
it('strictly enforces mutual exclusion: casting Shiver Armor evicts Frozen Armor', () => {
const engine = createTestEngine()
// Activate Frozen Armor
const faOv = engine.spawnOverlay('frozenarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
expect(engine.overlays.filter(ov => !ov.expired)).toContain(faOv)
// Cast Shiver Armor (Group 1)
const saOv = engine.spawnOverlay('shiverarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
const activeOverlays = engine.overlays.filter(ov => !ov.expired)
expect(activeOverlays).toContain(saOv)
expect(activeOverlays).not.toContain(faOv)
expect(faOv.expired).toBe(true)
expect(activeOverlays.length).toBe(1)
})
it('strictly enforces mutual exclusion: casting Chilling Armor evicts Shiver Armor', () => {
const engine = createTestEngine()
// Activate Shiver Armor
const saOv = engine.spawnOverlay('shiverarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
expect(engine.overlays.filter(ov => !ov.expired)).toContain(saOv)
// Cast Chilling Armor (Group 1)
const caOv = engine.spawnOverlay('chillingarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
const activeOverlays = engine.overlays.filter(ov => !ov.expired)
expect(activeOverlays).toContain(caOv)
expect(activeOverlays).not.toContain(saOv)
expect(saOv.expired).toBe(true)
expect(caOv.expired).toBe(false)
expect(activeOverlays.length).toBe(1)
})
it('refreshes the same cold armor on recast without stacking duplicate overlays', () => {
const engine = createTestEngine()
const fa1 = engine.spawnOverlay('frozenarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
expect(engine.overlays.filter(ov => !ov.expired).length).toBe(1)
const fa2 = engine.spawnOverlay('frozenarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
const active = engine.overlays.filter(ov => !ov.expired)
expect(active.length).toBe(1)
expect(active[0]).toBe(fa2)
expect(fa1.expired).toBe(true)
expect(fa2.expired).toBe(false)
})
})
describe('Dimension 5: Cross-Buff Coexistence', () => {
it('simultaneously supports Frozen Armor, Energy Shield, Enchant, and Thunder Storm', () => {
const engine = createTestEngine()
const fa = engine.spawnOverlay('frozenarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
const es = engine.spawnOverlay('energyshield', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
const ench = engine.spawnOverlay('enchant', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
const ts = engine.spawnOverlay('thunderstormback', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
const active = engine.overlays.filter(ov => !ov.expired)
expect(active.length).toBe(4)
expect(active).toContain(fa)
expect(active).toContain(es)
expect(active).toContain(ench)
expect(active).toContain(ts)
// Now switch Cold Armor from Frozen Armor to Shiver Armor
// Only Frozen Armor should be evicted; Energy Shield, Enchant, and Thunder Storm remain active!
const sa = engine.spawnOverlay('shiverarmor', { x: 200, y: 200 }, 'player', { loop: true, isStateOverlay: true })
const activeAfterSwitch = engine.overlays.filter(ov => !ov.expired)
expect(activeAfterSwitch.length).toBe(4)
expect(activeAfterSwitch).toContain(sa)
expect(activeAfterSwitch).toContain(es)
expect(activeAfterSwitch).toContain(ench)
expect(activeAfterSwitch).toContain(ts)
expect(activeAfterSwitch).not.toContain(fa)
expect(fa.expired).toBe(true)
})
})
describe('Dimension 6: Individual Buff Timer Expiration & Cleanup', () => {
it('cleans up each buff overlay individually when its specific duration timer hits zero', () => {
const engine = createTestEngine()
const player = engine.world.player
// Set staggered timers
player.coldArmorType = 'frozenarmor'
player.coldArmorTicks = 10
player.energyshieldTicks = 20
player.enchantTicks = 30
player.thunderstormTicks = 40
const fa = engine.spawnOverlay('frozenarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
const es = engine.spawnOverlay('energyshield', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
const ench = engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
const ts = engine.spawnOverlay('thunderstormback', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
expect(engine.overlays.filter(ov => !ov.expired).length).toBe(4)
// Tick 10 times -> Cold Armor expires
for (let i = 0; i < 10; i++) {
engine.tick(DEFAULT_INPUT)
}
expect(player.coldArmorTicks).toBe(0)
expect(player.coldArmorType).toBeUndefined()
expect(fa.expired).toBe(true)
expect(es.expired).toBe(false)
expect(ench.expired).toBe(false)
expect(ts.expired).toBe(false)
let active = engine.overlays.filter(ov => !ov.expired)
expect(active).not.toContain(fa)
expect(active).toContain(es)
expect(active).toContain(ench)
expect(active).toContain(ts)
// Tick 10 more times (total 20) -> Energy Shield expires
for (let i = 0; i < 10; i++) {
engine.tick(DEFAULT_INPUT)
}
expect(player.energyshieldTicks).toBe(0)
expect(es.expired).toBe(true)
expect(ench.expired).toBe(false)
expect(ts.expired).toBe(false)
active = engine.overlays.filter(ov => !ov.expired)
expect(active).not.toContain(es)
expect(active).toContain(ench)
expect(active).toContain(ts)
// Tick 10 more times (total 30) -> Enchant expires
for (let i = 0; i < 10; i++) {
engine.tick(DEFAULT_INPUT)
}
expect(player.enchantTicks).toBe(0)
expect(ench.expired).toBe(true)
expect(ts.expired).toBe(false)
active = engine.overlays.filter(ov => !ov.expired)
expect(active).not.toContain(ench)
expect(active).toContain(ts)
// Tick 10 more times (total 40) -> Thunder Storm expires
for (let i = 0; i < 10; i++) {
engine.tick(DEFAULT_INPUT)
}
expect(player.thunderstormTicks).toBe(0)
expect(ts.expired).toBe(true)
active = engine.overlays.filter(ov => !ov.expired)
expect(active.length).toBe(0)
})
})
})