/** * 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) }) }) })