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