fix(skill): preserve buff visual persistence and enforce armor mutual exclusion (Fixes #432)

This commit is contained in:
troytt 2026-09-24 13:55:10 +00:00
parent bafa83df55
commit d9ad6df63f
4 changed files with 565 additions and 44 deletions

View File

@ -389,14 +389,32 @@ export class GameEngine {
options?: CreateActiveOverlayOptions, options?: CreateActiveOverlayOptions,
): ActiveOverlay { ): ActiveOverlay {
const pos = caster ?? { x: this.world.player.x, y: this.world.player.y } 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 const overlay = createActiveOverlay(overlayName, pos, casterId, undefined, options)
for (const ov of this.overlays) {
if (ov.casterId === casterId && !ov.isAura) { if (overlay.isCastOverlay) {
ov.expired = true // 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) this.overlays = this.overlays.filter(ov => !ov.expired)
const overlay = createActiveOverlay(overlayName, pos, casterId, undefined, options)
this.overlays.push(overlay) this.overlays.push(overlay)
return overlay return overlay
} }
@ -663,30 +681,41 @@ export class GameEngine {
// Periodic Thunder Storm simulation // Periodic Thunder Storm simulation
if (player.thunderstormTicks && player.thunderstormTicks > 0) { if (player.thunderstormTicks && player.thunderstormTicks > 0) {
player.thunderstormTicks -= 1 player.thunderstormTicks -= 1
player.thunderstormNextStrike = (player.thunderstormNextStrike ?? 0) - 1 if (player.thunderstormTicks <= 0) {
if (player.thunderstormNextStrike <= 0) { for (const ov of this.overlays) {
player.thunderstormNextStrike = player.thunderstormInterval ?? 41 if (
// Find nearest live monster within 17 subtiles (340 px) (ov.overlayName === 'thunderstormback' || ov.overlayName === 'thunderstorm') &&
let nearestMonster: any = null ov.casterId === 'player'
let minDist = 340 ) {
for (const monster of this.world.monsters) { ov.expired = true
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) { } else {
const dmg = player.thunderstormDamage ?? 50 player.thunderstormNextStrike = (player.thunderstormNextStrike ?? 0) - 1
damageMonster(this.world, nearestMonster.index, dmg) if (player.thunderstormNextStrike <= 0) {
this.spawnOverlay('light_cast_2', { x: nearestMonster.x, y: nearestMonster.y }, `monster-${nearestMonster.index}`) player.thunderstormNextStrike = player.thunderstormInterval ?? 41
this.world.events.push({ // Find nearest live monster within 17 subtiles (340 px)
kind: 'monsterHit', let nearestMonster: any = null
x: nearestMonster.x, let minDist = 340
y: nearestMonster.y, for (const monster of this.world.monsters) {
amount: dmg, 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 // Enchant buff overlay management (countdown handled in tickCombat)
if (player.enchantTicks && player.enchantTicks > 0) { if (!player.enchantTicks || player.enchantTicks <= 0) {
player.enchantTicks -= 1 for (const ov of this.overlays) {
if (player.enchantTicks <= 0) { if (ov.overlayName === 'enchant' && ov.casterId === 'player') {
player.enchantTicks = 0 ov.expired = true
player.enchantDamage = undefined
player.enchantAttackRatingPercent = undefined
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 // Blaze buff duration tick & footstep fire patch emission
if (player.blazeTicks && player.blazeTicks > 0) { if (player.blazeTicks && player.blazeTicks > 0) {
player.blazeTicks -= 1 player.blazeTicks -= 1

View File

@ -2233,6 +2233,119 @@ export const CANONICAL_113C_OVERLAYS: Readonly<Record<string, OverlayTxtData>> =
xOffset: 0, xOffset: 0,
yOffset: 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) // Authentic 1.13c Paladin Aura Overlays (All 33 targets: 13 pairs front/back + 7 full rings)
// 1. Might (#98) // 1. Might (#98)
might_front: { might_front: {
@ -3979,6 +4092,9 @@ export interface ActiveOverlay {
readonly targetMonsterIndex?: number | undefined readonly targetMonsterIndex?: number | undefined
readonly isAura?: boolean | undefined readonly isAura?: boolean | undefined
readonly auraSkillId?: number | undefined readonly auraSkillId?: number | undefined
readonly isCastOverlay?: boolean | undefined
readonly isStateOverlay?: boolean | undefined
readonly stateGroup?: number | undefined
} }
export interface CreateActiveOverlayOptions { export interface CreateActiveOverlayOptions {
@ -3986,6 +4102,9 @@ export interface CreateActiveOverlayOptions {
readonly targetMonsterIndex?: number | undefined readonly targetMonsterIndex?: number | undefined
readonly isAura?: boolean | undefined readonly isAura?: boolean | undefined
readonly auraSkillId?: number | 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, options?: CreateActiveOverlayOptions,
): ActiveOverlay { ): ActiveOverlay {
const data = getOverlayTxtData(overlayName, overlaySource) 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 { return {
id: `overlay_${overlayName}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, id: `overlay_${overlayName}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
overlayName: data.overlay, overlayName: data.overlay,
@ -4017,6 +4151,9 @@ export function createActiveOverlay(
targetMonsterIndex: options?.targetMonsterIndex, targetMonsterIndex: options?.targetMonsterIndex,
isAura: options?.isAura, isAura: options?.isAura,
auraSkillId: options?.auraSkillId, auraSkillId: options?.auraSkillId,
isCastOverlay,
isStateOverlay,
stateGroup,
} }
} }

View File

@ -1354,7 +1354,7 @@ export function castSkill(
if (skillId !== 0 && (skillId !== 41 || isFirstInfernoCast)) { if (skillId !== 0 && (skillId !== 41 || isFirstInfernoCast)) {
const castOverlayName = getSkillCastOverlay(skillId) const castOverlayName = getSkillCastOverlay(skillId)
if (castOverlayName) { 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.coldArmorType = 'frozenarmor'
player.coldArmorTicks = duration player.coldArmorTicks = duration
player.coldArmorSlvl = slvl 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 player.cooldown = 0
status.textContent = '冰封装甲已激活' status.textContent = '冰封装甲已激活'
engine.metrics.casts += 1 engine.metrics.casts += 1
@ -1842,7 +1842,7 @@ export function castSkill(
player.coldArmorType = 'shiverarmor' player.coldArmorType = 'shiverarmor'
player.coldArmorTicks = duration player.coldArmorTicks = duration
player.coldArmorSlvl = slvl 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 player.cooldown = 0
status.textContent = '碎冰装甲已激活' status.textContent = '碎冰装甲已激活'
engine.metrics.casts += 1 engine.metrics.casts += 1
@ -1856,7 +1856,7 @@ export function castSkill(
player.coldArmorType = 'chillingarmor' player.coldArmorType = 'chillingarmor'
player.coldArmorTicks = duration player.coldArmorTicks = duration
player.coldArmorSlvl = slvl 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 player.cooldown = 0
status.textContent = '寒冰装甲已激活' status.textContent = '寒冰装甲已激活'
engine.metrics.casts += 1 engine.metrics.casts += 1
@ -1877,6 +1877,8 @@ export function castSkill(
player.thunderstormDamage = tsDmg player.thunderstormDamage = tsDmg
player.thunderstormSlvl = slvl player.thunderstormSlvl = slvl
engine.spawnOverlay('thunderstormback', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
player.cooldown = 0 player.cooldown = 0
engine.metrics.casts += 1 engine.metrics.casts += 1
status.textContent = '雷云风暴已激活' status.textContent = '雷云风暴已激活'
@ -1893,7 +1895,7 @@ export function castSkill(
player.energyshieldPct = absorbPct player.energyshieldPct = absorbPct
player.energyshieldSlvl = slvl 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 player.cooldown = 0
engine.metrics.casts += 1 engine.metrics.casts += 1
@ -1916,7 +1918,7 @@ export function castSkill(
player.fireMasterySlvl = fireMasterySlvl player.fireMasterySlvl = fireMasterySlvl
player.isSorceress = true 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 player.cooldown = 0
engine.metrics.casts += 1 engine.metrics.casts += 1

View File

@ -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)
})
})
})