mirror of
https://gitcode.com/GitHub_Trending/az/azerothcore-wotlk.git
synced 2026-10-10 07:06:38 +08:00
fix(Scripts/OnyxiasLair): Spawn Onyxia's whelps from rookery points and hatching eggs (#27833)
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user