fix(Scripts/OnyxiasLair): Spawn Onyxia's whelps from rookery points and hatching eggs (#27833)

This commit is contained in:
Andrew
2026-09-27 10:54:02 -03:00
committed by GitHub
parent 593686107c
commit 0a9ad7ecdb
5 changed files with 657 additions and 36 deletions
@@ -0,0 +1,5 @@
-- Onyxian Whelp
UPDATE `creature_template` SET `ScriptName` = 'npc_onyxian_whelp' WHERE `entry` = 11262;
-- Onyxia Egg
UPDATE `gameobject` SET `spawntimesecs` = 30, `VerifiedBuild` = 69933 WHERE `id` = 176511 AND `guid` BETWEEN 150444 AND 150562;
+1
View File
@@ -164,6 +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/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 |
@@ -0,0 +1,492 @@
//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 (
onyxiasLairMap = uint32(249)
npcOnyxia = uint32(10184)
npcWhelp = uint32(11262)
goOnyxiaEgg = uint32(176511)
spellTeleportSelf = uint32(42527)
// Sent by GameObject::DespawnOrUnsummon ahead of the destroy, while the egg is still cached.
smsgGameObjectDespawnAnim = uint16(0x215)
)
type spotKind int
const (
spotPoint spotKind = iota
spotLiftoffOnly
spotEgg
)
type spot struct {
kind spotKind
x, y float32
}
func (s spot) String() string {
return fmt.Sprintf("(%.1f, %.1f)", s.x, s.y)
}
// boss_onyxia.cpp's WhelpSpawnPoints, WhelpLiftoffOnlyPoint and the eight back-row eggs next to
// them, the only eggs a sniffed kill ever hatched. Summons land on the exact coordinates.
var whelpSpots = []spot{
{spotPoint, -102.75786, -198.85912},
{spotPoint, -107.54872, -198.04468},
{spotPoint, -112.76325, -196.49747},
{spotPoint, -117.191, -196.107},
{spotPoint, -99.41064, -198.543},
{spotPoint, -104.5892, -233.16988},
{spotPoint, -107.39845, -230.61523},
{spotPoint, -110.02973, -233.42484},
{spotPoint, -113.6534, -231.24023},
{spotPoint, -115.66789, -234.56912},
{spotLiftoffOnly, -107.17814, -232.05528},
{spotEgg, -103.47102, -199.9189},
{spotEgg, -106.70401, -235.04318},
{spotEgg, -106.77378, -227.09634},
{spotEgg, -111.04669, -201.00967},
{spotEgg, -111.54843, -199.27588},
{spotEgg, -112.76019, -232.20369},
{spotEgg, -113.54922, -198.28697},
{spotEgg, -114.866, -197.40495},
}
// Closest spots are 1.28y apart, so half a yard cannot pick the wrong one.
const spotTolerance = float32(0.5)
func spotAt(x, y float32) int {
best, bestDist := -1, float32(math.MaxFloat32)
for i, s := range whelpSpots {
if d := float32(math.Hypot(float64(s.x-x), float64(s.y-y))); d < bestDist {
best, bestDist = i, d
}
}
if bestDist > spotTolerance {
return -1
}
return best
}
func spotsOfKind(kind spotKind) []int {
var out []int
for i, s := range whelpSpots {
if s.kind == kind {
out = append(out, i)
}
}
return out
}
type whelpSeen struct {
guid uint64
seenAt time.Time
diedAt time.Time
x, y float32
spot int
}
type eggHatch struct {
at time.Time
x, y float32
spot int
}
// whelpObserver records every whelp create with where it appeared and when its health hit 0, and
// every egg despawn with its position, all timed on arrival at the client.
type whelpObserver struct {
mu sync.Mutex
whelps map[uint64]*whelpSeen
hatches []eggHatch
teleports int
badEggs int
// Without a whelp ever going for the bot, zero teleports would prove nothing.
targetedBot bool
}
func newWhelpObserver(t *testing.T, bot *e2eharness.ScenarioBot, sampleEvery time.Duration) *whelpObserver {
o := &whelpObserver{whelps: map[uint64]*whelpSeen{}}
cancelHook := bot.World.AddPacketHook(func(opcode uint16, data []byte) {
switch opcode {
case smsgGameObjectDespawnAnim:
if len(data) < 8 {
return
}
guid := binary.LittleEndian.Uint64(data[:8])
if uint32((guid>>24)&0xFFFFFF) != goOnyxiaEgg {
return
}
now := time.Now()
egg := bot.World.GetObject(guid)
o.mu.Lock()
defer o.mu.Unlock()
if egg == nil {
o.badEggs++
return
}
o.hatches = append(o.hatches, eggHatch{at: now, x: egg.PosX, y: egg.PosY, spot: spotAt(egg.PosX, egg.PosY)})
case client.SmsgSpellGo:
if id, ok := castSpellID(data); ok && id == spellTeleportSelf {
o.mu.Lock()
o.teleports++
o.mu.Unlock()
}
}
})
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(sampleEvery)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
}
now := time.Now()
units := bot.World.GetNearbyUnits(250)
o.mu.Lock()
for _, u := range units {
entry := u.Entry
if entry == 0 {
entry = uint32((u.GUID >> 24) & 0xFFFFFF)
}
if entry != npcWhelp {
continue
}
w, ok := o.whelps[u.GUID]
if !ok {
w = &whelpSeen{guid: u.GUID, seenAt: now, x: u.PosX, y: u.PosY, spot: spotAt(u.PosX, u.PosY)}
o.whelps[u.GUID] = w
}
if e2eharness.UnitTargetGUIDFromObj(u) == bot.World.CharGUID() {
o.targetedBot = true
}
if w.diedAt.IsZero() && u.Health() == 0 && u.MaxHealth() > 0 {
w.diedAt = now
}
}
o.mu.Unlock()
}
}()
t.Cleanup(func() {
cancelHook()
close(stop)
<-done
})
return o
}
func (o *whelpObserver) whelpsBetween(from, to time.Time) []whelpSeen {
o.mu.Lock()
defer o.mu.Unlock()
var out []whelpSeen
for _, w := range o.whelps {
if !w.seenAt.Before(from) && w.seenAt.Before(to) {
out = append(out, *w)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].seenAt.Before(out[j].seenAt) })
return out
}
func (o *whelpObserver) hatchesBetween(from, to time.Time) []eggHatch {
o.mu.Lock()
defer o.mu.Unlock()
var out []eggHatch
for _, h := range o.hatches {
if !h.at.Before(from) && h.at.Before(to) {
out = append(out, h)
}
}
return out
}
func (o *whelpObserver) whelp(guid uint64) whelpSeen {
o.mu.Lock()
defer o.mu.Unlock()
if w, ok := o.whelps[guid]; ok {
return *w
}
return whelpSeen{}
}
func (o *whelpObserver) counters() (teleports, badEggs int, targetedBot bool) {
o.mu.Lock()
defer o.mu.Unlock()
return o.teleports, o.badEggs, o.targetedBot
}
func (o *whelpObserver) waitFirstWhelp(timeout time.Duration, sampleEvery time.Duration) time.Time {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
o.mu.Lock()
var first time.Time
for _, w := range o.whelps {
if first.IsZero() || w.seenAt.Before(first) {
first = w.seenAt
}
}
o.mu.Unlock()
if !first.IsZero() {
return first
}
time.Sleep(sampleEvery)
}
return time.Time{}
}
// assertEggHatches checks that the eggs hatched in a window are exactly the eight back-row eggs,
// each once, and that each released one whelp on the egg after Summon Onyxia Whelp's 2s cast.
func assertEggHatches(t *testing.T, what string, hatches []eggHatch, whelps []whelpSeen, delayMin, delayMax time.Duration) {
t.Helper()
hatched := map[int]eggHatch{}
for _, h := range hatches {
if h.spot < 0 || whelpSpots[h.spot].kind != spotEgg {
e2eharness.Assertf(t, "%s: egg at (%.1f, %.1f) hatched, not one of the back-row eggs next to the rookery points", what, h.x, h.y)
}
if prev, dup := hatched[h.spot]; dup {
e2eharness.Assertf(t, "%s: egg %s hatched twice, %s apart", what, whelpSpots[h.spot], h.at.Sub(prev.at).Round(time.Millisecond))
}
hatched[h.spot] = h
}
var missing []string
for _, i := range spotsOfKind(spotEgg) {
if _, ok := hatched[i]; !ok {
missing = append(missing, whelpSpots[i].String())
}
}
if len(missing) > 0 {
e2eharness.Assertf(t, "%s: %d of 8 back-row eggs hatched, never hatched: %s", what, len(hatched), strings.Join(missing, " "))
}
for i, h := range hatched {
var released []time.Duration
for _, w := range whelps {
if w.spot == i && w.seenAt.After(h.at) {
released = append(released, w.seenAt.Sub(h.at))
}
}
if len(released) != 1 {
e2eharness.Assertf(t, "%s: egg %s released %d whelps after hatching, want 1 (delays %v)", what, whelpSpots[i], len(released), released)
}
if released[0] < delayMin || released[0] > delayMax {
e2eharness.Assertf(t, "%s: egg %s released its whelp %s after hatching, want %s-%s",
what, whelpSpots[i], released[0].Round(time.Millisecond), delayMin, delayMax)
}
}
}
// Onyxia's phase 2 whelps, measured against a retail sniff of a full kill:
// - At liftoff one whelp appears on each of ten rookery points plus a liftoff-only point.
// - Half a second later (the end of Rookery Whelp Spawn-in Spell) each new whelp hatches the
// nearest standing egg within 4 yards; a whelp appears on the egg 2s later and hatches in turn.
// That hatches the same eight back-row eggs every wave: 19 whelps at liftoff.
// - A rookery point respawns its whelp 30-60s after the previous one died, never while it lives,
// and the liftoff-only point never does. Eggs are back 30s after hatching, so the next wave
// hatches all eight again: 18 whelps.
//
// The bot fights on the lair floor, which every whelp can walk to, so none may cast Teleport Self.
func TestOnyxia_WhelpCadence(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"long", "instances"},
Runtime: "long",
Category: "instances/classic/onyxias_lair",
})
const (
// Inside Onyxia's 113y leash and within 80y of every rookery point and egg, so all of
// them stay in sight on realms that cut instance visibility to 120y.
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.
liftoffWindow = 60 * time.Second
// All eleven points are summoned in one tick.
burstSpread = 1500 * time.Millisecond
// The longest liftoff hatch chain runs three eggs deep, its last whelp 7.5s in.
burstSettle = 12 * time.Second
hatchDelayMin = 1500 * time.Millisecond
hatchDelayMax = 3 * time.Second
// 30-60s, widened for the AI update tick and packet delivery.
respawnMin = 29 * time.Second
respawnMax = 62 * time.Second
chainSlack = 12 * time.Second
)
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "OnyWhp", 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 := newWhelpObserver(t, bot, sampleEvery)
if early := obs.waitFirstWhelp(2*time.Second, sampleEvery); !early.IsZero() {
e2eharness.Preconditionf(t, "whelps already up before the pull; the instance is not fresh")
}
// God mode: Deep Breath and the whelps would kill an ungeared bot long before wave two.
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)
liftoff := obs.waitFirstWhelp(liftoffWindow, sampleEvery)
if liftoff.IsZero() {
e2eharness.Preconditionf(t, "no whelp within %s of Onyxia dropping below 65%%", liftoffWindow)
}
t.Logf("first whelp at liftoff %s", liftoff.Format(time.StampMilli))
// Wave one: the liftoff burst and its egg chains.
time.Sleep(time.Until(liftoff.Add(burstSettle)))
burstEnd := liftoff.Add(burstSettle)
burst := obs.whelpsBetween(liftoff, burstEnd)
burstHatches := obs.hatchesBetween(liftoff, burstEnd)
for _, w := range burst {
if w.spot < 0 {
e2eharness.Assertf(t, "liftoff: whelp 0x%X appeared at (%.1f, %.1f), none of the rookery points or back-row eggs", w.guid, w.x, w.y)
}
}
for _, i := range append(spotsOfKind(spotPoint), spotsOfKind(spotLiftoffOnly)...) {
var at []time.Duration
for _, w := range burst {
if w.spot == i {
at = append(at, w.seenAt.Sub(liftoff))
}
}
if len(at) != 1 || at[0] > burstSpread {
e2eharness.Assertf(t, "liftoff: point %s got %d whelps (at %v after the first), want 1 within %s",
whelpSpots[i], len(at), at, burstSpread)
}
}
assertEggHatches(t, "liftoff", burstHatches, burst, hatchDelayMin, hatchDelayMax)
if len(burst) != 19 {
e2eharness.Assertf(t, "liftoff: %d whelps within %s, want 19 (11 points + 8 eggs)", len(burst), burstSettle)
}
t.Logf("liftoff: %d whelps, %d eggs hatched", len(burst), len(burstHatches))
// Kill wave one; each point's respawn is timed from its own whelp's death.
var guids []uint64
pointWhelp := map[int]uint64{}
for _, w := range burst {
guids = append(guids, w.guid)
if whelpSpots[w.spot].kind != spotEgg {
pointWhelp[w.spot] = w.guid
}
}
bot.DamageKill(t, guids, 10_000_000, 30*time.Second)
killDone := time.Now()
deathOf := map[int]time.Time{}
for i, g := range pointWhelp {
died := obs.whelp(g).diedAt
if died.IsZero() {
e2eharness.HarnessFailf(t, "whelp 0x%X at %s never showed 0 health after DamageKill", g, whelpSpots[i])
}
deathOf[i] = died
}
// Wave two: every point comes back once, 30-60s after its whelp died, and hatches the eggs again.
waveEnd := killDone.Add(respawnMax + chainSlack)
time.Sleep(time.Until(waveEnd))
wave := obs.whelpsBetween(burstEnd, waveEnd)
waveHatches := obs.hatchesBetween(burstEnd, waveEnd)
var delays []string
for _, w := range wave {
if w.spot < 0 {
e2eharness.Assertf(t, "wave two: whelp 0x%X appeared at (%.1f, %.1f), none of the rookery points or back-row eggs", w.guid, w.x, w.y)
}
if whelpSpots[w.spot].kind == spotLiftoffOnly {
e2eharness.Assertf(t, "wave two: the liftoff-only point %s respawned a whelp %s after liftoff",
whelpSpots[w.spot], w.seenAt.Sub(liftoff).Round(time.Millisecond))
}
}
for _, i := range spotsOfKind(spotPoint) {
var at []time.Duration
for _, w := range wave {
if w.spot == i {
at = append(at, w.seenAt.Sub(deathOf[i]))
}
}
if len(at) != 1 {
e2eharness.Assertf(t, "wave two: point %s got %d whelps after its whelp died (at %v), want 1", whelpSpots[i], len(at), at)
}
if at[0] < respawnMin || at[0] > respawnMax {
e2eharness.Assertf(t, "wave two: point %s respawned %s after its whelp died, want %s-%s",
whelpSpots[i], at[0].Round(time.Millisecond), respawnMin, respawnMax)
}
delays = append(delays, fmt.Sprintf("%s=%.1fs", whelpSpots[i], at[0].Seconds()))
}
assertEggHatches(t, "wave two", waveHatches, wave, hatchDelayMin, hatchDelayMax)
if len(wave) != 18 {
e2eharness.Assertf(t, "wave two: %d whelps, want 18 (10 points + 8 eggs)", len(wave))
}
t.Logf("wave two: %d whelps, %d eggs hatched, respawn after death: %s", len(wave), len(waveHatches), strings.Join(delays, " "))
teleports, badEggs, targetedBot := obs.counters()
if badEggs > 0 {
e2eharness.HarnessFailf(t, "%d egg despawns arrived for eggs missing from the object cache", badEggs)
}
if !targetedBot {
e2eharness.Assertf(t, "no whelp ever targeted the bot, so the Teleport Self check below would pass without a chase")
}
if teleports > 0 {
e2eharness.Assertf(t, "%d whelps cast Teleport Self at a bot standing on the lair floor they can walk to", teleports)
}
t.Logf("PASS whelps: 19 at liftoff, 18 in wave two, points respawn 30-60s after death, no teleports")
}
// castSpellID pulls the spell id out of an SMSG_SPELL_GO header: cast-item and caster packed
// GUIDs, then a cast counter. The harness exposes neither the header nor a packed-GUID reader.
func castSpellID(data []byte) (uint32, bool) {
off := 0
for i := 0; i < 2; i++ {
if off >= len(data) {
return 0, false
}
mask := data[off]
off++
for bit := 0; bit < 8; bit++ {
if mask&(1<<uint(bit)) != 0 {
off++
}
}
}
off++
if off+4 > len(data) {
return 0, false
}
return binary.LittleEndian.Uint32(data[off : off+4]), true
}
@@ -16,10 +16,12 @@
*/
#include "CreatureScript.h"
#include "PathGenerator.h"
#include "Player.h"
#include "ScriptedCreature.h"
#include "SpellInfo.h"
#include "onyxias_lair.h"
#include <array>
enum Spells
{
@@ -36,6 +38,9 @@ enum Spells
SPELL_OLG_BLASTNOVA = 68958,
SPELL_OLG_IGNITEWEAPON = 68959,
SPELL_ROOKERY_WHELP_SPAWN_IN = 15750,
SPELL_TELEPORT_SELF = 42527,
SPELL_BREATH_N_TO_S = 17086,
SPELL_BREATH_S_TO_N = 18351,
SPELL_BREATH_E_TO_W = 18576,
@@ -62,9 +67,7 @@ enum Events
EVENT_START_PHASE_3 = 12,
EVENT_PHASE_3_ATTACK = 13,
EVENT_SPELL_BELLOWINGROAR = 14,
EVENT_WHELP_SPAM = 15,
EVENT_SUMMON_LAIR_GUARD = 16,
EVENT_SUMMON_WHELP = 17,
EVENT_OLG_SPELL_BLASTNOVA = 18,
EVENT_OLG_SPELL_IGNITEWEAPON = 19,
EVENT_ERUPTION = 20,
@@ -124,6 +127,29 @@ static_assert(std::size(OnyxiaMoveData) == WP_SOUTH_EAST + 1);
Position const LairGuardTriggerPos = { -134.40698f, -213.74207f, -70.19801f, 0.01745329f };
enum TaskGroups
{
GROUP_WHELP_RESPAWN = 1
};
// Each point respawns its whelp 30-60s after the previous one died, until Onyxia lands
static Position const WhelpSpawnPoints[] =
{
{ -102.75786f, -198.85912f, -93.76155f, 5.131268f },
{ -107.54872f, -198.04468f, -93.883644f, 4.433136f },
{ -112.76325f, -196.49747f, -92.722244f, 0.261799f },
{ -117.191f, -196.107f, -92.73233f, 0.034907f },
{ -99.41064f, -198.543f, -93.59504f, 5.864306f },
{ -104.5892f, -233.16988f, -94.13f, 6.248279f },
{ -107.39845f, -230.61523f, -93.882454f, 1.448623f },
{ -110.02973f, -233.42484f, -93.29275f, 4.694936f },
{ -113.6534f, -231.24023f, -92.559586f, 2.565634f },
{ -115.66789f, -234.56912f, -92.65229f, 0.663225f }
};
// Only spawns with the liftoff burst, never respawns
static Position const WhelpLiftoffOnlyPoint = { -107.17814f, -232.05528f, -93.999115f, 6.248279f };
enum Yells
{
SAY_AGGRO = 0,
@@ -138,7 +164,7 @@ struct boss_onyxia : public BossAI
{
boss_onyxia(Creature* creature) : BossAI(creature, DATA_ONYXIA)
{
// The whelp spam must keep ticking through Deep Breath, so the scheduler may not pause while casting
// Whelp respawns and egg hatches must keep ticking through Deep Breath, so the scheduler may not pause while casting
scheduler.ClearValidator();
Initialize();
}
@@ -148,6 +174,8 @@ struct boss_onyxia : public BossAI
_phase = PHASE_NONE;
_currentWP = WP_GROUND_SOUTH;
_manyWhelpsAvailable = false;
_whelpsRespawn = false;
_pointWhelpGUIDs.fill(ObjectGuid::Empty);
_lairGuardTriggerGUID.Clear();
}
@@ -238,12 +266,18 @@ struct boss_onyxia : public BossAI
{
summons.Summon(summon);
if (summon->GetEntry() != NPC_ONYXIAN_WHELP && summon->GetEntry() != NPC_ONYXIAN_LAIR_GUARD)
if (summon->GetEntry() == NPC_ONYXIAN_WHELP)
{
// Candidates are taken where the whelp appears: by the time its spawn-in ends it may already have moved or teleported
GuidVector const eggGUIDs = GetEggsNearestFirst(summon);
scheduler.Schedule(500ms, [this, eggGUIDs](TaskContext)
{
HatchNearestEgg(eggGUIDs);
});
return;
}
if (summon->GetEntry() == NPC_ONYXIAN_LAIR_GUARD && _phase < PHASE_AIRPHASE)
if (summon->GetEntry() != NPC_ONYXIAN_LAIR_GUARD || _phase < PHASE_AIRPHASE)
{
return;
}
@@ -255,6 +289,25 @@ struct boss_onyxia : public BossAI
}
}
void SummonedCreatureDies(Creature* summon, Unit* /*killer*/) override
{
if (!_whelpsRespawn)
return;
for (uint8 point = 0; point < _pointWhelpGUIDs.size(); ++point)
{
if (_pointWhelpGUIDs[point] != summon->GetGUID())
continue;
_pointWhelpGUIDs[point].Clear();
scheduler.Schedule(30s, 60s, GROUP_WHELP_RESPAWN, [this, point](TaskContext)
{
SummonPointWhelp(point);
});
break;
}
}
void MovementInform(uint32 type, uint32 id) override
{
if (type != POINT_MOTION_TYPE && type != EFFECT_MOTION_TYPE)
@@ -305,26 +358,44 @@ struct boss_onyxia : public BossAI
me->GetMotionMaster()->MovePoint(wp, point.X, point.Y, point.Z);
}
// Summons one whelp at each of the two side caves
void SummonWhelps()
void SummonPointWhelp(uint8 point)
{
float angle = rand_norm() * 2 * M_PI;
float dist = rand_norm() * 4.0f;
me->CastSpell(-33.18f + std::cos(angle) * dist, -258.80f + std::sin(angle) * dist, -89.0f, SPELL_SUMMON_WHELP, true);
me->CastSpell(-32.535f + std::cos(angle) * dist, -170.190f + std::sin(angle) * dist, -89.0f, SPELL_SUMMON_WHELP, true);
if (Creature* whelp = me->SummonCreature(NPC_ONYXIAN_WHELP, WhelpSpawnPoints[point]))
_pointWhelpGUIDs[point] = whelp->GetGUID();
}
// 20 batches of two whelps, 600ms apart
void StartWhelpSpam()
GuidVector GetEggsNearestFirst(Creature* whelp) const
{
scheduler.Schedule(0ms, [this](TaskContext context)
std::list<GameObject*> eggs;
whelp->GetGameObjectListWithEntryInGrid(eggs, GO_ONYXIA_EGG, 4.0f);
// The grid search pads the range with both object sizes
eggs.remove_if([whelp](GameObject* egg) { return whelp->GetExactDist(egg) > 4.0f; });
eggs.sort(Acore::ObjectDistanceOrderPred(whelp));
GuidVector eggGUIDs;
for (GameObject* egg : eggs)
eggGUIDs.push_back(egg->GetGUID());
return eggGUIDs;
}
// Only the nearest egg still standing hatches; its whelp appears 2s later, the cast time of Summon Onyxia Whelp
void HatchNearestEgg(GuidVector const& eggGUIDs)
{
for (ObjectGuid const& eggGUID : eggGUIDs)
{
SummonWhelps();
if (context.GetRepeatCounter() < 19)
GameObject* egg = ObjectAccessor::GetGameObject(*me, eggGUID);
if (!egg || !egg->isSpawned())
continue;
Position const eggPos = egg->GetPosition();
egg->DespawnOrUnsummon();
scheduler.Schedule(2s, [this, eggPos](TaskContext)
{
context.Repeat(600ms);
}
});
me->SummonCreature(NPC_ONYXIAN_WHELP, eggPos);
});
return;
}
}
void UpdateAI(uint32 diff) override
@@ -396,6 +467,11 @@ struct boss_onyxia : public BossAI
_manyWhelpsAvailable = true;
events.RescheduleEvent(EVENT_END_MANY_WHELPS_TIME, 10s);
_whelpsRespawn = true;
for (uint8 point = 0; point < std::size(WhelpSpawnPoints); ++point)
SummonPointWhelp(point);
me->SummonCreature(NPC_ONYXIAN_WHELP, WhelpLiftoffOnlyPoint);
break;
}
case EVENT_END_MANY_WHELPS_TIME:
@@ -405,9 +481,6 @@ struct boss_onyxia : public BossAI
{
me->SetSpeed(MOVE_RUN, 2.95f, false);
MoveToWaypoint(WP_NORTH);
StartWhelpSpam();
events.ScheduleEvent(EVENT_WHELP_SPAM, 90s);
break;
}
case EVENT_SUMMON_LAIR_GUARD:
@@ -419,12 +492,6 @@ struct boss_onyxia : public BossAI
events.Repeat(46s);
break;
}
case EVENT_WHELP_SPAM:
{
StartWhelpSpam();
events.Repeat(90s);
break;
}
case EVENT_LAND:
{
Talk(SAY_PHASE_3_TRANS);
@@ -498,6 +565,10 @@ struct boss_onyxia : public BossAI
}
case EVENT_PHASE_3_ATTACK:
{
// Hatches already under way still finish
_whelpsRespawn = false;
scheduler.CancelGroup(GROUP_WHELP_RESPAWN);
me->SetReactState(REACT_AGGRESSIVE);
if (Unit* target = SelectTarget(SelectTargetMethod::MaxThreat, 0, 0, false))
@@ -513,7 +584,6 @@ struct boss_onyxia : public BossAI
events.ScheduleEvent(EVENT_SPELL_TAILSWEEP, 15s, 20s);
events.ScheduleEvent(EVENT_SPELL_CLEAVE, 2s, 5s);
events.ScheduleEvent(EVENT_SPELL_BELLOWINGROAR, 15s);
events.ScheduleEvent(EVENT_SUMMON_WHELP, 10s);
break;
}
case EVENT_SPELL_BELLOWINGROAR:
@@ -531,12 +601,6 @@ struct boss_onyxia : public BossAI
}
break;
}
case EVENT_SUMMON_WHELP:
{
SummonWhelps();
events.Repeat(30s);
break;
}
default:
break;
}
@@ -560,6 +624,8 @@ private:
Phases _phase;
uint8 _currentWP;
bool _manyWhelpsAvailable;
bool _whelpsRespawn;
std::array<ObjectGuid, std::size(WhelpSpawnPoints)> _pointWhelpGUIDs;
ObjectGuid _lairGuardTriggerGUID;
};
@@ -618,8 +684,64 @@ struct npc_onyxian_lair_guard : public ScriptedAI
}
};
struct npc_onyxian_whelp : public ScriptedAI
{
npc_onyxian_whelp(Creature* creature) : ScriptedAI(creature) { }
void IsSummonedBy(WorldObject* /*summoner*/) override
{
DoCastSelf(SPELL_ROOKERY_WHELP_SPAWN_IN);
scheduler.Schedule(500ms, [this](TaskContext context)
{
if (Unit* target = me->SelectNearestTarget(300.0f))
{
AttackStart(target);
DoZoneInCombat();
}
TeleportIfVictimUnreachable();
context.Schedule(1s, [this](TaskContext check)
{
TeleportIfVictimUnreachable();
check.Repeat();
});
});
}
void JustDied(Unit* /*killer*/) override
{
me->DespawnOrUnsummon(4s);
}
void UpdateAI(uint32 diff) override
{
scheduler.Update(diff);
if (!UpdateVictim())
return;
DoMeleeAttackIfReady();
}
private:
// The chase movement walks partial paths, so a victim on an unreachable ledge would never be flagged by the core
void TeleportIfVictimUnreachable()
{
Unit* victim = me->GetVictim();
if (!victim || victim->IsFalling() || me->IsWithinMeleeRange(victim))
return;
PathGenerator path(me);
path.CalculatePath(victim->GetPositionX(), victim->GetPositionY(), victim->GetPositionZ());
if (path.GetPathType() & (PATHFIND_NOPATH | PATHFIND_INCOMPLETE))
DoCastVictim(SPELL_TELEPORT_SELF);
}
};
void AddSC_boss_onyxia()
{
RegisterOnyxiasLairCreatureAI(boss_onyxia);
RegisterOnyxiasLairCreatureAI(npc_onyxian_lair_guard);
RegisterOnyxiasLairCreatureAI(npc_onyxian_whelp);
}
@@ -48,7 +48,8 @@ enum Actions
enum GameObjectIds
{
GO_WHELP_SPAWNER = 176510
GO_WHELP_SPAWNER = 176510,
GO_ONYXIA_EGG = 176511
};
enum SharedSpells