358 lines
15 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|