Skip to content
Merged

Fixes #1613

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
baf67c3
Closes #1612
devw4r Dec 24, 2025
ab09691
Closes #1607
devw4r Dec 24, 2025
c1b8090
Update updates.sql
devw4r Dec 25, 2025
605c10c
Update updates.sql
devw4r Dec 25, 2025
d558d30
Closes #1614
devw4r Dec 27, 2025
9750214
Update updates.sql
devw4r Dec 27, 2025
2173c59
Closes #1615
devw4r Dec 27, 2025
e791f74
Closes #1616
devw4r Dec 27, 2025
38182eb
Update updates.sql
devw4r Dec 27, 2025
03e7566
Update updates.sql
devw4r Dec 27, 2025
0b122e9
Update updates.sql
devw4r Dec 28, 2025
37f705d
Merge branch 'J' of https://github.com/devw4r/alpha-core into J
devw4r Dec 28, 2025
7b98407
Update updates.sql
devw4r Dec 28, 2025
09f6ae3
Address inconsistencies between spell tooltips and the actual effect …
devw4r Dec 28, 2025
e85fc75
Update QuestManager.py
devw4r Dec 28, 2025
429f180
SPELL_AURA_MOD_BLOCK_PERCENT
devw4r Dec 28, 2025
533c0d5
Update updates.sql
devw4r Dec 29, 2025
4e51a3e
Update SpellEffect.py
devw4r Dec 29, 2025
bd7a286
Update Definitions.py
devw4r Dec 29, 2025
2a81518
Fix creatures not stopping upon root.
devw4r Dec 29, 2025
6b8b2cb
Fix Yell not reaching anyone beyond VIEW_DISTANCE.
devw4r Dec 29, 2025
20c9141
Update Cell.py
devw4r Dec 29, 2025
4473639
Hammerhead Sharks
devw4r Dec 30, 2025
56c970b
Update updates.sql
devw4r Dec 30, 2025
e5a9b10
Update updates.sql
devw4r Dec 30, 2025
3a4b9fe
Update updates.sql
devw4r Dec 30, 2025
5fa9a5e
Update updates.sql
devw4r Dec 30, 2025
b2e090b
Update updates.sql
devw4r Dec 30, 2025
70dbf7c
Update SpellManager.py
devw4r Jan 1, 2026
7c2c1fd
Revert "Update SpellManager.py"
devw4r Jan 2, 2026
b2f66e8
Update SpellManager.py
devw4r Jan 4, 2026
b9e4920
Update AuraManager.py
devw4r Jan 4, 2026
1094f97
Update SpellManager.py
devw4r Jan 4, 2026
e1edc18
Update updates.sql
devw4r Jan 4, 2026
cd9c562
Update SpellManager.py
devw4r Jan 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions etc/databases/world/updates/updates.sql

Large diffs are not rendered by default.

50 changes: 34 additions & 16 deletions game/world/managers/maps/Cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from game.world.managers.objects.farsight.FarSightManager import FarSightManager
from threading import RLock

from utils.ConfigManager import config


class Cell:
def __init__(self, cell_x=0, cell_y=0, map_id=0, instance_id=0, key=''):
Expand Down Expand Up @@ -180,26 +182,42 @@ def send_all(self, packet, source, include_source=False, exclude=None, use_ignor
camera.broadcast_packet(packet, exclude=players_reached)

def send_all_in_range(self, packet, range_, source, include_source=True, exclude=None, use_ignore=False):
# If range is non-positive, send to all players without filtering.
if range_ <= 0:
self.send_all(packet, source, exclude)
else:
players_reached = set()
for guid, player_mgr in list(self.players.items()):
if not player_mgr.online or not player_mgr.location.distance(source.location) <= range_:
continue
if not include_source and player_mgr.guid == source.guid:
continue
if use_ignore and player_mgr.friends_manager.has_ignore(source.guid):
continue
# Never send messages to a player that does not know the source object.
if not player_mgr.guid == source.guid and source.guid not in player_mgr.known_objects:
return

is_yell = int(range_) == int(config.World.Chat.ChatRange.yell_range)
is_say = int(range_) == int(config.World.Chat.ChatRange.say_range)

players_reached = set()
for guid, player_mgr in list(self.players.items()):
# Skip offline players.
if not player_mgr.online:
continue
# Check distance.
distance = player_mgr.location.distance(source.location)
if distance > range_:
continue
# Optionally exclude source.
if not include_source and player_mgr.guid == source.guid:
continue
# Skip players that have ignored the source.
if use_ignore and player_mgr.friends_manager.has_ignore(source.guid):
continue
# Ensure the player knows about the source object if this is not a chat message.
if not is_say and not is_yell:
if guid != source.guid and source.guid not in player_mgr.known_objects:
continue
players_reached.add(player_mgr.guid)
player_mgr.enqueue_packet(packet)

# If this cell has cameras, route packets.
for camera in FarSightManager.get_cell_cameras(self):
camera.broadcast_packet(packet, exclude=players_reached)
# Add to reached players.
players_reached.add(guid)
# Send packet.
player_mgr.enqueue_packet(packet)

# Route packets via cameras if applicable.
for camera in FarSightManager.get_cell_cameras(self):
camera.broadcast_packet(packet, exclude=players_reached)

def can_deactivate(self):
return not self.has_players() and not self.has_cameras()
Expand Down
17 changes: 10 additions & 7 deletions game/world/managers/maps/GridManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,15 +197,16 @@ def _update_players_surroundings(self, cell_key, exclude_cells=None, world_objec

return affected_cells

def _get_surrounding_cells_by_cell(self, cell=None, cell_x=0, cell_y=0, map_id=0, instance_id=0):
def _get_surrounding_cells_by_cell(self, cell=None, cell_x=0, cell_y=0, map_id=0, instance_id=0, range_=0):
if cell:
cell_x = cell.cell_x
cell_y = cell.cell_y
map_id = cell.map_id
instance_id = cell.instance_id

view_distance = VIEW_DISTANCE if not range_ else range_
# Calculate how many cells to include in each direction given the view distance, at least 1.
max_cells_radius = max(1, int(VIEW_DISTANCE // CELL_SIZE))
max_cells_radius = max(1, int(view_distance // CELL_SIZE))
surrounding_cells = set()

for dx in range(-max_cells_radius, max_cells_radius + 1):
Expand All @@ -222,13 +223,15 @@ def _get_surrounding_cells_by_cell(self, cell=None, cell_x=0, cell_y=0, map_id=0

return surrounding_cells

def _get_surrounding_cells_by_object(self, world_object):
def _get_surrounding_cells_by_object(self, world_object, range_=0):
pos = world_object.location
return self._get_surrounding_cells_by_location(pos.x, pos.y, world_object.map_id, world_object.instance_id)
return self._get_surrounding_cells_by_location(
pos.x, pos.y, world_object.map_id, world_object.instance_id, range_=range_)

def _get_surrounding_cells_by_location(self, x, y, map_, instance_id):
def _get_surrounding_cells_by_location(self, x, y, map_, instance_id, range_=0):
cell_x, cell_y = CellUtils.generate_coord_data(x, y)
return self._get_surrounding_cells_by_cell(cell_x=cell_x, cell_y=cell_y, map_id=map_, instance_id=instance_id)
return self._get_surrounding_cells_by_cell(cell_x=cell_x, cell_y=cell_y, map_id=map_,
instance_id=instance_id, range_=range_)

def send_surrounding(self, packet, world_object, include_self=True, exclude=None, use_ignore=False):
if world_object.current_cell:
Expand All @@ -244,7 +247,7 @@ def send_surrounding_in_range(self, packet, world_object, range_, include_self=T
Logger.warning(f'{world_object.get_name()} Cannot send surrounding in range without current cell.')
return

for cell in self._get_surrounding_cells_by_object(world_object):
for cell in self._get_surrounding_cells_by_object(world_object, range_=range_):
cell.send_all_in_range(packet, range_, world_object, include_self, exclude, use_ignore)

def get_surrounding_objects(self, world_object, object_types):
Expand Down
64 changes: 5 additions & 59 deletions game/world/managers/objects/spell/CastingSpell.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,66 +483,12 @@ def requires_combo_points(self):
def requires_aura_state(self):
return self.spell_entry.CasterAuraState != 0

'''
TODO: Figure out this for proper spell min max damage calculation.
void __fastcall Spell_C_GetMinMaxPoints(int effectIndex, int a2, int *min, int *max, unsigned int level, int isPet)
{
signed int SpellLevel; // edi
int v10; // eax
double v11; // st7
char v13; // c0
double v14; // st7
int v15; // ecx
int v16; // ecx
int v17; // edi
double v18; // [esp+0h] [ebp-18h]
int dieSides; // [esp+14h] [ebp-4h]
int maxBonus; // [esp+28h] [ebp+10h]
float maxBonusa; // [esp+28h] [ebp+10h]
int minBonus; // [esp+2Ch] [ebp+14h]

*min = 0;
*max = 0;
if ( effectIndex )
{
SpellLevel = level;
dieSides = *(_DWORD *)(effectIndex + 4 * a2 + 224);
if ( !level )
SpellLevel = Spell_C_GetSpellLevel(*(_DWORD *)effectIndex, isPet);
v10 = *(_DWORD *)(effectIndex + 88);
maxBonus = SpellLevel;
if ( v10 > 0 )
{
SpellLevel -= v10;
maxBonus = SpellLevel;
}
if ( SpellLevel < 0 )
{
SpellLevel = 0;
maxBonus = 0;
}
v11 = (double)maxBonus * *(float *)(effectIndex + 4 * a2 + 260);
maxBonusa = v11;
minBonus = (__int64)v11;
_floor(maxBonusa);
v18 = maxBonusa;
if ( v13 )
v14 = _floor(v18);
else
v14 = _ceil(v18);
v15 = SpellLevel * *(_DWORD *)(effectIndex + 4 * a2 + 248) + *(_DWORD *)(effectIndex + 4 * a2 + 236);
*min = v15;
*min = *(_DWORD *)(effectIndex + 4 * a2 + 272) + minBonus + v15;
v16 = dieSides * *(_DWORD *)(effectIndex + 4 * a2 + 236);
*max = v16;
v17 = v16 + *(_DWORD *)(effectIndex + 4 * a2 + 248) * dieSides * SpellLevel;
*max = v17;
*max = *(_DWORD *)(effectIndex + 4 * a2 + 272) + (__int64)v14 + v17;
}
}
'''
def calculate_effective_level(self):
level = self.spell_caster.level
skill = 0
if self.spell_caster.is_player():
skill = self.spell_caster.skill_manager.get_skill_value_for_spell_id(self.spell_entry.ID)

level = self.spell_caster.level if not skill else int(skill / 5)
if level > self.spell_entry.MaxLevel > 0:
level = self.spell_entry.MaxLevel
elif level < self.spell_entry.BaseLevel:
Expand Down
12 changes: 11 additions & 1 deletion game/world/managers/objects/spell/SpellEffect.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class SpellEffect:

caster_effective_level: int
effect_index: int
effect_points: int = 0
targets: EffectTargets
radius_entry: SpellRadius

Expand All @@ -50,6 +51,7 @@ def __init__(self, casting_spell, index):
self.load_effect(casting_spell.spell_entry, index)

self.caster_effective_level = casting_spell.caster_effective_level
self.effect_points = self.get_effect_points()
self.targets = EffectTargets(casting_spell, self)
self.radius_entry = DbcDatabaseManager.spell_radius_get_by_id(self.radius_index) if self.radius_index else None
self.casting_spell = casting_spell
Expand Down Expand Up @@ -158,7 +160,15 @@ def is_periodic(self):
return self.aura_period != 0

def get_effect_points(self) -> int:
rolled_points = random.randint(1, self.die_sides + self.dice_per_level) if self.die_sides != 0 else 0
if self.effect_points:
return self.effect_points

# Calculate min and max dice roll values.
min_roll = 1 if self.die_sides != 0 else 0
max_roll = self.die_sides + self.dice_per_level if self.die_sides != 0 else 0

# Roll.
rolled_points = random.randint(min_roll, max_roll) if self.die_sides != 0 else 0
return self.base_points + int(self.real_points_per_level * self.caster_effective_level) + rolled_points

def get_effect_simple_points(self) -> int:
Expand Down
26 changes: 17 additions & 9 deletions game/world/managers/objects/spell/SpellManager.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import math
import time
from random import randint
from struct import pack
Expand Down Expand Up @@ -420,6 +419,13 @@ def perform_spell_cast(self, casting_spell: CastingSpell, validate=True):
self.send_cast_result(casting_spell, SpellCheckCastResult.SPELL_NO_ERROR)
self.send_spell_go(casting_spell)

# Spells that use the KneelLoop animation causes the client to get stuck in this animation until relog.
# Send a KneelEnd animation to resolve this issue. e.g. spell 6717 'Place Lion Carcass'
if casting_spell.spell_visual_entry and casting_spell.spell_visual_entry.CastKit == 380: # KneelLoop.
data = pack(f'QI', self.caster.guid, 444)
packet = PacketWriter.get_packet(OpCode.SMSG_PLAY_SPELL_VISUAL, data)
self.caster.enqueue_packet(packet)

if casting_spell.requires_combo_points():
# Combo points will be reset by consume_resources_for_cast.
casting_spell.spent_combo_points = self.caster.combo_points
Expand Down Expand Up @@ -835,14 +841,15 @@ def send_cast_start(self, casting_spell):
if not self.caster.is_unit(by_mask=True):
return # Non-unit casters should not broadcast their casts.

is_player = self.caster.is_player()
source_guid = self.caster.guid
if casting_spell.source_item:
source_guid = casting_spell.source_item.guid

source_guid = casting_spell.initial_target.guid if casting_spell.initial_target_is_item() else self.caster.guid
cast_flags = casting_spell.cast_flags

# Validate if this spell crashes the client.
# Force SpellCastFlags.CAST_FLAG_PROC, which hides the start cast.
if not is_player and not ExtendedSpellData.UnitSpellsValidator.spell_has_valid_cast(casting_spell):
if not self.caster.is_player() and not ExtendedSpellData.UnitSpellsValidator.spell_has_valid_cast(casting_spell):
Logger.warning(f'Hiding spell {casting_spell.spell_entry.Name_enUS} start cast due invalid cast.')
cast_flags |= SpellCastFlags.CAST_FLAG_PROC

Expand All @@ -866,7 +873,7 @@ def send_cast_start(self, casting_spell):
# Spell start.
data = pack(signature, *data)
packet = PacketWriter.get_packet(OpCode.SMSG_SPELL_START, data)
self.caster.get_map().send_surrounding(packet, self.caster, include_self=is_player)
self.caster.get_map().send_surrounding(packet, self.caster, include_self=self.caster.is_player())

def handle_channel_start(self, casting_spell):
if not casting_spell.is_channeled():
Expand Down Expand Up @@ -960,13 +967,14 @@ def send_spell_resist_result(self, casting_spell, damage_info):
self.caster.get_map().send_surrounding(packet, self.caster, include_self=is_player)

def send_spell_go(self, casting_spell):
# The client expects the source to only be set for unit casters.
caster_unit = casting_spell.initial_target.guid if casting_spell.initial_target_is_item() \
else self.caster.guid
source_guid = self.caster.guid
if casting_spell.source_item:
source_guid = casting_spell.source_item.guid

caster_guid = self.caster.guid if self.caster.is_unit(by_mask=True) else 0

# Exclude proc flag from GO - proc casts are visible in 0.5.5 screenshots.
data = [caster_unit, caster_guid, casting_spell.spell_entry.ID,
data = [source_guid, caster_guid, casting_spell.spell_entry.ID,
casting_spell.cast_flags & ~SpellCastFlags.CAST_FLAG_PROC]

signature = '<2QIHB' # caster, source, ID, flags .. (targets, ammo info).
Expand Down
22 changes: 17 additions & 5 deletions game/world/managers/objects/spell/aura/AuraEffectHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from game.world.managers.objects.units.player.StatManager import UnitStats
from game.world.managers.objects.spell import ExtendedSpellData
from utils.Logger import Logger
from utils.constants.ItemCodes import InventoryError
from utils.constants import ItemCodes
from utils.constants.ItemCodes import InventoryError, ItemSubClasses
from utils.constants.MiscCodes import UnitDynamicTypes, ProcFlags
from utils.constants.PetCodes import PetSlot
from utils.constants.SpellCodes import ShapeshiftForms, AuraTypes, SpellSchoolMask, SpellImmunity
Expand Down Expand Up @@ -774,16 +775,27 @@ def handle_mod_dodge_chance(aura, effect_target, remove):
effect_target.stat_manager.apply_aura_stat_bonus(aura.index, UnitStats.DODGE_CHANCE,
amount_percent, percentual=False)

# TODO: Need to have separate blocking stats depending on item subclass.
# e.g. 'Increases your chance to block with a Shield (not a Buckler) by 2%.'
@staticmethod
def handle_mod_block_chance(aura, effect_target, remove):
if remove:
effect_target.stat_manager.remove_aura_stat_bonus(aura.index, percentual=False)
return

item_subclass = aura.spell_effect.casting_spell.spell_entry.EquippedItemSubclass
shield_present = item_subclass & (1 << ItemSubClasses.ITEM_SUBCLASS_SHIELD)
buckler_present = item_subclass & (1 << ItemSubClasses.ITEM_SUBCLASS_BUCKLER)

if shield_present and buckler_present:
stat_type = UnitStats.BLOCK_SHIELD | UnitStats.BLOCK_BUCKLER
elif shield_present:
stat_type = UnitStats.BLOCK_SHIELD
elif buckler_present:
stat_type = UnitStats.BLOCK_BUCKLER
else:
return

amount_percent = aura.get_effect_points() / 100
effect_target.stat_manager.apply_aura_stat_bonus(aura.index, UnitStats.BLOCK_CHANCE,
amount_percent, percentual=False)
effect_target.stat_manager.apply_aura_stat_bonus(aura.index, stat_type, amount_percent, percentual=False)

@staticmethod
def handle_mod_threat(aura, effect_target, remove):
Expand Down
2 changes: 1 addition & 1 deletion game/world/managers/objects/spell/aura/AuraManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def check_aura_interrupts(self, moved=False, turned=False, changed_stand_state=F

# An interrupt for sitting does not exist.
# Food/drink spells do claim that the player must remain seated.
# In later versions an aurainterrupt exists for this purpose.
# In later versions an aura interrupt exists for this purpose.
if aura.source_spell.is_refreshment_spell() and changed_stand_state and \
self.unit_mgr.stand_state != StandState.UNIT_SITTING:
self.remove_aura(aura)
Expand Down
15 changes: 10 additions & 5 deletions game/world/managers/objects/units/UnitManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,12 @@ def can_block(self, attacker_location=None, in_combat=False):
return self.has_block_passive and not self.spell_manager.is_casting() and \
not self.unit_state & UnitStates.STUNNED

def has_shield(self):
return False

def has_buckler(self):
return False

def can_parry(self, attacker_location=None, in_combat=False):
if not in_combat:
return self.has_parry_passive
Expand Down Expand Up @@ -1243,11 +1249,6 @@ def set_stealthed(self, active=True, index=-1) -> bool:
def set_rooted(self, active=True, index=-1) -> bool:
is_rooted = self.set_move_flag(MoveFlags.MOVEFLAG_ROOTED, active, index)
is_rooted |= self.set_unit_state(UnitStates.ROOTED, active, index)

if is_rooted:
# Stop movement if needed.
self.movement_manager.stop()

return is_rooted

def set_stunned(self, active=True, index=-1) -> bool:
Expand Down Expand Up @@ -1337,6 +1338,10 @@ def set_move_flag(self, move_flag, active=True, index=-1) -> bool:
else:
self.movement_flags &= ~move_flag

# Force movement stop if rooted or immobilized.
if flag_changed and is_active and move_flag in {MoveFlags.MOVEFLAG_ROOTED, MoveFlags.MOVEFLAG_IMMOBILIZED}:
self.movement_manager.stop(force=True)

# Only broadcast swimming, rooted, walking or immobilized.
if flag_changed and move_flag in {MoveFlags.MOVEFLAG_SWIMMING, MoveFlags.MOVEFLAG_ROOTED,
MoveFlags.MOVEFLAG_IMMOBILIZED, MoveFlags.MOVEFLAG_WALK}:
Expand Down
4 changes: 4 additions & 0 deletions game/world/managers/objects/units/creature/CreatureManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ def finish_loading(self):
self.apply_default_auras()

# Movement.
self.set_move_flag(MoveFlags.MOVEFLAG_SWIMMING, active=self.static_flags & CreatureStaticFlags.AQUATIC != 0)
self.set_move_flag(MoveFlags.MOVEFLAG_WALK, active=not self.should_always_run_ooc())
self.movement_manager.initialize_or_reset()

Expand Down Expand Up @@ -948,6 +949,9 @@ def _update_swimming_state(self):
if not self.can_swim():
return

if not self.get_map().is_active_cell_for_location(self.location):
return

is_under_water = self.is_under_water()

if is_under_water and not self.movement_flags & MoveFlags.MOVEFLAG_SWIMMING:
Expand Down
Loading