From 0a9ad7ecdbfa24a659b5362555f6a01efef91ee4 Mon Sep 17 00:00:00 2001 From: Andrew <47818697+Nyeriah@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:54:02 -0300 Subject: [PATCH] fix(Scripts/OnyxiasLair): Spawn Onyxia's whelps from rookery points and hatching eggs (#27833) --- .../rev_1790511523936203400.sql | 5 + e2e/README.md | 1 + .../classic/onyxias_lair/onyxia_e2e_test.go | 492 ++++++++++++++++++ .../Kalimdor/OnyxiasLair/boss_onyxia.cpp | 192 +++++-- .../Kalimdor/OnyxiasLair/onyxias_lair.h | 3 +- 5 files changed, 657 insertions(+), 36 deletions(-) create mode 100644 data/sql/updates/pending_db_world/rev_1790511523936203400.sql create mode 100644 e2e/suites/instances/classic/onyxias_lair/onyxia_e2e_test.go diff --git a/data/sql/updates/pending_db_world/rev_1790511523936203400.sql b/data/sql/updates/pending_db_world/rev_1790511523936203400.sql new file mode 100644 index 0000000000..e854bbe457 --- /dev/null +++ b/data/sql/updates/pending_db_world/rev_1790511523936203400.sql @@ -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; diff --git a/e2e/README.md b/e2e/README.md index 5c98a8679c..f055079dc6 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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 | diff --git a/e2e/suites/instances/classic/onyxias_lair/onyxia_e2e_test.go b/e2e/suites/instances/classic/onyxias_lair/onyxia_e2e_test.go new file mode 100644 index 0000000000..d39640aefb --- /dev/null +++ b/e2e/suites/instances/classic/onyxias_lair/onyxia_e2e_test.go @@ -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< len(data) { + return 0, false + } + return binary.LittleEndian.Uint32(data[off : off+4]), true +} diff --git a/src/server/scripts/Kalimdor/OnyxiasLair/boss_onyxia.cpp b/src/server/scripts/Kalimdor/OnyxiasLair/boss_onyxia.cpp index c2f848a752..6bcf57447f 100644 --- a/src/server/scripts/Kalimdor/OnyxiasLair/boss_onyxia.cpp +++ b/src/server/scripts/Kalimdor/OnyxiasLair/boss_onyxia.cpp @@ -16,10 +16,12 @@ */ #include "CreatureScript.h" +#include "PathGenerator.h" #include "Player.h" #include "ScriptedCreature.h" #include "SpellInfo.h" #include "onyxias_lair.h" +#include 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 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 _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); } diff --git a/src/server/scripts/Kalimdor/OnyxiasLair/onyxias_lair.h b/src/server/scripts/Kalimdor/OnyxiasLair/onyxias_lair.h index 87e20bb7c4..dc0081696b 100644 --- a/src/server/scripts/Kalimdor/OnyxiasLair/onyxias_lair.h +++ b/src/server/scripts/Kalimdor/OnyxiasLair/onyxias_lair.h @@ -48,7 +48,8 @@ enum Actions enum GameObjectIds { - GO_WHELP_SPAWNER = 176510 + GO_WHELP_SPAWNER = 176510, + GO_ONYXIA_EGG = 176511 }; enum SharedSpells