fix(skill): preserve buff visual persistence and enforce armor mutual exclusion (Fixes #432)
This commit is contained in:
parent
bafa83df55
commit
d9ad6df63f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2233,6 +2233,119 @@ export const CANONICAL_113C_OVERLAYS: Readonly<Record<string, OverlayTxtData>> =
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue