fix(Scripts/OnyxiasLair): Match Onyxia's phase transitions and Eruption to retail (#27843)

This commit is contained in:
Andrew
2026-09-27 16:27:00 -03:00
committed by GitHub
parent 5237a51168
commit d8daef1f55
8 changed files with 653 additions and 58 deletions
@@ -0,0 +1,6 @@
-- Onyxia's Lair, Bellowing Roar and Eruption
DELETE FROM `spell_script_names` WHERE `spell_id` IN (18431, 17731, 69294) AND `ScriptName` = 'spell_onyxia_disturb_lava_fissure';
INSERT INTO `spell_script_names` (`spell_id`, `ScriptName`) VALUES
(18431, 'spell_onyxia_disturb_lava_fissure'),
(17731, 'spell_onyxia_disturb_lava_fissure'),
(69294, 'spell_onyxia_disturb_lava_fissure');
+1 -1
View File
@@ -164,7 +164,7 @@ go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1
| guild/charter_bank | charter buy+turn-in | P2 | covered | — |
| instances/bind_reset | party tele; ritual summon | P2 | covered; post-reset summon `blocked-harness` (AcceptSummon after reset) | #10708 |
| instances/classic/stratholme | Timmy remains hidden while a relevant Square Scarlet lives, then emerges after the area is clear | P2 | covered (`TestAC_26363_TimmyEmergesAfterSquareCleared`) | #26363 |
| instances/classic/onyxias_lair | Onyxia's phase 2 whelps follow a retail sniff: 19 at liftoff (ten rookery points, a liftoff-only point, and the eight back-row eggs each new whelp hatches, 2s later), then each point respawns 30-60s after its own whelp dies and the eggs hatch again, 18 in wave two; no whelp casts Teleport Self at a bot it can walk to | P2 | covered (`TestOnyxia_WhelpCadence`) | — |
| instances/classic/onyxias_lair | Onyxia's phase 2 whelps follow a retail sniff: 19 at liftoff (ten rookery points, a liftoff-only point, and the eight back-row eggs each new whelp hatches, 2s later), then each point respawns 30-60s after its own whelp dies and the eggs hatch again, 18 in wave two; no whelp casts Teleport Self at a bot it can walk to; at 40% she lands straight down in about 10s, first Bellowing Roar 2s after touchdown, and each landing Roar sets off the Lava Fissures near her, each fissure casting Eruption itself, and only a fissure above the lair floor passing it on to its neighbours in the same tick, so a Roar erupts a cluster around her and never the whole floor, with a crack animation per hit, never before a Roar lands and never twice inside a fissure's 10s cooldown | P2 | covered (`TestOnyxia_WhelpCadence`, `TestOnyxia_EruptionFollowsBellowingRoar`) | — |
| instances/ulduar | named tele; Freya wave interval; a Laughing Skull's Lunatic Gaze stops at the brain room's geometry instead of draining sanity through it; a wave of Elder Brightleaf's Unstable Sun Beams stays capped however many players stand in range; Psychosis and Malady of the Mind stop picking a player once they are at 40 Sanity or less; Algalon holds still for 3s once his Big Bang cast lands instead of resuming melee and Quantum Strike on the next tick; a Writhing Lasher stays up past 62947's own 10s summon duration, because the despawn type its AI sets in `IsSummonedBy` is no longer overwritten on the way out of the summon call; a wipe to Thorim's melee with a death-persistent damage shield on the last victim spawns no Cache of Storms while his hard-reset despawn runs, and a lethal hit on the respawned Thorim still yields, runs the outro to his teleport-out without an evade and leaves the saved encounter DONE; an Ancient Water Spirit whose Tidal Wave is kicked casts no damage-and-knockback follow-up; one Elder left alive spawns the one-Elder Freya's Gift, which is the chest whose emblem count the fix corrects; Ignis dying kills every Iron Construct including the dormant ones, and his Kill All Constructs instakill spares the player and every other creature in range; after a wipe exactly one Algalon comes back through his hard-reset respawn, arriving from the sky flagged immune before he lands, even when a player re-enters the raid inside the 20s respawn window, and it stays one past the 30s reload fallback; a Kick that lands on Flame Leviathan stops his Flame Vents channel | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestAC_27455_*`, `TestUlduar_BrightleafSunBeamsCappedPerWave`, `TestUlduar_FreyaWardLasherOutlivesSummonDuration`, `TestUlduar_ThorimEvadeDespawnDoesNotYield`, `TestUlduar_FreyaGiftMatchesElderCount`, `TestAC_27736_IgnisKillsAllConstructs`, `TestUlduar_AlgalonWipeRespawnsOnce`, `TestUlduar_FlameVentsInterruptedByKick`); Kologarn Charge `blocked-harness` (bridge Z after Charge); Freya's Gift emblem counts `blocked-harness` (a chest's loot opens only through SPELL_EFFECT_OPEN_LOCK, and CMSG_CAST_SPELL carries no TARGET_FLAG_GAMEOBJECT); Ignis constructs on 25-man `blocked-harness` (no way to set a bot's raid difficulty: CMSG_SET_RAID_DIFFICULTY is unexported and a `characters`.`instance_mode_mask` write is overwritten by the save on Relog's logout) | #26266 #27095 #27455 #27539 #27590 #27602 #27736 |
| professions/trainers | every Engineering trainer group offers a visitor exactly the twelve TBC master-tier recipes its class can learn — and none of the others, so a broken class filter fails too. Eleven are class-restricted goggles: four classes cover all twelve across the eight groups, two characters per class cover the Horde/Alliance and Aldor/Scryer interaction splits, and the remaining five classes pin the armour-type sharing at Technician Mihila. Why a hidden recipe is hidden is pinned too: either the class cannot wear the goggle at all — the refusal read from SMSG_INVENTORY_CHANGE_FAILURE, which must be EQUIP_ERR_NO_REQUIRED_PROFICIENCY — or the goggle is another class's within the same armour type, which is why armour proficiency would be the wrong filter to gate on | P2 | covered (`TestAC_27146_*`) | #27146 |
| world/dalaran | Dalaran's faction guards eject a hostile player from the Silver Enclave and Sunreaver's Sanctuary, and from nowhere else — public mailboxes, the street, the sewers and the neutral inn are left alone. Both directions are asserted: only checking public ground would pass a build where the guards never fire. One case parks a pet inside a quarter with its owner outside, since every other case is a lone player and would not notice the guard reading the owner's position instead of the trespasser's | P2 | covered (`TestAC_4467_*`) | #4467 |
@@ -325,7 +325,7 @@ func TestOnyxia_WhelpCadence(t *testing.T) {
padX, padY, padZ = float32(-40.0), float32(-215.0), float32(-84.0)
sampleEvery = 25 * time.Millisecond
// Onyxia walks to her takeoff spot after dropping below 65% and yells as she lifts off.
// Onyxia yells on dropping below 65%, then walks to her takeoff spot; the whelps come at liftoff.
liftoffWindow = 60 * time.Second
// All eleven points are summoned in one tick.
burstSpread = 1500 * time.Millisecond
@@ -0,0 +1,564 @@
//go:build e2e
package onyxiaslair_test
import (
"encoding/binary"
"fmt"
"math"
"sort"
"strings"
"sync"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/AzerothGhost/client"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
)
const (
spellBellowingRoar = uint32(18431)
spellFireball = uint32(18392)
spellFireball25 = uint32(68926)
spellEruption = uint32(17731)
spellEruption25 = uint32(69294)
smsgGameObjectCustomAnim = uint16(0x0B3)
)
// The Lava Fissures that sit above the lair floor. Retail sniffs show only these passing an
// Eruption on; a fissure sunk below the floor erupts when hit but its own Eruption hits nothing.
var lavaFissuresAboveFloor = map[uint32]bool{
176811: true, 176814: true, 176821: true, 176824: true, 176827: true, 176829: true,
176830: true, 176839: true, 176841: true, 176908: true, 176921: true,
}
// The 52 Lava Fissure templates; each has exactly one spawn on map 249.
func isLavaFissure(entry uint32) bool {
return (entry >= 176513 && entry <= 176515) || (entry >= 176809 && entry <= 176842) ||
(entry >= 176908 && entry <= 176922)
}
type castSeen struct {
at time.Time
caster uint64
spell uint32
// Onyxia's position when a Roar landed, as cached on arrival.
x, y, z float32
}
type animSeen struct {
at time.Time
guid uint64
}
type eruptionObserver struct {
mu sync.Mutex
fireballAt time.Time
roarStarts []castSeen
roarGos []castSeen
eruptions []castSeen
anims []animSeen
}
func newEruptionObserver(t *testing.T, bot *e2eharness.ScenarioBot, onyxia uint64) *eruptionObserver {
o := &eruptionObserver{}
cancel := bot.World.AddPacketHook(func(opcode uint16, data []byte) {
now := time.Now()
switch opcode {
case client.SmsgSpellStart, client.SmsgSpellGo:
caster, spell, ok := castHeader(data)
if !ok {
return
}
o.mu.Lock()
defer o.mu.Unlock()
switch {
case caster == onyxia && (spell == spellFireball || spell == spellFireball25) && o.fireballAt.IsZero():
o.fireballAt = now
case caster == onyxia && spell == spellBellowingRoar:
c := castSeen{at: now, caster: caster, spell: spell}
if opcode == client.SmsgSpellStart {
o.roarStarts = append(o.roarStarts, c)
return
}
if u := bot.World.GetObject(onyxia); u != nil {
c.x, c.y, c.z = u.PosX, u.PosY, u.PosZ
}
o.roarGos = append(o.roarGos, c)
case opcode == client.SmsgSpellGo && (spell == spellEruption || spell == spellEruption25):
o.eruptions = append(o.eruptions, castSeen{at: now, caster: caster, spell: spell})
}
case smsgGameObjectCustomAnim:
if len(data) < 12 {
return
}
guid := binary.LittleEndian.Uint64(data[:8])
if !isLavaFissure(uint32((guid >> 24) & 0xFFFFFF)) {
return
}
o.mu.Lock()
o.anims = append(o.anims, animSeen{at: now, guid: guid})
o.mu.Unlock()
}
})
t.Cleanup(cancel)
return o
}
func (o *eruptionObserver) firstFireball() time.Time {
o.mu.Lock()
defer o.mu.Unlock()
return o.fireballAt
}
func (o *eruptionObserver) snapshot() (starts, gos, eruptions []castSeen, anims []animSeen) {
o.mu.Lock()
defer o.mu.Unlock()
return append([]castSeen(nil), o.roarStarts...), append([]castSeen(nil), o.roarGos...),
append([]castSeen(nil), o.eruptions...), append([]animSeen(nil), o.anims...)
}
type landingSeen struct {
start time.Time
length time.Duration
fromZ float32
toZ float32
}
// watchLanding records the first of Onyxia's splines that drops her 10y or more: the phase 3
// landing. It parses SMSG_MONSTER_MOVE itself because the harness reads the animation fields
// under the wrong flag bit, which garbles the duration of MoveLand's animated spline.
func watchLanding(t *testing.T, bot *e2eharness.ScenarioBot, onyxia uint64) func() landingSeen {
var mu sync.Mutex
var seen landingSeen
cancel := bot.World.AddPacketHook(func(opcode uint16, data []byte) {
if opcode != client.SmsgMonsterMove {
return
}
guid, fromZ, toZ, length, ok := monsterMoveDrop(data)
if !ok || guid != onyxia || fromZ-toZ < 10 {
return
}
mu.Lock()
defer mu.Unlock()
if seen.start.IsZero() {
seen = landingSeen{start: time.Now(), length: length, fromZ: fromZ, toZ: toZ}
}
})
t.Cleanup(cancel)
return func() landingSeen {
mu.Lock()
defer mu.Unlock()
return seen
}
}
// monsterMoveDrop reads an SMSG_MONSTER_MOVE as AzerothCore's PacketBuilder writes it: the mover's
// packed GUID, a byte, the start point, the spline id, the facing, the flags, the animation and
// parabolic extras when flagged, the duration, then the path.
func monsterMoveDrop(data []byte) (guid uint64, fromZ, toZ float32, length time.Duration, ok bool) {
const (
flagFlying = 0x00002000
flagCatmullrom = 0x00040000
flagAnimation = 0x00200000
flagParabolic = 0x00000800
)
off := 0
u32 := func() (uint32, bool) {
if off+4 > len(data) {
return 0, false
}
v := binary.LittleEndian.Uint32(data[off:])
off += 4
return v, true
}
f32 := func() (float32, bool) {
v, ok := u32()
return math.Float32frombits(v), ok
}
if off >= len(data) {
return
}
mask := data[off]
off++
for bit := 0; bit < 8; bit++ {
if mask&(1<<uint(bit)) == 0 {
continue
}
if off >= len(data) {
return
}
guid |= uint64(data[off]) << (8 * uint(bit))
off++
}
off++ // unk byte
off += 8 // start x, y
if fromZ, ok = f32(); !ok {
return
}
off += 4 // spline id
if off >= len(data) {
return 0, 0, 0, 0, false
}
facing := data[off]
off++
switch facing {
case 1: // stop: no path follows
return 0, 0, 0, 0, false
case 2:
off += 12
case 3:
off += 8
case 4:
off += 4
}
flags, ok := u32()
if !ok {
return
}
if flags&flagAnimation != 0 {
off += 1 + 4
}
duration, ok := u32()
if !ok {
return
}
if flags&flagParabolic != 0 {
off += 4 + 4
}
count, ok := u32()
if !ok || count == 0 {
return 0, 0, 0, 0, false
}
if flags&(flagFlying|flagCatmullrom) != 0 {
off += int(count-1)*12 + 8 // every point in full; skip to the last one's z
} else {
off += 8 // linear paths send the destination first, then packed offsets
}
if toZ, ok = f32(); !ok {
return
}
return guid, fromZ, toZ, time.Duration(duration) * time.Millisecond, true
}
type fissurePos struct{ x, y, z float32 }
func loadLavaFissures(t *testing.T) map[uint32]fissurePos {
t.Helper()
db, err := e2eharness.OpenWorldDB()
if err != nil {
e2eharness.Preconditionf(t, "world DB: %v", err)
}
defer db.Close()
rows, err := db.Query("SELECT `id`, `position_x`, `position_y`, `position_z` FROM `gameobject` WHERE `map` = ?", onyxiasLairMap)
if err != nil {
e2eharness.Preconditionf(t, "load Onyxia's Lair gameobjects: %v", err)
}
defer rows.Close()
out := map[uint32]fissurePos{}
for rows.Next() {
var id uint32
var p fissurePos
if err := rows.Scan(&id, &p.x, &p.y, &p.z); err != nil {
e2eharness.Preconditionf(t, "scan gameobject: %v", err)
}
if isLavaFissure(id) {
out[id] = p
}
}
if len(out) != 52 {
e2eharness.Preconditionf(t, "found %d Lava Fissure spawns on map %d, want 52", len(out), onyxiasLairMap)
}
return out
}
func dist3(ax, ay, az, bx, by, bz float32) float32 {
return float32(math.Sqrt(float64((ax-bx)*(ax-bx) + (ay-by)*(ay-by) + (az-bz)*(az-bz))))
}
// Onyxia's cached Z is the start of her last spline, which after the landing is still her flight
// height, so distances from her are measured flat.
func dist2(ax, ay, bx, by float32) float32 {
return float32(math.Hypot(float64(ax-bx), float64(ay-by)))
}
// Phase 3 Eruption, measured against retail sniffs: Bellowing Roar's own activate-object effect
// sets off every Lava Fissure within 13y of Onyxia when the cast lands, and each fissure casts
// Eruption itself. Only a fissure above the lair floor passes it on, to every fissure within 13y
// in the same tick, so a Roar erupts a cluster around her and never the whole floor. Every hit
// plays the fissure's crack animation. A fissure's 10s trap cooldown keeps it from erupting twice
// in a row, and nothing erupts before a Roar lands.
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27843
func TestOnyxia_EruptionFollowsBellowingRoar(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"long", "instances"},
Runtime: "long",
Category: "instances/classic/onyxias_lair",
})
const (
// Three fissures within 7y, so once Onyxia is on the ground and chasing the bot her Roars
// have fissures in range.
padX, padY, padZ = float32(-14.0), float32(-214.0), float32(-88.5)
sampleEvery = 100 * time.Millisecond
// Walk to the takeoff spot, a ~10s climb, the flight north, then the first Fireball.
airborneBy = 60 * time.Second
// Up to a 3s Fireball, a ~10s descent, 2s on the ground, then a Roar every 22-26s.
roarsBy = 90 * time.Second
wantRoars = 2
roarRadius = float32(13)
fissureRadius = float32(13)
mustEruptWithin = float32(11)
rangeSlack = float32(2)
// The Roar and every Eruption it chains into are cast in one server tick.
eruptLead = 250 * time.Millisecond
eruptTrail = time.Second
// Lava Fissure trap cooldown, less a margin for packet timing.
fissureCooldown = 9500 * time.Millisecond
// Retail: 9.87s from 25y up. Onyxia flies 25-29y above the floor.
landingMin = 8 * time.Second
landingMax = 13 * time.Second
// Retail: 2.0s. Widened for the AI update tick and packet delivery.
roarAfterTouchdownMin = 1500 * time.Millisecond
roarAfterTouchdownMax = 3500 * time.Millisecond
)
fissures := loadLavaFissures(t)
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "OnyErp", Race: e2eharness.RaceHuman,
Class: e2eharness.ClassWarrior, Level: 80,
})
// GM mode stays on through the instance enter.
bot.Teleport(t, padX, padY, padZ, onyxiasLairMap)
if _, _, _, m := bot.Pos(); m != onyxiasLairMap {
e2eharness.Preconditionf(t, "bot not in Onyxia's Lair after the pad tele map=%d", m)
}
onyxia := bot.WaitUnit(t, npcOnyxia, 15*time.Second)
obs := newEruptionObserver(t, bot, onyxia)
// God mode: Deep Breath, Eruption and the whelps would kill an ungeared bot.
e2eharness.CombatReady(t, bot.World, e2eharness.CombatReadyOpts{God: true})
bot.Engage(t, onyxia, 15*time.Second)
bot.DamageToFraction(t, onyxia, 0.64, 30*time.Second)
// Only drop her to 40% once she has reached a waypoint at flight height, as in the sniff: her
// Fireballs are only cast there. The cached position alone passes while she is still climbing.
for deadline := time.Now().Add(airborneBy); obs.firstFireball().IsZero(); {
if time.Now().After(deadline) {
e2eharness.Preconditionf(t, "Onyxia cast no Fireball within %s of dropping below 65%%", airborneBy)
}
time.Sleep(sampleEvery)
}
landing := watchLanding(t, bot, onyxia)
bot.DamageToFraction(t, onyxia, 0.39, 30*time.Second)
phase3At := time.Now()
for deadline := time.Now().Add(roarsBy); ; {
if _, gos, _, _ := obs.snapshot(); len(gos) >= wantRoars {
break
}
if time.Now().After(deadline) {
_, gos, _, _ := obs.snapshot()
e2eharness.Assertf(t, "%d Bellowing Roars landed within %s of Onyxia dropping below 40%%, want %d", len(gos), roarsBy, wantRoars)
}
time.Sleep(sampleEvery)
}
// Let the last Roar's eruptions arrive.
time.Sleep(eruptTrail)
starts, gos, eruptions, anims := obs.snapshot()
// The landing: straight down from the flight, about 10s at 2.5y/s, then the first Roar 2s
// after touchdown.
land := landing()
if land.start.IsZero() {
e2eharness.Assertf(t, "no spline dropped Onyxia 10y or more after she was damaged below 40%%")
}
if land.length < landingMin || land.length > landingMax {
e2eharness.Assertf(t, "landing from z %.1f to %.1f took %s, want %s-%s",
land.fromZ, land.toZ, land.length.Round(time.Millisecond), landingMin, landingMax)
}
if len(starts) == 0 {
e2eharness.HarnessFailf(t, "Bellowing Roar landed %d times but no SMSG_SPELL_START for it arrived", len(gos))
}
touchdown := land.start.Add(land.length)
if gap := starts[0].at.Sub(touchdown); gap < roarAfterTouchdownMin || gap > roarAfterTouchdownMax {
e2eharness.Assertf(t, "first Bellowing Roar started %s after touchdown, want %s-%s",
gap.Round(time.Millisecond), roarAfterTouchdownMin, roarAfterTouchdownMax)
}
t.Logf("landing: descent from z %.1f to %.1f began %+.1fs from the 39%% damage and took %s, first Roar %s after touchdown",
land.fromZ, land.toZ, land.start.Sub(phase3At).Seconds(),
land.length.Round(time.Millisecond), starts[0].at.Sub(touchdown).Round(time.Millisecond))
fissureOf := func(guid uint64) (uint32, fissurePos, bool) {
entry := uint32((guid >> 24) & 0xFFFFFF)
p, ok := fissures[entry]
return entry, p, ok
}
// Every Eruption comes from a fissure, inside the tick of a landing Roar.
windowOf := make([]int, len(eruptions))
for i, e := range eruptions {
if _, _, ok := fissureOf(e.caster); !ok {
e2eharness.Assertf(t, "Eruption %d cast by 0x%X, not a Lava Fissure", e.spell, e.caster)
}
windowOf[i] = -1
for r, g := range gos {
if !e.at.Before(g.at.Add(-eruptLead)) && !e.at.After(g.at.Add(eruptTrail)) {
windowOf[i] = r
break
}
}
if windowOf[i] < 0 {
var near []string
for _, s := range starts {
near = append(near, fmt.Sprintf("start %+.1fs", e.at.Sub(s.at).Seconds()))
}
e2eharness.Assertf(t, "fissure 0x%X erupted %s after Onyxia dropped below 40%% but not when a Bellowing Roar landed (%s)",
e.caster, e.at.Sub(phase3At).Round(time.Millisecond), strings.Join(near, ", "))
}
}
// No fissure erupts twice inside its trap cooldown.
byFissure := map[uint64][]time.Time{}
for _, e := range eruptions {
byFissure[e.caster] = append(byFissure[e.caster], e.at)
}
for guid, times := range byFissure {
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
for i := 1; i < len(times); i++ {
if gap := times[i].Sub(times[i-1]); gap < fissureCooldown {
entry, _, _ := fissureOf(guid)
e2eharness.Assertf(t, "fissure %d erupted twice %s apart, inside its 10s trap cooldown", entry, gap.Round(time.Millisecond))
}
}
}
// Per Roar: the fissures near Onyxia erupt, the rest are chained from an erupting fissure above
// the floor, nothing else erupts, and every erupting fissure plays its crack animation.
judged := 0
var summary []string
for r, g := range gos {
erupted := map[uint32]castSeen{}
for i, e := range eruptions {
if windowOf[i] == r {
entry, _, _ := fissureOf(e.caster)
erupted[entry] = e
}
}
var names []string
for entry := range erupted {
names = append(names, fmt.Sprint(entry))
}
sort.Strings(names)
t.Logf("roar %d: Onyxia at (%.1f, %.1f), erupted %s", r+1, g.x, g.y, strings.Join(names, " "))
// Whether p lies within radius of Onyxia or of an erupting fissure that passes Eruption on.
reachedBy := func(entry uint32, p fissurePos, radius float32) bool {
if dist2(g.x, g.y, p.x, p.y) <= radius {
return true
}
for other := range erupted {
if o := fissures[other]; other != entry && lavaFissuresAboveFloor[other] && dist3(o.x, o.y, o.z, p.x, p.y, p.z) <= radius {
return true
}
}
return false
}
for entry, p := range fissures {
if !reachedBy(entry, p, mustEruptWithin) {
continue
}
if _, ok := erupted[entry]; ok {
continue
}
// A fissure still on cooldown from the previous Roar stays quiet.
onCooldown := false
for _, e := range eruptions {
if en, _, _ := fissureOf(e.caster); en == entry && e.at.Before(g.at.Add(-eruptLead)) && g.at.Sub(e.at) < 10*time.Second {
onCooldown = true
}
}
if !onCooldown {
e2eharness.Assertf(t, "Roar %d landed with Onyxia at (%.1f, %.1f): fissure %d, %.1fy from her, is in reach of her or an erupting fissure above the floor but did not erupt",
r+1, g.x, g.y, entry, dist2(g.x, g.y, p.x, p.y))
}
}
direct, chained := 0, 0
for entry, e := range erupted {
p := fissures[entry]
if dist2(g.x, g.y, p.x, p.y) <= roarRadius+rangeSlack {
direct++
} else {
if !reachedBy(entry, p, fissureRadius+rangeSlack) {
e2eharness.Assertf(t, "Roar %d: fissure %d erupted %.1fy from Onyxia with no erupting fissure above the floor within %.0fy; only those pass an Eruption on",
r+1, entry, dist2(g.x, g.y, p.x, p.y), fissureRadius)
}
chained++
}
animated := false
for _, a := range anims {
if a.guid == e.caster && !a.at.Before(g.at.Add(-eruptLead)) && !a.at.After(g.at.Add(eruptTrail)) {
animated = true
break
}
}
if !animated {
e2eharness.Assertf(t, "Roar %d: fissure %d erupted without a crack animation", r+1, entry)
}
}
if len(erupted) > 0 {
judged++
}
summary = append(summary, fmt.Sprintf("roar %d at %+.1fs: %d fissures (%d direct, %d chained)",
r+1, g.at.Sub(phase3At).Seconds(), len(erupted), direct, chained))
}
if judged == 0 {
e2eharness.Preconditionf(t, "none of the %d Roars landed near a Lava Fissure: %s", len(gos), strings.Join(summary, "; "))
}
t.Logf("%s", strings.Join(summary, "; "))
t.Logf("PASS eruptions: %d Roars, %d Eruptions, %d crack animations, all set off by a landing Roar and chained only through fissures above the floor, none inside a fissure's cooldown",
len(gos), len(eruptions), len(anims))
}
// castHeader reads the caster GUID and spell id from an SMSG_SPELL_START or SMSG_SPELL_GO header:
// cast-item packed GUID, caster packed GUID, cast counter, spell id.
func castHeader(data []byte) (caster uint64, spell uint32, ok bool) {
off := 0
var guids [2]uint64
for i := 0; i < 2; i++ {
if off >= len(data) {
return 0, 0, false
}
mask := data[off]
off++
for bit := 0; bit < 8; bit++ {
if mask&(1<<uint(bit)) == 0 {
continue
}
if off >= len(data) {
return 0, 0, false
}
guids[i] |= uint64(data[off]) << (8 * uint(bit))
off++
}
}
off++
if off+4 > len(data) {
return 0, 0, false
}
return guids[1], binary.LittleEndian.Uint32(data[off : off+4]), true
}
-13
View File
@@ -724,19 +724,6 @@ void Spell::EffectDummy(SpellEffIndex effIndex)
unitCaster->CastSpell(unitTarget, 66904, true);
return;
}
case 17731:
case 69294:
{
if (!gameObjTarget || gameObjTarget->GetRespawnTime() > GameTime::GetGameTime().count())
return;
gameObjTarget->SetRespawnTime(10);
gameObjTarget->SendCustomAnim(gameObjTarget->GetGoAnimProgress());
if (Creature* trigger = gameObjTarget->SummonCreature(12758, *gameObjTarget, TEMPSUMMON_TIMED_DESPAWN, 1000))
trigger->CastSpell(trigger, 17731, false);
return;
}
// HoL, Arc Weld
case 59086:
{
@@ -2120,14 +2120,6 @@ void SpellMgr::LoadSpellInfoCorrections()
spellInfo->DurationEntry = sSpellDurationStore.LookupEntry(5);
});
// Onyxia's Lair, Onyxia, Eruption
ApplySpellFix({ 17731, 69294 }, [](SpellInfo* spellInfo)
{
spellInfo->Effects[EFFECT_1].Effect = SPELL_EFFECT_DUMMY;
spellInfo->CastTimeEntry = sSpellCastTimesStore.LookupEntry(3);
spellInfo->Effects[EFFECT_1].RadiusEntry = sSpellRadiusStore.LookupEntry(EFFECT_RADIUS_18_YARDS); // 18yd instead of 13yd to make sure all cracks erupt
});
// Onyxia's Lair, Onyxia, Breath
ApplySpellFix({
18576, 18578, 18579, 18580, 18581, 18582, 18583, 18609, 18611, 18612, 18613, 18614, 18615, 18616, 18584,
@@ -20,7 +20,10 @@
#include "Player.h"
#include "ScriptedCreature.h"
#include "SpellInfo.h"
#include "SpellScript.h"
#include "SpellScriptLoader.h"
#include "onyxias_lair.h"
#include <algorithm>
#include <array>
enum Spells
@@ -33,7 +36,6 @@ enum Spells
SPELL_BELLOWINGROAR = 18431,
SPELL_SUMMON_LAIR_GUARD = 68968,
SPELL_ERUPTION = 17731,
SPELL_OLG_BLASTNOVA = 68958,
SPELL_OLG_IGNITEWEAPON = 68959,
@@ -73,11 +75,9 @@ enum Events
EVENT_SUMMON_LAIR_GUARD = 16,
EVENT_OLG_SPELL_BLASTNOVA = 18,
EVENT_OLG_SPELL_IGNITEWEAPON = 19,
EVENT_ERUPTION = 20,
EVENT_LIFTOFF = 31,
EVENT_FLY_S_TO_N = 32,
EVENT_LAND = 33,
EVENT_END_MANY_WHELPS_TIME
};
@@ -102,7 +102,6 @@ enum Points
{
POINT_GROUND_SOUTH = 10,
POINT_TAKEOFF = 11,
POINT_PRE_LAND = 12,
POINT_LAND = 13
};
@@ -198,7 +197,8 @@ struct boss_onyxia : public BossAI
events.ScheduleEvent(EVENT_START_PHASE_2, 0ms);
break;
case PHASE_LANDED:
events.ScheduleEvent(EVENT_START_PHASE_3, 5s);
// Runs once the current cast ends: the landing never cuts a Fireball short
events.ScheduleEvent(EVENT_START_PHASE_3, 0ms);
break;
default:
break;
@@ -246,7 +246,6 @@ struct boss_onyxia : public BossAI
});
ScheduleHealthCheckEvent(40, [&]
{
me->InterruptNonMeleeSpells(false);
summons.DespawnEntry(NPC_WORLD_TRIGGER);
SetPhase(PHASE_LANDED);
});
@@ -332,23 +331,24 @@ struct boss_onyxia : public BossAI
switch (id)
{
// The landing replaces an unfinished walk or takeoff, and the replaced movement still reports its point
case POINT_GROUND_SOUTH:
if (_phase != PHASE_AIRPHASE)
break;
me->SetFacingTo(OnyxiaMoveData[WP_GROUND_SOUTH].O);
events.ScheduleEvent(EVENT_LIFTOFF, 0ms);
break;
case POINT_TAKEOFF:
if (_phase != PHASE_AIRPHASE)
break;
me->SetFacingTo(OnyxiaMoveData[WP_SOUTH].O);
events.ScheduleEvent(EVENT_FLY_S_TO_N, 0ms);
break;
case POINT_PRE_LAND:
me->SetFacingTo(OnyxiaMoveData[WP_SOUTH].O);
events.ScheduleEvent(EVENT_LAND, 0ms);
break;
case POINT_LAND:
me->SetCanFly(false);
me->SetDisableGravity(false);
me->SetSpeed(MOVE_RUN, me->GetCreatureTemplate()->speed_run, false);
events.ScheduleEvent(EVENT_PHASE_3_ATTACK, 0ms);
events.ScheduleEvent(EVENT_PHASE_3_ATTACK, 2s);
break;
default:
break;
@@ -444,6 +444,7 @@ struct boss_onyxia : public BossAI
}
case EVENT_START_PHASE_2:
{
Talk(SAY_PHASE_2_TRANS);
me->AttackStop();
me->SetReactState(REACT_PASSIVE);
me->StopMoving();
@@ -458,7 +459,6 @@ struct boss_onyxia : public BossAI
}
case EVENT_LIFTOFF:
{
Talk(SAY_PHASE_2_TRANS);
me->SendMeleeAttackStop(me->GetVictim());
me->GetMotionMaster()->MoveIdle();
me->DisableSpline();
@@ -495,14 +495,6 @@ struct boss_onyxia : public BossAI
events.Repeat(46s);
break;
}
case EVENT_LAND:
{
Talk(SAY_PHASE_3_TRANS);
me->SendMeleeAttackStop(me->GetVictim());
me->GetMotionMaster()->MoveLand(POINT_LAND, OnyxiaMoveData[WP_GROUND_SOUTH].X + 1.0f, OnyxiaMoveData[WP_GROUND_SOUTH].Y, OnyxiaMoveData[WP_GROUND_SOUTH].Z, 12.0f);
DoResetThreatList();
break;
}
case EVENT_SPELL_FIREBALL_FIRST:
{
if (Unit* target = SelectTarget(SelectTargetMethod::Random, 0, 200.0f, true))
@@ -562,8 +554,20 @@ struct boss_onyxia : public BossAI
}
case EVENT_START_PHASE_3:
{
me->SetSpeed(MOVE_RUN, 2.95f, false);
me->GetMotionMaster()->MovePoint(POINT_PRE_LAND, OnyxiaMoveData[WP_SOUTH].X, OnyxiaMoveData[WP_SOUTH].Y, OnyxiaMoveData[WP_SOUTH].Z);
Talk(SAY_PHASE_3_TRANS);
me->SendMeleeAttackStop(me->GetVictim());
DoResetThreatList();
me->StopMoving();
// Straight down from wherever the flight is; the whole descent takes about 10s
Position landPos = me->GetPosition();
float const groundZ = me->GetMapHeight(landPos.GetPositionX(), landPos.GetPositionY(), landPos.GetPositionZ());
if (groundZ > INVALID_HEIGHT)
landPos.m_positionZ = groundZ;
else
landPos.Relocate(OnyxiaMoveData[WP_GROUND_SOUTH].X, OnyxiaMoveData[WP_GROUND_SOUTH].Y, OnyxiaMoveData[WP_GROUND_SOUTH].Z);
me->GetMotionMaster()->MoveLand(POINT_LAND, landPos, 2.5f);
break;
}
case EVENT_PHASE_3_ATTACK:
@@ -581,27 +585,17 @@ struct boss_onyxia : public BossAI
DoCastAOE(SPELL_BELLOWINGROAR);
events.ScheduleEvent(EVENT_ERUPTION, 0ms);
events.ScheduleEvent(EVENT_SPELL_WINGBUFFET, 10s, 20s);
events.ScheduleEvent(EVENT_SPELL_FLAMEBREATH, 10s, 20s);
events.ScheduleEvent(EVENT_SPELL_TAILSWEEP, 15s, 20s);
events.ScheduleEvent(EVENT_SPELL_CLEAVE, 2s, 5s);
events.ScheduleEvent(EVENT_SPELL_BELLOWINGROAR, 15s);
events.ScheduleEvent(EVENT_SPELL_BELLOWINGROAR, 22s, 26s);
break;
}
case EVENT_SPELL_BELLOWINGROAR:
{
DoCastAOE(SPELL_BELLOWINGROAR);
events.Repeat(22s);
events.ScheduleEvent(EVENT_ERUPTION, 0ms);
break;
}
case EVENT_ERUPTION:
{
if (Creature* trigger = me->SummonCreature(NPC_ONYXIA_TRIGGER, *me, TEMPSUMMON_TIMED_DESPAWN, 1000))
{
trigger->CastSpell(trigger, SPELL_ERUPTION, false);
}
events.Repeat(22s, 26s);
break;
}
default:
@@ -769,9 +763,62 @@ private:
}
};
// The Lava Fissures whose position sits above the lair floor. In retail sniffs only these pass an Eruption on: a
// fissure sunk below the floor erupts when hit, but its own Eruption hits no neighbour
static constexpr std::array<uint32, 11> LavaFissuresAboveFloor =
{
176811, 176814, 176821, 176824, 176827, 176829, 176830, 176839, 176841, 176908, 176921
};
// 18431 - Bellowing Roar
// 17731, 69294 - Eruption
class spell_onyxia_disturb_lava_fissure : public SpellScript
{
PrepareSpellScript(spell_onyxia_disturb_lava_fissure);
void FilterFissures(std::list<WorldObject*>& targets)
{
Unit* caster = GetCaster();
if (!caster)
return;
// The area search measures a gameobject by its display bounds, which stretch a fissure's reach well past the
// effect radius; retail sniffs only ever hit fissures within that radius of the centre
float const radius = GetSpellInfo()->Effects[EFFECT_1].CalcRadius(caster);
targets.remove_if([caster, radius](WorldObject* target) { return caster->GetExactDist(target) > radius; });
if (GetSpellInfo()->Id == SPELL_BELLOWINGROAR)
return;
// A fissure's Eruption is cast by a trigger the fissure summons
TempSummon* trigger = caster->ToTempSummon();
GameObject* fissure = trigger ? trigger->GetSummonerGameObject() : nullptr;
if (!fissure)
return;
if (std::find(LavaFissuresAboveFloor.begin(), LavaFissuresAboveFloor.end(), fissure->GetEntry()) == LavaFissuresAboveFloor.end())
targets.clear();
}
// The default effect sets the fissure off; every hit also plays its crack animation, even while the fissure's
// trap cooldown keeps it from erupting again
void HandleActivateObject(SpellEffIndex /*effIndex*/)
{
if (GameObject* fissure = GetHitGObj())
fissure->SendCustomAnim(0);
}
void Register() override
{
OnObjectAreaTargetSelect += SpellObjectAreaTargetSelectFn(spell_onyxia_disturb_lava_fissure::FilterFissures, EFFECT_1, TARGET_GAMEOBJECT_DEST_AREA);
OnEffectHitTarget += SpellEffectFn(spell_onyxia_disturb_lava_fissure::HandleActivateObject, EFFECT_1, SPELL_EFFECT_ACTIVATE_OBJECT);
}
};
void AddSC_boss_onyxia()
{
RegisterOnyxiasLairCreatureAI(boss_onyxia);
RegisterOnyxiasLairCreatureAI(npc_onyxian_lair_guard);
RegisterOnyxiasLairCreatureAI(npc_onyxian_whelp);
RegisterSpellScript(spell_onyxia_disturb_lava_fissure);
}
@@ -36,7 +36,6 @@ enum CreatureIds
{
NPC_ONYXIA = 10184,
NPC_ONYXIAN_WHELP = 11262,
NPC_ONYXIA_TRIGGER = 12758,
NPC_WORLD_TRIGGER = 22515,
NPC_ONYXIAN_LAIR_GUARD = 36561,
};