diff --git a/src/game/engine.ts b/src/game/engine.ts index cb06b3d..244380e 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -389,14 +389,32 @@ export class GameEngine { options?: CreateActiveOverlayOptions, ): ActiveOverlay { const pos = caster ?? { x: this.world.player.x, y: this.world.player.y } - // Cleanly replace any ongoing non-aura cast overlay for this caster to prevent oversaturated additive stacking - for (const ov of this.overlays) { - if (ov.casterId === casterId && !ov.isAura) { - ov.expired = true + const overlay = createActiveOverlay(overlayName, pos, casterId, undefined, options) + + if (overlay.isCastOverlay) { + // If spawning a Cast Overlay: only expire existing active Cast Overlays on that caster to prevent oversaturated additive stacking + for (const ov of this.overlays) { + if (ov.casterId === casterId && ov.isCastOverlay && !ov.isAura) { + ov.expired = true + } + } + } else if (overlay.isStateOverlay) { + // If spawning a State Overlay: only expire existing State Overlays on that caster if: + // - Same overlay name (refresh), OR + // - Both have stateGroup === 1 (Group 1 Cold Armor mutual exclusion). + // DO NOT touch Cast Overlays or different group State Overlays. + for (const ov of this.overlays) { + if (ov.casterId === casterId && ov.isStateOverlay && !ov.isAura) { + const isSameOverlay = ov.overlayName === overlay.overlayName + const isGroup1MutualExclusion = ov.stateGroup === 1 && overlay.stateGroup === 1 + if (isSameOverlay || isGroup1MutualExclusion) { + ov.expired = true + } + } } } + this.overlays = this.overlays.filter(ov => !ov.expired) - const overlay = createActiveOverlay(overlayName, pos, casterId, undefined, options) this.overlays.push(overlay) return overlay } @@ -663,30 +681,41 @@ export class GameEngine { // Periodic Thunder Storm simulation if (player.thunderstormTicks && player.thunderstormTicks > 0) { player.thunderstormTicks -= 1 - player.thunderstormNextStrike = (player.thunderstormNextStrike ?? 0) - 1 - if (player.thunderstormNextStrike <= 0) { - player.thunderstormNextStrike = player.thunderstormInterval ?? 41 - // Find nearest live monster within 17 subtiles (340 px) - let nearestMonster: any = null - let minDist = 340 - for (const monster of this.world.monsters) { - if (monster.state === 'dead' || monster.hp <= 0) continue - const dist = Math.hypot(monster.x - player.x, monster.y - player.y) - if (dist <= minDist) { - minDist = dist - nearestMonster = monster + if (player.thunderstormTicks <= 0) { + for (const ov of this.overlays) { + if ( + (ov.overlayName === 'thunderstormback' || ov.overlayName === 'thunderstorm') && + ov.casterId === 'player' + ) { + ov.expired = true } } - if (nearestMonster) { - const dmg = player.thunderstormDamage ?? 50 - damageMonster(this.world, nearestMonster.index, dmg) - this.spawnOverlay('light_cast_2', { x: nearestMonster.x, y: nearestMonster.y }, `monster-${nearestMonster.index}`) - this.world.events.push({ - kind: 'monsterHit', - x: nearestMonster.x, - y: nearestMonster.y, - amount: dmg, - }) + } else { + player.thunderstormNextStrike = (player.thunderstormNextStrike ?? 0) - 1 + if (player.thunderstormNextStrike <= 0) { + player.thunderstormNextStrike = player.thunderstormInterval ?? 41 + // Find nearest live monster within 17 subtiles (340 px) + let nearestMonster: any = null + let minDist = 340 + for (const monster of this.world.monsters) { + if (monster.state === 'dead' || monster.hp <= 0) continue + const dist = Math.hypot(monster.x - player.x, monster.y - player.y) + if (dist <= minDist) { + minDist = dist + nearestMonster = monster + } + } + if (nearestMonster) { + const dmg = player.thunderstormDamage ?? 50 + damageMonster(this.world, nearestMonster.index, dmg) + this.spawnOverlay('light_cast_2', { x: nearestMonster.x, y: nearestMonster.y }, `monster-${nearestMonster.index}`) + this.world.events.push({ + kind: 'monsterHit', + x: nearestMonster.x, + y: nearestMonster.y, + amount: dmg, + }) + } } } } @@ -703,17 +732,11 @@ export class GameEngine { } } - // Enchant buff duration tick & overlay management - if (player.enchantTicks && player.enchantTicks > 0) { - player.enchantTicks -= 1 - if (player.enchantTicks <= 0) { - player.enchantTicks = 0 - player.enchantDamage = undefined - player.enchantAttackRatingPercent = undefined - for (const ov of this.overlays) { - if (ov.overlayName === 'enchant' && ov.casterId === 'player') { - ov.expired = true - } + // Enchant buff overlay management (countdown handled in tickCombat) + if (!player.enchantTicks || player.enchantTicks <= 0) { + for (const ov of this.overlays) { + if (ov.overlayName === 'enchant' && ov.casterId === 'player') { + ov.expired = true } } } @@ -736,6 +759,8 @@ export class GameEngine { } } + this.overlays = this.overlays.filter(overlay => !overlay.expired) + // Blaze buff duration tick & footstep fire patch emission if (player.blazeTicks && player.blazeTicks > 0) { player.blazeTicks -= 1 diff --git a/src/game/skills.ts b/src/game/skills.ts index 07663e1..b158396 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -2233,6 +2233,119 @@ export const CANONICAL_113C_OVERLAYS: Readonly> = xOffset: 0, yOffset: 0, }, + // Authentic 1.13c Sorceress State / Buff Overlays + frozenarmor: { + overlay: 'frozenarmor', + filename: 'FrozenArmor', + frames: 24, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 13, + red: 200, + green: 200, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + shiverarmor: { + overlay: 'shiverarmor', + filename: 'FrozenArmor', + frames: 24, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 13, + red: 200, + green: 200, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + chillingarmor: { + overlay: 'chillingarmor', + filename: 'FrozenArmor', + frames: 24, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 13, + red: 200, + green: 200, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + chillarmor: { + overlay: 'chillingarmor', + filename: 'FrozenArmor', + frames: 24, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 13, + red: 200, + green: 200, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + energyshield: { + overlay: 'energyshield', + filename: 'ManaShieldBall', + frames: 8, + animRate: 8, + trans: 5, + preDraw: false, + initRadius: 0, + radius: 0, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + enchant: { + overlay: 'enchant', + filename: 'FireEnchant', + frames: 17, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 178, + blue: 64, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + thunderstormback: { + overlay: 'thunderstormback', + filename: 'ThunderstormCast_operate', + frames: 19, + animRate: 16, + trans: 3, + preDraw: true, + initRadius: 8, + radius: 8, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, // Authentic 1.13c Paladin Aura Overlays (All 33 targets: 13 pairs front/back + 7 full rings) // 1. Might (#98) might_front: { @@ -3979,6 +4092,9 @@ export interface ActiveOverlay { readonly targetMonsterIndex?: number | undefined readonly isAura?: boolean | undefined readonly auraSkillId?: number | undefined + readonly isCastOverlay?: boolean | undefined + readonly isStateOverlay?: boolean | undefined + readonly stateGroup?: number | undefined } export interface CreateActiveOverlayOptions { @@ -3986,6 +4102,9 @@ export interface CreateActiveOverlayOptions { readonly targetMonsterIndex?: number | undefined readonly isAura?: boolean | undefined readonly auraSkillId?: number | undefined + readonly isCastOverlay?: boolean | undefined + readonly isStateOverlay?: boolean | undefined + readonly stateGroup?: number | undefined } /** @@ -3999,6 +4118,21 @@ export function createActiveOverlay( options?: CreateActiveOverlayOptions, ): ActiveOverlay { const data = getOverlayTxtData(overlayName, overlaySource) + const cleanName = overlayName.trim().toLowerCase() + const isColdArmor = ['frozenarmor', 'shiverarmor', 'chillingarmor', 'chillarmor'].includes(cleanName) + const isKnownState = [ + 'frozenarmor', + 'shiverarmor', + 'chillingarmor', + 'chillarmor', + 'energyshield', + 'enchant', + 'thunderstormback', + ].includes(cleanName) + const isStateOverlay = options?.isStateOverlay ?? isKnownState + const isCastOverlay = options?.isCastOverlay ?? (!isStateOverlay && !options?.isAura) + const stateGroup = options?.stateGroup ?? (isColdArmor ? 1 : 0) + return { id: `overlay_${overlayName}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, overlayName: data.overlay, @@ -4017,6 +4151,9 @@ export function createActiveOverlay( targetMonsterIndex: options?.targetMonsterIndex, isAura: options?.isAura, auraSkillId: options?.auraSkillId, + isCastOverlay, + isStateOverlay, + stateGroup, } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 38e85b1..9507176 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -1354,7 +1354,7 @@ export function castSkill( if (skillId !== 0 && (skillId !== 41 || isFirstInfernoCast)) { const castOverlayName = getSkillCastOverlay(skillId) if (castOverlayName) { - engine.spawnOverlay(castOverlayName, { x: player.x, y: player.y }, 'player') + engine.spawnOverlay(castOverlayName, { x: player.x, y: player.y }, 'player', { isCastOverlay: true }) } } @@ -1828,7 +1828,7 @@ export function castSkill( player.coldArmorType = 'frozenarmor' player.coldArmorTicks = duration player.coldArmorSlvl = slvl - engine.spawnOverlay('frozenarmor', { x: player.x, y: player.y }, 'player', { loop: true }) + engine.spawnOverlay('frozenarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) player.cooldown = 0 status.textContent = '冰封装甲已激活' engine.metrics.casts += 1 @@ -1842,7 +1842,7 @@ export function castSkill( player.coldArmorType = 'shiverarmor' player.coldArmorTicks = duration player.coldArmorSlvl = slvl - engine.spawnOverlay('shiverarmor', { x: player.x, y: player.y }, 'player', { loop: true }) + engine.spawnOverlay('shiverarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) player.cooldown = 0 status.textContent = '碎冰装甲已激活' engine.metrics.casts += 1 @@ -1856,7 +1856,7 @@ export function castSkill( player.coldArmorType = 'chillingarmor' player.coldArmorTicks = duration player.coldArmorSlvl = slvl - engine.spawnOverlay('chillingarmor', { x: player.x, y: player.y }, 'player', { loop: true }) + engine.spawnOverlay('chillingarmor', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) player.cooldown = 0 status.textContent = '寒冰装甲已激活' engine.metrics.casts += 1 @@ -1877,6 +1877,8 @@ export function castSkill( player.thunderstormDamage = tsDmg player.thunderstormSlvl = slvl + engine.spawnOverlay('thunderstormback', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) + player.cooldown = 0 engine.metrics.casts += 1 status.textContent = '雷云风暴已激活' @@ -1893,7 +1895,7 @@ export function castSkill( player.energyshieldPct = absorbPct player.energyshieldSlvl = slvl - engine.spawnOverlay('energyshield', { x: player.x, y: player.y }, 'player', { loop: true }) + engine.spawnOverlay('energyshield', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) player.cooldown = 0 engine.metrics.casts += 1 @@ -1916,7 +1918,7 @@ export function castSkill( player.fireMasterySlvl = fireMasterySlvl player.isSorceress = true - engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: true }) + engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true }) player.cooldown = 0 engine.metrics.casts += 1 diff --git a/tests/skills/sor/issue-432-buff-visual-persistence.test.ts b/tests/skills/sor/issue-432-buff-visual-persistence.test.ts new file mode 100644 index 0000000..43280d6 --- /dev/null +++ b/tests/skills/sor/issue-432-buff-visual-persistence.test.ts @@ -0,0 +1,357 @@ +/** + * 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) + }) + }) +})