feat(Core/e2e): live protocol e2e suite (#27158)

This commit is contained in:
Anton Popovichenko
2026-08-27 14:57:14 +02:00
committed by GitHub
parent a10ae400e0
commit 887538fe67
38 changed files with 4798 additions and 5 deletions
+172
View File
@@ -0,0 +1,172 @@
# E2E policy
Mandatory when writing or changing live-stack e2e (`e2e/`). **When** to write tests. **How:** AzerothGhost `LLM_GUIDE.md` / `EXAMPLES.md` (this file does not replace them). Inventory: `e2e/README.md`. Stack: AC 3.3.5a auth+world+MySQL; `E2E_*` may point at a gateway.
## Terms
| Term | Meaning |
|------|---------|
| Unit | Isolated C++/Go; no live stack. `src/test/`. |
| Integration | In-process multi-component; no client login. |
| E2E | `//go:build e2e` Go test: `e2eharness` bots on a running realm; assert protocol / object cache / DB. |
| Player-visible | A real client can observe it. |
| Tracked issue | AC issue/PR id for `ConfirmedBugf`. |
## When
First match wins:
| If | Then |
|----|------|
| Pure calc / parse / bit math / no world | UNIT. NEVER e2e. |
| Covered by existing unit/mocks (`SpellProc*`, …) without login/map/DBC fidelity | UNIT or in-process integration. E2E only if unit cannot reach the path. |
| SQL-only static (text, displayid, non-script loot) | No e2e. Manual optional. |
| SmartAI / conditions / waypoints; outcome only in-world | E2E SHOULD if player-visible and deterministic; else document manual repro. |
| Multi-system on a live session, or protocol/client-observed, or player-repro bugfix | E2E MUST (or update existing). Unit MAY still cover pure pieces. |
| Refactor, no behaviour intent | Update tests that break. Add e2e only if a critical system below lacks an oracle. |
Bias: player-visible and MVT fits → prefer e2e over unit-only. Not a MUST trigger and unsure → do not invent e2e; file a gap.
**MUST add or update e2e** when any of: player-visible bugfix (encode the repro); protocol change; multi-system (combat/aura/death/quest/loot/mount/pet/vehicle/instance/PvP/guild/relog/DB after `.save`); crash/hang/freeze on client action (`ProbeWorldAlive` / `AssertWorldAlive`); existing `TestAC_*` path touched (update, do not drop); critical system with no oracle; claimed blizzlike fix for a tracked issue (`ConfirmedBugf` until fixed).
**MUST NOT** treat “tested in-game” as a substitute when MVT is feasible.
Harness cannot express it: extend harness or file a harness gap; inventory the gap; still unit-test pure logic.
**NEVER / do instead**
| Situation | Do instead |
|-----------|------------|
| Pure math, flags, calendar | `src/test/` |
| Isolated spell-proc already unit-covered; no new player-visible interaction | extend unit |
| SQL-only static, no behavioural branch | SQL review |
| Rename / comments / refactor; no behaviour; not a critical system | existing tests |
| Human judgment, nondeterminism, hours-long lockouts | manual; optional one deterministic slice |
| Same oracle as an existing test | extend or share a helper |
| “World boots” with no behaviour oracle | not feature e2e |
| Invent harness APIs / test the harness | contribute to AzerothGhost |
SHOULD NOT e2e a GM command that is not on the player path (GM setup is fine; assert player-relevant state).
## Authoring (PR gate)
Consumer suite (`e2e/suites/` / `e2e/smoke/`), not only Ghost. `//go:build e2e`. Import `e2eharness` + blank MySQL driver.
Fixture: `NewSolo` / `NewScenario` (`BotSpec` / `ByRole` if roles differ). Prefer `ScenarioBot` over raw `Session` except guild charter/bank.
`meta.Begin` serial by default. Prefix ≤7 chars (auth name max 17 = Prefix+10). Tag `parallel` only when pad-safe.
Name: `TestArea_Behaviour` or `TestAC_<issue>_<Short>`. Comment the issue/PR URL.
Flow: fixture → place → setup (GM ok) → `CombatReady` if pull → drive → assert.
Place: `Teleport` / `TeleportPad` / `TeleNamed` / `GoCreatureID`. Melee: `GoCreatureID` after named tele. Tele clears object cache — re-`WaitUnit`.
Combat: `CombatReady` / `CombatReadyFull` before pulls. NEVER `.gm on` during aggro. Damage: `Damage` / `DamageKill` only — NEVER `.gm on` mid-fight for `.damage`.
Casts: `Cast` / `CastMust` / `CastOrGM` / `CastAtPosition`; fail reason via `SpellFailReasonName`.
Waiters: Arm → Send → Wait. NEVER re-arm during Wait. NEVER replace waiters with long fixed sleeps.
After fire-and-forget GM that later casts depend on (`.pvp on`, `.gm off`), `FlushWorld` on that bot (world-thread ack) or a state waiter (`WaitUnitPvP`).
Quest DB: only after `Save` / `QuestStatusAfterSave`. Spell-summon: NEVER `.npc add` instead of the spell. Set race so GM text uses the character’s language (Horde ≠ Common).
Oracle = behaviour (protocol / cache; DB after `Save`), not “no error”. SHOULD `t.Logf("PASS …")`. SHOULD log GUIDs/spell ids/statuses.
**MVT** (all required or it is not coverage): one primary oracle; real client path; setup failures are `Preconditionf`; correct severity; no footguns above; minutes not hours (long boss waves only if the oracle needs them); runnable with `E2E_*` on stock AC; name+comment findable from issue or mechanic.
```go
//go:build e2e
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/N
func TestAC_N_ShortOracle(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "issue"}, Runtime: "short", Issue: N})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "Short", Race: e2eharness.RaceHuman,
Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true,
})
// place → setup → (CombatReady) → drive → assert
}
```
Run: `go test -tags=e2e -run TestName -count=1 -v` green on fixed core (or only `CONFIRMED BUG` on unfixed). SHOULD `-count=2` stable. Document if exclusive realm needed.
NEVER: invent harness APIs; wrap GM/map without logs; bare `t.Fatalf("CONFIRMED BUG")`; copy whole examples; one test for five issues.
## Fail helpers
Use these, not bare `t.Fatalf`, for classified outcomes:
| Helper | Prefix | When |
|--------|--------|------|
| `Preconditionf` | `precondition:` | Setup never reached a judgeable state |
| `ConfirmedBugf(t, N, …)` | `AC#N CONFIRMED BUG:` | Tracked issue; core wrong (fails CI) |
| `HarnessFailf` / `Assertf` | `harness:` / `assert:` | Infra or fixed-core regression (fails CI) |
| `SoftWarnf` | `WARNING:` | Non-fatal soft deviation |
| Open / unfixed issue | — | Comment out the **entire** test: `TODO(e2e): re-enable when AC#N is fixed` + issue URL. Re-enable body MUST hard-fail. NEVER log+return soft-pass. |
## Critical systems
A PR that implements, fixes, or refactors a row MUST add or update e2e for that oracle, or keep an existing test that still asserts it. If harness cannot: gap in `e2e/README.md` + unit for pure logic.
| System | Oracle / drive |
|--------|----------------|
| Aura apply/strip/consume | apply → action → `AssertAuraRemains` / `AssertAuraConsumed` / aura waiters. Paths: `Spells`, aura scripts, `spell_*.cpp` |
| Cast / charge / pathing | `Cast`/`CastMust` + fail reason / `SMSG_SPELL_GO`; charge stays on bridge; ground AoE landing |
| Death / repop / corpse | `DieAndRepop` (or staged) + surviving system (STAY_ALIVE, Raise Dead) |
| Quest + persistence | `AddQuest` → action → `Save` → `AssertQuestStatus` |
| Relog / extra_flags / GM vis | set → `Save` → `Relog` → DB or protocol |
| Threat / evade / engage | `CombatReady` → `Engage` → still in combat; no evade from bad GM |
| Spell summon | cast the summon spell (not `.npc add`) + summoned unit properties |
| Instance boss AI | place → `CombatReady` → `Engage` → spawn/target/interval (`AssertIntervalNotAccelerated`) |
| Guild charter / bank | Session guild helpers |
| Account / realm GM | `.account set gmlevel` scoped to target realm (#27088 style) |
| Crash-prone cast | repro + `ProbeWorldAlive` / `AssertWorldAlive` |
| Pure helper extracted from the above | unit the helper **and** keep e2e on the player path |
## Coverage
Search consumer suite + Ghost `examples/` + `TestAC_*` for issue, spell, quest, creature, mechanic **before** adding.
Same oracle → same test (extend / table-drive). Same setup, different oracle → helper or `t.Run`, not a second login. NEVER copy Ghost `examples/` without changing the oracle. One tracked issue → one primary `TestAC_<id>_…`; extra edges as `t.Run` or siblings only if isolation requires. Same root cause + same assertion surface → merge.
Inventory (`e2e/README.md` + suite comments): category, one-sentence oracle, P0–P3, `covered` (test name) / `gap` / `blocked-harness` / `manual-only`, issue links, note. MUST update when adding coverage or finding a gap. `blocked-harness` MUST name the missing API (opcode, waiter, multi-realm, …). Closing a P0/P1 gap is preferred when touching that subsystem. When pruning, merge redundant tests and refresh the inventory.
Prefer one solid test per merged player-visible bugfix. Deepen critical categories over new weak ones. Every test: unique oracle + owner (issue or feature). Delete or merge tests that no longer map to a behaviour.
## Priority (next test)
Highest feasible MVT:
| Pri | Cover |
|-----|-------|
| **P0** | Crash / hang / corruption on client action; already-regressed player bug with issue id |
| **P1** | Aura strip/consume/persist; death+quest; relog flags; evade/engage; charge/position; totem/grounding; spell-summon (not GM spawn) |
| **P2** | Single-boss timers/waves/targeting; guild charter/bank; multi-bot PvP; realm-scoped GM / persisted visibility |
| **P3** | QoL, display, nondeterministic farm; full raid clears (out of default growth) |
On a PR: implement e2e for the highest-priority trigger the PR activates. Do not add P3 in the same change unless asked.
## Isolation, comments, flakes
Unique `Prefix`; cleanup via `t.Cleanup`. Do not depend on other tests’ characters/guilds/instance saves. Time oracles: bounded windows with documented thresholds, not “sleep 60s”.
Tracked issues: URL in comment + id in `ConfirmedBugf`. State expected green-on-fixed vs CONFIRMED-BUG-on-unfixed. Do not claim blizzlike without issue, PR, or reviewed wowhead/web evidence.
Flake: classify (infra, timing, pad collision, dirty world, core race). Fix waiters / Arm-Send-Wait / `CombatReady` / unique prefixes / re-wait after tele. Intermittent fail on a correct core is a harness/test bug until proven otherwise. Quarantine (`t.Skip` + reason) only for environmental flake, rare and ticketed. NEVER silent retries or endless timeout bumps.
## Open issues
| Core | Test |
|------|------|
| Unfixed | Comment out the whole test + TODO + URL. Re-enable MUST hard-fail. |
| Fixed | Uncomment; MUST go green. Keep `TestAC_<n>_` and comment. |
| Invalid / cannot repro | Delete the disabled block; no lying TODOs. |
| Env cannot run | `Preconditionf` or inventory `blocked-*`; not a soft PASS. |
NEVER let a test PASS while the product oracle is wrong. NEVER multi-retry + soft-exit to hide flakes or open bugs. NEVER delete a green regression because the issue closed.
Update tests in the same PR as the behaviour change. Harness renames: update consumers in the same landing window. Ghost `e2e/examples/` are patterns; AC regressions live in the consumer suite.
Scratch MUST be `e2e/local/` (gitignored except `local/README.md`). NEVER commit throwaways. Promote into `e2e/suites/` next to related tests in the same PR as the fix. Prefer live e2e over ad-hoc GM when the stack is up.
Local (from `e2e/`): `go test -tags=e2e ./...`. Official-repo PR and master CI: full suite after nopch clang-18 (reuses those binaries). Dispatch `-f scope=smoke` for a smaller run. Touch `.github/workflows/e2e-live.yml` only when changing CI.
+5
View File
@@ -7,6 +7,11 @@ insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 120
[*.go]
indent_style = tab
indent_size = 4
tab_width = 4
[*.{json,ts,js,yml,sh}]
charset = utf-8
indent_style = space
+51 -4
View File
@@ -8,6 +8,15 @@ on:
- opened
- reopened
- synchronize
- ready_for_review
workflow_dispatch:
inputs:
scope:
description: 'e2e scope after the clang-18 build'
required: false
default: 'full'
type: choice
options: [smoke, full]
concurrency:
# One concurrency group per workflow + ref.
@@ -26,15 +35,34 @@ permissions:
contents: read
jobs:
# Split out of the matrix so e2e can needs: this cell only (not gcc/clang-21).
build-e2e:
name: ubuntu-24.04-clang-18-nopch
runs-on: ubuntu-24.04
if: github.repository == 'azerothcore/azerothcore-wotlk' && !github.event.pull_request.draft
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/linux-build
with:
CC: clang-18
CXX: clang++-18
pch: false
- name: Upload server binaries for e2e
uses: actions/upload-artifact@v4
with:
name: ac-server-ubuntu-24.04-clang-18
path: |
env/dist/bin/authserver
env/dist/bin/worldserver
env/dist/etc/
if-no-files-found: error
retention-days: 3
build:
strategy:
fail-fast: true
matrix:
include:
- os: ubuntu-24.04
compiler:
CC: clang-18
CXX: clang++-18
- os: ubuntu-26.04
compiler:
CC: gcc-15
@@ -53,3 +81,22 @@ jobs:
CC: ${{ matrix.compiler.CC }}
CXX: ${{ matrix.compiler.CXX }}
pch: false
e2e:
name: e2e ${{ inputs.scope || 'full' }}
needs: build-e2e
# Official PR, master push, and dispatch. Master re-runs full so flakes
# and merge-base drift show up after squash, not only on the PR head.
if: |
github.repository == 'azerothcore/azerothcore-wotlk'
&& !github.event.pull_request.draft
&& needs.build-e2e.result == 'success'
&& (
github.event_name == 'pull_request'
|| github.event_name == 'workflow_dispatch'
|| (github.event_name == 'push' && github.ref == 'refs/heads/master')
)
uses: ./.github/workflows/e2e-live.yml
with:
scope: ${{ inputs.scope || 'full' }}
artifact_run_id: ${{ github.run_id }}
-1
View File
@@ -279,7 +279,6 @@ jobs:
fi
continue-on-error: false
- name: Test worldserver with startup scripts
run: |
./acore.sh sm create world worldserver --bin-path ./env/dist/bin --provider pm2
+421
View File
@@ -0,0 +1,421 @@
# Live e2e (native only): start auth + world, then go test.
#
# PR / master: nopch-build (ubuntu-24.04 clang-18) compiles + dry-run,
# uploads binaries; this workflow is called with artifact_run_id, scope=full.
# Dispatch: compile via linux-build, or nopch-build -f scope=smoke|full.
#
# Triggers: workflow_call (from nopch-build) | workflow_dispatch
name: e2e-live
on:
workflow_call:
inputs:
scope:
description: 'smoke | full'
type: string
default: 'full'
count:
type: string
default: '1'
timeout:
type: string
default: ''
parallel:
type: string
default: '1'
package_parallel:
type: string
default: '1'
packages:
type: string
default: './...'
tags_filter:
type: string
default: ''
artifact_run_id:
description: 'Caller run id that uploaded ac-server-ubuntu-24.04-clang-18'
type: string
default: ''
workflow_dispatch:
inputs:
packages:
description: 'Go packages under e2e/ when scope=full'
required: false
default: './...'
scope:
description: 'smoke | full'
required: false
default: 'full'
type: choice
options: [smoke, full]
count:
required: false
default: '1'
timeout:
description: 'go test -timeout (smoke default 30m; full should be >=120m)'
required: false
default: ''
parallel:
description: 'go test -parallel (in-package; keep 1 for pad safety)'
required: false
default: '1'
package_parallel:
description: 'go test -p (packages; keep 1 unless each concurrent suite has a unique pad)'
required: false
default: '1'
tags_filter:
description: 'E2E_TAGS for full scope only'
required: false
default: ''
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
# Cancel superseded PR runs; do not cancel intentional dispatch full suites.
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
permissions:
contents: read
actions: read # download-artifact with run-id
env:
CONTINUOUS_INTEGRATION: true
MYSQL_ROOT_PASSWORD: root
jobs:
e2e:
name: e2e (native / ${{ inputs.scope || 'full' }})
if: github.repository == 'azerothcore/azerothcore-wotlk'
# Artifact path (PR nopch): ubuntu-24.04 to match the uploaded binaries.
# Dispatch (no artifact): optional self-hosted via E2E_RUNS_ON.
runs-on: ${{ inputs.artifact_run_id != '' && 'ubuntu-24.04' || vars.E2E_RUNS_ON || 'ubuntu-24.04' }}
timeout-minutes: 480
steps:
- name: Free disk
run: |
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc "$AGENT_TOOLSDIRECTORY" || true
df -h
- name: Checkout AzerothCore
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: e2e/go.mod
cache-dependency-path: e2e/go.sum
# AzerothGhost is a public Go module (e2e/go.mod; repo azerothcore/AzerothGhost).
- name: Download e2e modules
working-directory: e2e
run: go mod download
# upload-artifact roots the zip at the common parent (env/dist), so the
# archive is bin/authserver + etc/, not env/dist/bin/authserver.
- name: Download nopch server binaries
if: ${{ inputs.artifact_run_id != '' }}
uses: actions/download-artifact@v4
with:
name: ac-server-ubuntu-24.04-clang-18
path: ${{ runner.temp }}/ac-server
github-token: ${{ github.token }}
run-id: ${{ inputs.artifact_run_id }}
- name: Place downloaded binaries
if: ${{ inputs.artifact_run_id != '' }}
run: |
set -euo pipefail
root="${RUNNER_TEMP}/ac-server"
find "$root" -type f -print | sed -n '1,50p'
auth=$(find "$root" -type f -name authserver | sed -n '1p')
[[ -n "${auth}" ]] || { echo '::error::authserver missing from artifact'; exit 1; }
src=$(dirname "${auth}")
mkdir -p env/dist/bin env/dist/etc
cp -a "${src}/authserver" "${src}/worldserver" env/dist/bin/
chmod +x env/dist/bin/authserver env/dist/bin/worldserver
etc=$(find "$root" -type d -name etc | head -n 1)
[[ -n "${etc}" ]] || { echo '::error::etc/ missing from artifact'; exit 1; }
cp -a "${etc}/." env/dist/etc/
ls -l env/dist/bin/authserver env/dist/bin/worldserver env/dist/etc
# Runtime only (no compile). install-deps starts MySQL in CI.
- name: Deps, empty DBs, client-data
if: ${{ inputs.artifact_run_id != '' }}
env:
MYSQL_ROOT_PASSWORD: root
CONTINUOUS_INTEGRATION: true
DATAPATH: ${{ github.workspace }}/env/dist/data
run: |
set -euo pipefail
./acore.sh install-deps
./acore.sh setup-db
./acore.sh client-data
echo "AC_DATA_DIR=${DATAPATH}" >> "$GITHUB_ENV"
timeout-minutes: 30
- name: Build (dispatch / no artifact)
if: ${{ inputs.artifact_run_id == '' }}
uses: ./.github/actions/linux-build
with:
CC: clang-18
CXX: clang++-18
pch: false
# linux-build only clones ac-data (DBC). Real worldserver needs maps/vmaps/mmaps.
- name: Client-data (dispatch / no artifact)
if: ${{ inputs.artifact_run_id == '' }}
env:
CONTINUOUS_INTEGRATION: true
DATAPATH: ${{ github.workspace }}/env/dist/data
run: |
set -euo pipefail
./acore.sh client-data
echo "AC_DATA_DIR=${DATAPATH}" >> "$GITHUB_ENV"
timeout-minutes: 30
# create_mysql.sql only creates acore@localhost (unix_socket). Servers and
# the harness connect via TCP 127.0.0.1, which needs a separate account host.
# Grants are loopback-only (no root@% / acore@%) for self-hosted runner safety.
- name: MySQL TCP grants for acore + root password
env:
MYSQL_ROOT_PASSWORD: root
run: |
set -euo pipefail
mysql_root() {
sudo mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" "$@"
}
# Hosted images often have empty root password. Set it once.
# Do not ALTER when root already uses a password (self-hosted).
if sudo mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e 'SELECT 1' &>/dev/null; then
:
elif sudo mysql -uroot -e 'SELECT 1' &>/dev/null; then
sudo mysql -uroot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY '${MYSQL_ROOT_PASSWORD}';"
else
echo '::error::cannot connect to MySQL as root'
exit 1
fi
mysql_root <<'SQL'
CREATE USER IF NOT EXISTS 'root'@'127.0.0.1' IDENTIFIED BY 'root';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION;
CREATE USER IF NOT EXISTS 'acore'@'127.0.0.1' IDENTIFIED BY 'acore';
GRANT ALL PRIVILEGES ON `acore_world`.* TO 'acore'@'127.0.0.1' WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON `acore_characters`.* TO 'acore'@'127.0.0.1' WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON `acore_auth`.* TO 'acore'@'127.0.0.1' WITH GRANT OPTION;
CREATE USER IF NOT EXISTS 'acore'@'localhost' IDENTIFIED BY 'acore';
GRANT ALL PRIVILEGES ON `acore_world`.* TO 'acore'@'localhost' WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON `acore_characters`.* TO 'acore'@'localhost' WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON `acore_auth`.* TO 'acore'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SQL
mysql -h127.0.0.1 -uacore -pacore -e 'SELECT USER(), CURRENT_USER();' acore_auth
# Copy .conf from .dist only (no sed). Runtime overrides use AC_* env vars
# (ConfigMgr: ini key → AC_UPPER_SNAKE, e.g. Console.Enable → AC_CONSOLE_ENABLE).
- name: Prepare server conf + AC_*
run: |
set -euo pipefail
etc="$(cd env/dist/etc && pwd)"
for n in authserver worldserver; do
[[ -f "${etc}/${n}.conf" ]] || cp -v "${etc}/${n}.conf.dist" "${etc}/${n}.conf"
done
mkdir -p env/dist/bin env/dist/data
logs_dir="$(cd env/dist/bin && pwd)"
data_dir="$(cd env/dist/data && pwd)"
{
echo "AC_LOGS_DIR=${logs_dir}"
echo "AC_DATA_DIR=${data_dir}"
echo "AC_CONSOLE_ENABLE=0"
echo "AC_WARDEN_ENABLED=0"
echo "AC_BEEP_AT_START=0"
echo "AC_REALMS_STATE_UPDATE_DELAY=1"
echo "E2E_AUTH_ADDR=127.0.0.1:3724"
echo "E2E_AUTH_DSN=acore:acore@tcp(127.0.0.1:3306)/acore_auth"
echo "E2E_CHAR_DSN=acore:acore@tcp(127.0.0.1:3306)/acore_characters"
echo "E2E_WORLD_DSN=acore:acore@tcp(127.0.0.1:3306)/acore_world"
} >> "$GITHUB_ENV"
- name: Start authserver + worldserver (background)
run: |
set -euo pipefail
cd env/dist/bin
etc="$(cd ../etc && pwd)"
[[ -f "${etc}/authserver.conf" ]] || cp -v "${etc}/authserver.conf.dist" "${etc}/authserver.conf"
[[ -f "${etc}/worldserver.conf" ]] || cp -v "${etc}/worldserver.conf.dist" "${etc}/worldserver.conf"
# AC_* from GITHUB_ENV (set in Prepare server conf). Re-export for this shell.
: "${AC_LOGS_DIR:=$(pwd)}"
export AC_LOGS_DIR
# linux-build sets a workspace-relative DataDir; worldserver CWD is bin/.
if [[ -n "${AC_DATA_DIR:-}" ]]; then
export AC_DATA_DIR
fi
export AC_CONSOLE_ENABLE="${AC_CONSOLE_ENABLE:-0}"
export AC_WARDEN_ENABLED="${AC_WARDEN_ENABLED:-0}"
export AC_BEEP_AT_START="${AC_BEEP_AT_START:-0}"
export AC_REALMS_STATE_UPDATE_DELAY="${AC_REALMS_STATE_UPDATE_DELAY:-1}"
echo "AC overrides: LOGS_DIR=${AC_LOGS_DIR} DATA_DIR=${AC_DATA_DIR:-} CONSOLE=${AC_CONSOLE_ENABLE} WARDEN=${AC_WARDEN_ENABLED} BEEP=${AC_BEEP_AT_START} REALMS_DELAY=${AC_REALMS_STATE_UPDATE_DELAY}"
wait_port() {
local port="$1" name="$2" secs="${3:-120}"
local i
for i in $(seq 1 "${secs}"); do
if (echo >"/dev/tcp/127.0.0.1/${port}") >/dev/null 2>&1; then
echo "${name} listening on ${port} at ${i}s"
return 0
fi
if ! kill -0 "$(cat "${RUNNER_TEMP}/${name}.pid")" 2>/dev/null; then
echo "::error::${name} exited early"
tail -n 150 "${RUNNER_TEMP}/${name}.log" 2>/dev/null || true
return 1
fi
if (( i % 30 == 0 )); then
echo "${name}: still waiting for port ${port} (${i}s)"
fi
sleep 1
done
echo "::error::${name} did not open port ${port} within ${secs}s"
tail -n 150 "${RUNNER_TEMP}/${name}.log" 2>/dev/null || true
return 1
}
# World first: Main.cpp binds :8085 only after SetInitialWorldSettings
# (first boot also applies world/chars schema — can take ~15m).
./worldserver -c "${etc}/worldserver.conf" \
< /dev/null >"${RUNNER_TEMP}/worldserver.log" 2>&1 &
echo $! >"${RUNNER_TEMP}/worldserver.pid"
wait_port 8085 worldserver 900
./authserver -c "${etc}/authserver.conf" \
< /dev/null >"${RUNNER_TEMP}/authserver.log" 2>&1 &
echo $! >"${RUNNER_TEMP}/authserver.pid"
wait_port 3724 authserver 300
# realmlist exists only after first-boot schema apply.
mysql -h127.0.0.1 -uacore -pacore -e \
"UPDATE acore_auth.realmlist
SET address='127.0.0.1', localAddress='127.0.0.1',
localSubnetMask='255.255.255.0', flag=0, port=8085, gamebuild=12340
WHERE id=1;
SELECT id,name,address,localAddress,localSubnetMask,port,flag,gamebuild
FROM acore_auth.realmlist\G"
# >= RealmsStateUpdateDelay (1s) + margin so in-memory realm list drops OFFLINE.
sleep 3
(echo >/dev/tcp/127.0.0.1/8085) >/dev/null 2>&1 || { echo '::error::world port lost'; exit 1; }
(echo >/dev/tcp/127.0.0.1/3724) >/dev/null 2>&1 || { echo '::error::auth port lost'; exit 1; }
timeout-minutes: 40
- name: Validate E2E_*
run: |
set -euo pipefail
for v in E2E_AUTH_ADDR E2E_AUTH_DSN E2E_CHAR_DSN E2E_WORLD_DSN; do
[[ -n "${!v:-}" ]] || { echo "missing $v"; exit 1; }
done
echo "E2E_AUTH_ADDR=${E2E_AUTH_ADDR}"
- name: Run go e2e
working-directory: e2e
env:
# Inputs via env only — never expand untrusted workflow_dispatch into bash -c text.
E2E_SCOPE: ${{ inputs.scope || 'smoke' }}
E2E_COUNT: ${{ inputs.count || '1' }}
E2E_TIMEOUT_INPUT: ${{ inputs.timeout || '' }}
E2E_PARALLEL: ${{ inputs.parallel || '1' }}
E2E_P: ${{ inputs.package_parallel || '1' }}
E2E_PACKAGES: ${{ inputs.packages || './...' }}
E2E_TAGS_FILTER: ${{ inputs.tags_filter || '' }}
run: |
set -euo pipefail
count="${E2E_COUNT}"
parallel="${E2E_PARALLEL}"
p="${E2E_P}"
scope="${E2E_SCOPE}"
packages_raw="${E2E_PACKAGES}"
# Packages must stay under e2e/. Allow Go ./... ; reject a ../ path segment.
[[ "${packages_raw}" =~ ^\./[A-Za-z0-9_./\*+-]*$ ]] || { echo "bad packages"; exit 1; }
case "${packages_raw}" in
..|../*|*/..|*/../*) echo "packages must not contain ../"; exit 1 ;;
esac
[[ "${count}" =~ ^[1-9][0-9]*$ ]] || { echo "bad count (need >=1)"; exit 1; }
if (( count > 5 )); then
echo "count capped at 5 (was ${count})"
count=5
fi
[[ "${parallel}" =~ ^[1-9][0-9]*$ ]] || { echo "bad parallel (need >=1)"; exit 1; }
[[ "${p}" =~ ^[1-9][0-9]*$ ]] || { echo "bad package_parallel (need >=1)"; exit 1; }
if (( parallel > 1 )); then
echo "::warning::parallel=${parallel} >1 shares PackagePad within a package; prefer 1"
fi
if (( p > 1 )); then
echo "::warning::package_parallel=${p} >1 may hash-share IsolationPads if two suites land on the same pad"
fi
if (( p > 27 )); then
echo "package_parallel capped at 27 (IsolationPads size)"
p=27
fi
if [[ -n "${E2E_TIMEOUT_INPUT}" ]]; then
[[ "${E2E_TIMEOUT_INPUT}" =~ ^[0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h)$ ]] \
|| { echo "bad timeout duration: ${E2E_TIMEOUT_INPUT}"; exit 1; }
timeout="${E2E_TIMEOUT_INPUT}"
elif [[ "${scope}" == "full" ]]; then
timeout="180m"
else
timeout="30m"
fi
run_go_test() {
go test -tags=e2e -count="${count}" -timeout="${timeout}" -parallel="${parallel}" -p "${p}" -v "$@"
}
if [[ "${scope}" == "smoke" ]]; then
# Smoke package + suites tagged smoke (open-issue suites must not carry smoke).
run_go_test ./smoke/
E2E_TAGS=smoke run_go_test ./suites/...
else
if [[ -n "${E2E_TAGS_FILTER}" ]]; then
export E2E_TAGS="${E2E_TAGS_FILTER}"
fi
# shellcheck disable=SC2086
run_go_test ${packages_raw}
fi
- name: Stop servers
if: always()
run: |
for n in worldserver authserver; do
if [[ -f "${RUNNER_TEMP}/${n}.pid" ]]; then
kill "$(cat "${RUNNER_TEMP}/${n}.pid")" 2>/dev/null || true
sleep 2
kill -9 "$(cat "${RUNNER_TEMP}/${n}.pid")" 2>/dev/null || true
fi
pkill -f "env/dist/bin/${n}" 2>/dev/null || true
done
- name: Logs on failure
if: failure()
run: |
ls -la env/dist/bin/*.log env/dist/logs 2>/dev/null || true
for f in env/dist/bin/Server.log env/dist/bin/Auth.log; do
echo "==== ${f} ===="
tail -n 150 "${f}" 2>/dev/null || true
done
for f in worldserver.log authserver.log; do
echo "==== ${RUNNER_TEMP}/${f} ===="
tail -n 80 "${RUNNER_TEMP}/${f}" 2>/dev/null || true
done
- name: Upload server logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-server-logs
path: |
${{ runner.temp }}/worldserver.log
${{ runner.temp }}/authserver.log
env/dist/bin/Server.log
env/dist/bin/Auth.log
env/dist/logs/**
if-no-files-found: ignore
retention-days: 7
+7
View File
@@ -19,6 +19,7 @@
!/env/dist/.gitkeep
/env/user/*
/.env*
/e2e/.env
/apps/joiner
/deps/deno
/data/sql/custom/*
@@ -125,3 +126,9 @@ __pycache__/
# !modules/yourmodule
#
# ==================
# Live-stack e2e private / local tooling (scratch tests stay uncommitted)
e2e/local/**
!e2e/local/README.md
e2e/go.work
e2e/go.work.sum
+4
View File
@@ -7,6 +7,8 @@ AzerothCore is a C++ MMORPG server emulator for World of Warcraft 3.3.5a (WotLK)
- **Do not configure or build unless explicitly asked.** Builds are slow and rarely needed for code changes.
- **Never edit SQL files outside `data/sql/updates/pending_db_*/` unless explicitly requested.** `data/sql/base/`, `data/sql/archive/`, and `data/sql/updates/db_*/` are immutable.
- Formatting follows `.editorconfig`: UTF-8, LF, max 120 cols, trailing newline, no trailing whitespace; 4-space indent for C++ (tabs forbidden), 2-space for JSON/YAML/sh/ts/js.
- **Prefer live-stack e2e to debug/validate player-visible behaviour** when a local auth+world+MySQL stack is available (protocol, combat, quests, loot, death, multi-bot). See `e2e/README.md` and AzerothGhost `e2e/LLM_GUIDE.md`. Do not invent e2e for pure unit-sized logic — see `.agents/docs/e2e-policy.md`.
- **Scratch e2e only under `e2e/local/`** (gitignored). Never commit throwaway debug tests. Promote keepers into `e2e/suites/` or `e2e/smoke/`.
- Planning docs go in `.agents/plans/<task-slug>/` (gitignored), named `<task-slug>.<TYPE>.md` (`PLAN`, `REQUIREMENTS`, `ANALYSIS`, …).
## Mandatory reading per task
@@ -21,6 +23,7 @@ Read the matching doc(s) BEFORE starting the task:
- Reviewing a changeset or PR → `.agents/docs/code-review.md`
- Self-reviewing, or opening or updating a PR → also `.agents/docs/self-review-rules.md`
- Touching a subsystem that has a doc in `.agents/docs/systems/` → read that doc too
- Writing, debugging, or changing live-stack e2e (`e2e/`) → `e2e/README.md`, `.agents/docs/e2e-policy.md`, and AzerothGhost `e2e/LLM_GUIDE.md` (scratch work → `e2e/local/`)
- Capturing a lesson or adding/updating agent docs → `.agents/docs/README.md`
## Repository layout
@@ -32,6 +35,7 @@ Read the matching doc(s) BEFORE starting the task:
- `src/server/shared/` — code shared by auth and world servers.
- `src/server/apps/{authserver,worldserver}/` — entry points (ports 3724 and 8085).
- `src/test/` — unit tests + mocks.
- `e2e/` — live-stack Go e2e (AzerothGhost harness); see `e2e/README.md`. Scratch/debug tests: `e2e/local/` (gitignored).
- `data/sql/` — `base/` (historical schema), `updates/db_*/` (merged), `updates/pending_db_*/` (in-flight), `custom/` (gitignored).
- `modules/` — external modules (see below).
- `apps/` — helper scripts; `apps/codestyle/` holds the lint scripts.
+12
View File
@@ -0,0 +1,12 @@
# Point at your local AzerothCore stack (auth + world + MySQL).
# Stock AC / CI use acore:acore (see worldserver.conf.dist).
E2E_AUTH_ADDR=127.0.0.1:3724
E2E_AUTH_DSN=acore:acore@tcp(127.0.0.1:3306)/acore_auth
E2E_CHAR_DSN=acore:acore@tcp(127.0.0.1:3306)/acore_characters
E2E_WORLD_DSN=acore:acore@tcp(127.0.0.1:3306)/acore_world
# Optional filters (see internal/meta)
# E2E_TAGS=smoke,short
# E2E_SKIP_TAGS=long
# E2E_ISSUE=26549
# E2E_RUNTIME=short
+54
View File
@@ -0,0 +1,54 @@
# AzerothCore live-stack e2e (AzerothGhost harness)
# Requires: running auth+world+MySQL and E2E_* env vars (see .env.example).
GO ?= go
TAGS ?= e2e
TIMEOUT ?= 30m
# In-package test concurrency. Keep 1: PackagePad is sticky per package; parallel
# tests in the same package share one pad and thrash each other.
PARALLEL ?= 1
# Package concurrency (go test -p). IsolationPads has 27 unique pads; more packages
# than pads hash-share locations. Default 1 = safe unique pad assignment always.
P ?= 1
COUNT ?= 1
GOTEST = $(GO) test -tags=$(TAGS) -count=$(COUNT) -timeout $(TIMEOUT) -p $(P)
.PHONY: e2e-smoke e2e-category e2e-sub e2e-issue e2e-tags e2e-full e2e-list e2e-local e2e-mod
e2e-mod:
$(GO) mod tidy
e2e-list:
$(GOTEST) -list . ./...
e2e-smoke:
$(GOTEST) -v -parallel $(PARALLEL) ./smoke/
# Suites tagged smoke only (open-issue suites must not carry the smoke tag).
E2E_TAGS=smoke $(GOTEST) -v -parallel $(PARALLEL) ./suites/...
e2e-category:
@test -n "$(C)" || (echo "usage: make e2e-category C=quests"; exit 1)
$(GOTEST) -v -parallel $(PARALLEL) ./suites/$(C)/...
e2e-sub:
@test -n "$(C)" || (echo "usage: make e2e-sub C=spells/aura"; exit 1)
$(GOTEST) -v -parallel $(PARALLEL) ./suites/$(C)/...
e2e-issue:
@test -n "$(N)" || (echo "usage: make e2e-issue N=26549"; exit 1)
# Prefer TestAC_<n>_* names; E2E_ISSUE also selects by meta.Issue.
E2E_ISSUE=$(N) $(GOTEST) -v -parallel 1 -run 'TestAC_$(N)_' ./suites/...
e2e-tags:
@test -n "$(TAGS_FILTER)" || (echo "usage: make e2e-tags TAGS_FILTER=smoke,short"; exit 1)
E2E_TAGS=$(TAGS_FILTER) $(GOTEST) -v -parallel $(PARALLEL) ./...
# Scratch/debug packages under local/ (gitignored). Does not run smoke/suites.
e2e-local:
$(GOTEST) -v -parallel 1 ./local/...
# Default 120m unless the caller set TIMEOUT= on the command line.
e2e-full: TIMEOUT = 120m
e2e-full:
$(GOTEST) -v -parallel $(PARALLEL) ./...
+361
View File
@@ -0,0 +1,361 @@
# AzerothCore live-stack e2e
Protocol-level regression tests for this repository. They import
[`github.com/azerothcore/AzerothGhost/e2e/e2eharness`](https://github.com/azerothcore/AzerothGhost)
and run against a **live** authserver + worldserver + MySQL.
Offline `go test ./...` (without `-tags=e2e`) skips these packages.
Authoring rules for new tests live in the harness:
| Doc | Audience |
|-----|----------|
| [LLM_GUIDE.md](https://github.com/azerothcore/AzerothGhost/blob/v1.0.8/e2e/LLM_GUIDE.md) | Compact MUST/NEVER + APIs (LLMs and humans) |
| [EXAMPLES.md](https://github.com/azerothcore/AzerothGhost/blob/v1.0.8/e2e/EXAMPLES.md) | Full recipes and skeletons |
| `.agents/docs/e2e-policy.md` | When to add e2e vs unit tests (agent/review policy) |
---
## Prerequisites
1. Running AzerothCore **3.3.5a** authserver + worldserver.
2. MySQL with `acore_auth`, `acore_characters`, and `acore_world` (world DB is required for spawn cleanup and many fixtures).
3. Go **1.26+** and network reachability to auth (default `127.0.0.1:3724`).
Accounts are created by the harness (GM level 3, password `test`). Do not reuse real player accounts.
### Local harness (optional)
For co-development against a local AzerothGhost checkout:
```bash
cd e2e
cp go.work.example go.work # gitignored; edit the replace path
# replace github.com/azerothcore/AzerothGhost => /path/to/AzerothGhost
```
`e2e/go.mod` pins `github.com/azerothcore/AzerothGhost v1.0.8` (see `go.sum`).
`go test` / `go mod download` fetch that module.
---
## Environment
Copy and adjust [`e2e/.env.example`](./.env.example). Stock AC and CI use `acore:acore`.
| Variable | Default (harness) | Meaning |
|----------|-------------------|---------|
| `E2E_AUTH_ADDR` | `127.0.0.1:3724` | Auth / realm-list address clients use |
| `E2E_AUTH_DSN` | `acore:acore@tcp(127.0.0.1:3306)/acore_auth` | Auth DB |
| `E2E_CHAR_DSN` | `acore:acore@tcp(127.0.0.1:3306)/acore_characters` | Characters DB |
| `E2E_WORLD_DSN` | `acore:acore@tcp(127.0.0.1:3306)/acore_world` | World DB (spawns, tele names, cleanup) |
Optional filters (`internal/meta`):
| Variable | Effect |
|----------|--------|
| `E2E_TAGS` | Comma list; test must include **all** listed tags (AND) |
| `E2E_SKIP_TAGS` | Skip if test has **any** listed tag |
| `E2E_ISSUE` | Run only tests with matching `TestMeta.Issue` |
| `E2E_RUNTIME` | Run only `short` / `med` / `long` |
| `E2E_ALLOW_SOFT_PASS` | **`1` only for local debug.** SoftPass is fail-closed by default |
Export vars in your shell (or `set -a; source .env; set +a`) before `go test`.
---
## How to run
**Recommended full suite** (pad-safe defaults: serial tests **and** packages):
```bash
cd e2e
# export E2E_* from .env.example first
go test -tags=e2e ./... -count=1 -v -timeout 120m -parallel 1 -p 1
# or:
make e2e-full
```
`-parallel 1` keeps in-package tests serial (`meta.Begin` is serial by default). `-p 1` runs one package at a time so IsolationPads never hash-share. Raising either without a unique preferred pad per concurrent package causes thrash.
### Make targets
```bash
make e2e-smoke # smoke/ + suites tagged smoke
make e2e-category C=quests # one category tree
make e2e-sub C=spells/aura # one leaf suite
make e2e-issue N=26549 # TestAC_26549_* anywhere
make e2e-tags TAGS_FILTER=smoke,short
make e2e-full # ./... long timeout
make e2e-list # list tests
```
Override parallel / timeout:
```bash
make e2e-full PARALLEL=1 P=1 TIMEOUT=120m
# P = go test -p (packages). Keep 1 unless every concurrent suite has a unique preferred pad.
```
### Single test / package
```bash
go test -tags=e2e ./suites/combat/pets -count=1 -v -timeout 30m -parallel 1
go test -tags=e2e ./suites/... -run TestPets_SummonWaitDismiss -count=1 -v -timeout 15m
```
---
## Layout
| Path | Role |
|------|------|
| `smoke/` | Fast login / tele / relog guards |
| `suites/<category>/…` | Hierarchical scenarios (directory = category) |
| `suites/<category>/` | Suites by domain; issue guards named `TestAC_<n>_…` live beside related tests |
| `local/` | **Scratch/debug only** — gitignored (except `local/README.md`); never commit throwaways |
| `internal/meta` | `TestMeta` + env tag filters + `Begin` (Parallel policy) |
| `internal/fixtures` | Re-exports pads / `PackagePad` for suites that prefer fixtures |
Every live test uses `//go:build e2e` and should call `meta.Begin(t, meta.TestMeta{…})` before expensive setup.
### Scratch / agent debug (`local/`)
When validating a fix on a **live** stack (player-visible combat, protocol, quests, multi-bot), prefer writing a small e2e under **`e2e/local/`** instead of ad-hoc GM spam or long manual checklists. That tree is **not committed**.
```bash
# create e.g. local/repro/repro_e2e_test.go (//go:build e2e)
make e2e-local
# or:
go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1
```
If the scenario should stay as a regression, **move** it into `suites/` next to related tests with proper `meta.Begin` tags — see `.agents/docs/e2e-policy.md`.
### Inventory
| Category | Oracle | Pri | Coverage | Issue |
|----------|--------|-----|----------|-------|
| smoke | login / pad tele / relog / world alive | P0 | covered (`TestSmoke_*`) | — |
| combat/charm | apply/cancel aura | P1 | covered; Yogg MC logout `blocked-harness` (no charm/MC drive) | #25506 |
| combat/death | die → ghost → release → reclaim | P1 | covered | — |
| combat/pets | summon / GUID / attack / dismiss | P1 | covered; dungeon Raise Dead `blocked-harness` (ready-check / instance summon) | #27081 |
| combat/threat | engage / taunt switch / kill clears combat | P1 | covered | — |
| combat/vehicles | spellclick steed enter/exit | P2 | covered | — |
| spells/aura | apply/query; CC broken by damage; mount persist | P1 | covered (`TestAC_26130_*`) | #26130 |
| spells/cast | Charge on dummy; fail path; stance; Raise Dead + ghoul | P1 | covered (`TestAC_27061_*`) | #27061 |
| spells/effects | Charge / grounding totem / Sweeping Strikes Execute | P1 | covered (`TestAC_26997_*`); dummy-summon `blocked-harness` (engineering dummy lifetime) | #26774 #26997 |
| social/group | form / leave / leader / loot method / disband | P2 | covered | — |
| social/loot | need/greed / master loot; below-half kill | P1 | covered (`TestAC_26862_*`); chest mid-roll `blocked-harness` (GO 194821 UseGameObject); pass-on-loot delete `blocked-harness` (item-survive after ALL_PASSED) | #26894 #26862 #22000 |
| social/trade | item+gold accept; cancel; walk-OOR TARGET_TO_FAR | P1 | covered | #25723 |
| quests/lifecycle | STAY_ALIVE fail on death; status after save/relog | P1 | covered (`TestAC_26549_*`) | #26549 |
| quests/escort | find spawned unit; follow-NPC despawns on logout | P2 | covered (`TestAC_24450_*`) | #24450 |
| items/equip | visible-item slot after EquipEntry; additem; survives relog | P2 | covered | — |
| protocol/session | pos; item/quest load; money save/relog | P1 | covered; GM vis persist `blocked-harness` (extra_flags after relog) | #25793 |
| protocol/teleport | cross-map; named; GoCreatureID | P1 | covered | — |
| 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/ulduar | named tele; Freya wave interval | P2 | covered (`TestAC_27095_*`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 |
---
## Parallelism and isolation
### Model
| Layer | Behaviour |
|-------|-----------|
| **Packages** | `go test -p N` runs packages concurrently. Default **`P=1`** in the Makefile — IsolationPads has 27 unique pads; more packages than pads **hash-share** and can thrash. Raise `P` only when every concurrent package has a unique preferred pad. |
| **Tests in a package** | `meta.Begin` is **serial by default** (no `t.Parallel`). Tag `parallel` only if pad-safe. Makefile `PARALLEL=1`. |
| **Pads** | `e2eharness.PackagePad(t)` is **sticky per suite folder** for the process lifetime. |
Use **`PackagePad`** for combat/social placement — not a shared Stormwind cell for every suite.
```go
pad := e2eharness.PackagePad(t)
bot.TeleportPad(t, pad)
// multi-bot:
e2eharness.FormPartyAtPad(t, pad, leader, mate)
e2eharness.TeleportAllPad(t, bots, pad)
```
`PadStormwindOutskirts` is a **legacy alias** of the AbandonHouse pad. Prefer `PackagePad(t)`.
### Isolation pads
Far-apart world locations (operator-captured). Combat-heavy packages have preferred 1:1 assignments; other packages take free pads or a stable hash share when the pool is exhausted.
| Pad name | Map | Notes |
|----------|-----|--------|
| `Tower1` | Eastern Kingdoms (0) | Preferred: `combat/threat` |
| `Tower2` | Eastern Kingdoms (0) | Preferred: `combat/death` |
| `AbandonHouse` | Eastern Kingdoms (0) | Preferred: `combat/pets`; legacy SW outskirts alias |
| `NagrandArena` | Outland (530) | Preferred: `combat/charm` |
| `FloatingIsland1` | Outland (530) | Preferred: `combat/vehicles` |
| `FloatingIsland2` | Outland (530) | Preferred: `social/loot` |
| `FloatingIsland3` | Outland (530) | Preferred: `social/group` |
| `InMountains1` | Kalimdor (1) | Preferred: `social/trade` |
| `InMountains2` | Kalimdor (1) | Preferred: `spells/cast` |
| `InMountains3` | Kalimdor (1) | Preferred: `spells/effects` |
| `ElwynnRidge` | Eastern Kingdoms (0) | Elwynn cliff |
| `BurningSteppes` | Eastern Kingdoms (0) | ~300y from ElwynnRidge |
| `Mulgore1` | Kalimdor (1) | Preferred: `quests/escort` (plains, spawn-safe) |
| `Mulgore3`–`Mulgore5`, `MulgoreNorth` | Kalimdor (1) | Mulgore / Thunder Bluff mesa |
| `Boulderslide` | Kalimdor (1) | Stonetalon Boulderslide Ravine |
| `Stonetalon1`, `Stonetalon2` | Kalimdor (1) | Stonetalon Mountains |
| `Talondeep` | Kalimdor (1) | Talondeep Path (near Ashenvale1) |
| `Ashenvale1` | Kalimdor (1) | Ashenvale |
| `FelwoodSouth`, `MorlosAran` | Kalimdor (1) | Felwood |
| `HyjalApproach`, `Hyjal1`, `Hyjal2` | Kalimdor (1) | Hyjal |
Unlisted suites (`smoke`, `spells/aura`, protocol, quests, items, instances, guild, …) receive the first free pad, then hash-share if needed. Logs include `PackagePad suite=… pad=…`.
**Do not** hardcode one shared coordinate for every new combat test. Content that *must* use a fixed world location (tabard designer, instance entrance, named tele) still may — then return to the package pad when possible.
---
## Cleanup rules
Live e2e mutates a real realm. Cleanup is mandatory.
### Persistent spawns (`.npc add` / `.gobject add`)
These write **DB rows**. Bare add without cleanup litters pads (e.g. Crimson Templar 15209, Gift of the Observer GO 194821).
| Do | Do not |
|----|--------|
| `bot.Spawn` / `SpawnKillLootable` / `SpawnGameObject` (register cleanup) | Bare `.npc add` / `.gobject add` |
| `DespawnCreatureSpawn` / `DespawnGameObjectSpawn` by **DB spawn id** | Assume process exit cleans world DB |
| Rely on SQL DELETE + soft live despawn (harness does both) | Only live delete after socket already closed |
Despawn path: **SQL DELETE always** (survives session close) + optional soft `.npc delete` / `.gobject delete` while the socket is open.
**Loot:** do not use `.npc add temp` for loot tests (`TEMPSUMMON_CORPSE_DESPAWN` removes the corpse; use `Spawn` / `SpawnKillLootable`).
### Pets, ghouls, guardians, totems
`NewScenario` / `NewSolo` register `CleanupOwnedSummons` on `t.Cleanup` (dismiss pet + despawn units with SUMMONEDBY/CREATEDBY = player).
After Raise Dead / heavy summon use, also call while still InWorld:
```go
bot.CleanupOwnedSummons(t)
```
### SoftPass (fail-closed)
`SoftPass` / `SoftPassf` **fail the test by default** so unjudgeable fixtures cannot greenwash CI. Only local debug:
```bash
E2E_ALLOW_SOFT_PASS=1 go test -tags=e2e …
```
Prefer `Preconditionf` (setup blocked), `Assertf` / `ConfirmedBugf` (oracle), or `HarnessFailf` (infra).
---
## Writing a test (short)
```go
//go:build e2e
package pets_test
import (
"testing"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
func TestPets_Example(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "combat", "pets"},
Runtime: "med",
Category: "combat/pets",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "PetEx",
Class: e2eharness.ClassWarlock,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
// setup (GM ok) → CombatReady before pulls → drive → assert
t.Logf("PASS …")
}
```
Checklist:
1. `//go:build e2e` + MySQL blank import + `e2eharness`.
2. `meta.Begin` (tag filters; **serial by default** — tag `parallel` only if pad-safe).
3. Unique short `Prefix` (**≤ 7 chars**: account = Prefix + 2 digits + 8 hex, auth max 17);
place with **`PackagePad`**.
4. Flow: fixture → place → setup → `CombatReady` if pull → drive → assert.
5. Waiters (**Arm → Send → Wait**), not fixed long sleeps.
6. Severity helpers for fatals; quest DB only after `Save`.
7. Spawn helpers with cleanup; summons cleaned via `CleanupOwnedSummons`.
Full API surface: harness `LLM_GUIDE.md` / `EXAMPLES.md`.
---
## Common flakes and fixes
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| NPC ignores bot / no aggro | GM mode still on | `CombatReady` / `CombatReadyFull` before pull |
| Boss resets mid-fight | `.gm on` for `.damage` | `Damage` / `DamageKill` (mode off) |
| Melee out of range after `.tele` | Named tele short of target | `GoCreatureID` after `TeleNamed` |
| Unit not in cache after tele | Cache clear on transfer | Re-`WaitUnit` after tele / `WaitInWorld` |
| Random combat on pad | Leftover spawns / pets / other package thrash | Cleanup helpers; `-parallel 1`; PackagePad |
| Cast fails “in combat” on empty pad | Contested pad / leftover temp | `CombatStop`; despawn litter; unique pad |
| Loot roll never starts | Wrong creature or threshold | `CreatureGroupLootFixture` (15209) + `LootThresholdUncommon` |
| Quest status wrong in DB | Asserted before save | `Save` / `QuestStatusAfterSave` |
| GM commands silently ignored | Wrong race language | Set `Race` on bot (harness uses racial language) |
| `SOFT-PASS disabled` fatal | SoftPass without opt-in | Use real severity helpers, or `E2E_ALLOW_SOFT_PASS=1` locally only |
| `precondition:` spam | Stack/DB/env mismatch | Check `E2E_*`, world up, DSN DB names |
| Intermittent pass without code change | Sleeps / races | Replace sleeps with waiters; fix Arm→Send→Wait |
A test that fails intermittently on a **correct** core is a test/harness bug until proven otherwise. Do not hide flakes with longer sleeps or SoftPass.
---
## Severity markers
| Helper | Meaning |
|--------|---------|
| `Preconditionf` | Setup never reached a judgeable state |
| `ConfirmedBugf(t, issue, …)` | Core wrong for tracked AC issue/PR |
| `HarnessFailf` | Infra: timeout, SQL, empty cache, send error |
| `Assertf` / `AssertBugf` | Post-drive product oracle |
| `SoftWarnf` | Non-fatal note |
| `SoftPass` | **Fail-closed** unless `E2E_ALLOW_SOFT_PASS=1` |
---
## Policy (when to add e2e)
Use `.agents/docs/e2e-policy.md` for decision trees (e2e vs unit, mandatory triggers, MVT). This README is **how to run and structure** the suite; the harness guides are **how to author** scenarios.
---
## CI
Details live in the workflow files only:
[`.github/workflows/e2e-live.yml`](../.github/workflows/e2e-live.yml),
[`.github/workflows/core-build-nopch.yml`](../.github/workflows/core-build-nopch.yml).
| How | Effect |
|-----|--------|
| Non-draft PR in **azerothcore/azerothcore-wotlk** | nopch `ubuntu-24.04`/clang-18 compiles + dry-run, then **full** e2e reuses those binaries |
| Merge to `master` | same clang-18 nopch build, then **full** e2e again (flake + merge-base drift) |
| Actions → **e2e-live** → Run workflow (official repo; needs workflow on default branch, or `gh workflow run … --ref e2e`) | Compiles on the runner; choose scope (smoke/full) |
Day-to-day development and agent debugging should use a **local** stack + `e2e/local/` or the committed suites — not CI setup docs.
Greppable failure prefixes: `precondition:`, `AC#N CONFIRMED BUG:`, `harness:`, `WARNING:`.
+14
View File
@@ -0,0 +1,14 @@
module github.com/azerothcore/azerothcore-wotlk/e2e
go 1.26.0
require (
github.com/azerothcore/AzerothGhost v1.0.8
github.com/go-sql-driver/mysql v1.10.0
)
require filippo.io/edwards25519 v1.2.0 // indirect
// Pinned to azerothcore/AzerothGhost v1.0.8 == d2e0dee
// (module github.com/azerothcore/AzerothGhost; Go 1.26). Immutable tag — do not force-move.
// Local co-dev: go.work (gitignored). CI: go mod download from this pin.
+6
View File
@@ -0,0 +1,6 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/azerothcore/AzerothGhost v1.0.8 h1:yKyJgLSGjqey4piydJn50apahHW+oUHgV1XCUDr7UiI=
github.com/azerothcore/AzerothGhost v1.0.8/go.mod h1:n5VudLK9mOqc+fIY9dlOf4njrwSMICR9hKr4SsUia6A=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
+11
View File
@@ -0,0 +1,11 @@
go 1.26.0
use .
// Point at a local AzerothGhost checkout that contains e2e/e2eharness
// (https://github.com/azerothcore/AzerothGhost). Copy to go.work (gitignored)
// and adjust the path.
//
// cp go.work.example go.work
//
replace github.com/azerothcore/AzerothGhost => /path/to/AzerothGhost
+24
View File
@@ -0,0 +1,24 @@
// Package fixtures holds AC-specific constants shared across e2e suites.
package fixtures
import "github.com/azerothcore/AzerothGhost/e2e/e2eharness"
// Re-export isolation pads so suites can depend on internal/fixtures.
var (
// PadStormwindOutskirts is a legacy alias (AbandonHouse). Prefer PackagePad.
PadStormwindOutskirts = e2eharness.PadStormwindOutskirts
IsolationPads = e2eharness.IsolationPads
PreferredPackagePads = e2eharness.PreferredPackagePads
)
// PackagePad is sticky per suite folder — use for all combat/social pad placement.
var PackagePad = e2eharness.PackagePad
const (
MapEasternKingdoms = e2eharness.MapEasternKingdoms
MapKalimdor = e2eharness.MapKalimdor
MapOutland = e2eharness.MapOutland
MapNorthrend = e2eharness.MapNorthrend
MapUlduar = e2eharness.MapUlduar
)
+113
View File
@@ -0,0 +1,113 @@
// Package meta provides TestMeta gating for AC e2e category/tag filters.
package meta
import (
"os"
"strconv"
"strings"
"testing"
)
// TestMeta describes cross-cutting filters for a live e2e test.
// Call Gate at the start of every test (before expensive setup).
type TestMeta struct {
// Tags are comma-free tokens, e.g. "smoke", "short", "multi_bot", "issue", "parallel".
// Live e2e defaults to serial (no t.Parallel) so in-package tests do not thrash
// one PackagePad. Opt in with tag "parallel" only when pad-safe.
Tags []string
// Issue is an optional AC GitHub issue number (0 = none).
Issue int
// Category is a path-like label, e.g. "spells/aura" (informational; directory is primary).
Category string
// Runtime: short | med | long
Runtime string
}
// Gate skips the test when env filters exclude it.
// Prefer Begin(t, m) which also enforces parallel policy (serial by default).
//
// Env:
//
// E2E_TAGS — comma list; test must include ALL listed tags (AND)
// E2E_SKIP_TAGS — comma list; test skipped if it has ANY listed tag
// E2E_ISSUE — if set, only tests with matching Issue run
// E2E_RUNTIME — if set, only tests with matching Runtime run
func Gate(t *testing.T, m TestMeta) {
t.Helper()
if want := strings.TrimSpace(os.Getenv("E2E_ISSUE")); want != "" {
n, err := strconv.Atoi(want)
if err != nil {
t.Fatalf("meta: invalid E2E_ISSUE=%q: %v", want, err)
}
if m.Issue != n {
t.Skipf("meta: E2E_ISSUE=%d, test issue=%d", n, m.Issue)
}
}
if want := strings.TrimSpace(os.Getenv("E2E_RUNTIME")); want != "" {
if !strings.EqualFold(m.Runtime, want) {
t.Skipf("meta: E2E_RUNTIME=%s, test runtime=%s", want, m.Runtime)
}
}
have := tagSet(m.Tags)
if raw := strings.TrimSpace(os.Getenv("E2E_SKIP_TAGS")); raw != "" {
for _, tag := range splitCSV(raw) {
if _, ok := have[tag]; ok {
t.Skipf("meta: skip tag %q matched", tag)
}
}
}
if raw := strings.TrimSpace(os.Getenv("E2E_TAGS")); raw != "" {
for _, tag := range splitCSV(raw) {
if _, ok := have[tag]; !ok {
t.Skipf("meta: required tag %q missing (have %v)", tag, m.Tags)
}
}
}
}
// HasTag reports whether m.Tags contains tag (case-insensitive).
func HasTag(m TestMeta, tag string) bool {
_, ok := tagSet(m.Tags)[strings.ToLower(strings.TrimSpace(tag))]
return ok
}
// Begin runs Gate then optionally t.Parallel().
// Default is serial: PackagePad is sticky per package, so in-package parallel
// would co-locate bots on the same pad. Tag "parallel" to opt in (use only when
// tests do not share pad placement, or with go test -parallel 1 which no-ops Parallel).
// Tag "serial" is accepted as an explicit no-op for readability.
func Begin(t *testing.T, m TestMeta) {
t.Helper()
Gate(t, m)
if HasTag(m, "parallel") && !HasTag(m, "serial") {
t.Parallel()
}
}
func tagSet(tags []string) map[string]struct{} {
out := make(map[string]struct{}, len(tags))
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if t != "" {
out[t] = struct{}{}
}
}
return out
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.ToLower(strings.TrimSpace(p))
if p != "" {
out = append(out, p)
}
}
return out
}
+45
View File
@@ -0,0 +1,45 @@
package meta
import (
"testing"
)
func TestGate_NoEnvRuns(t *testing.T) {
for _, key := range []string{"E2E_ISSUE", "E2E_RUNTIME", "E2E_SKIP_TAGS", "E2E_TAGS"} {
t.Setenv(key, "")
}
// Should not skip when no filters set.
Gate(t, TestMeta{Tags: []string{"smoke", "short"}, Runtime: "short"})
}
func TestSplitCSV(t *testing.T) {
got := splitCSV(" smoke, Short , ")
if len(got) != 2 || got[0] != "smoke" || got[1] != "short" {
t.Fatalf("got %#v", got)
}
}
func TestHasTag_Serial(t *testing.T) {
m := TestMeta{Tags: []string{"med", "serial", "loot"}}
if !HasTag(m, "serial") {
t.Fatal("expected serial tag")
}
if HasTag(m, "smoke") {
t.Fatal("unexpected smoke tag")
}
// Case-insensitive
if !HasTag(m, "SERIAL") {
t.Fatal("expected case-insensitive serial match")
}
}
// TestBegin_DefaultSerial documents that Begin does not call t.Parallel by default.
func TestBegin_DefaultSerial(t *testing.T) {
Begin(t, TestMeta{Tags: []string{"unit", "serial"}, Runtime: "short"})
// Reaching here means Gate ran and default serial path did not panic.
}
// TestBegin_ParallelOptIn allows t.Parallel only when tagged parallel (not serial).
func TestBegin_ParallelOptIn(t *testing.T) {
Begin(t, TestMeta{Tags: []string{"unit", "parallel"}, Runtime: "short"})
}
+63
View File
@@ -0,0 +1,63 @@
# e2e/local — scratch / debug tests (not committed)
Put **throwaway** live-stack tests here while debugging a core/script change.
Everything under this directory is **gitignored** except this README.
## When to use
| Put it here | Promote to |
|-------------|------------|
| One-off repro while fixing a bug | `suites/…` or `suites/issues/` once it should stay green |
| Agent exploratory scenario | Same — only keep if policy says e2e is warranted |
| Experiments with harness APIs | Drop or move when done |
Do **not** put permanent regressions here; CI and reviewers only look at `smoke/` and `suites/`.
## How to run
From `e2e/` with stack up and `E2E_*` set (see parent [README.md](../README.md)):
```bash
# all scratch packages
go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1
# one file’s package (create local/foo/foo_e2e_test.go)
go test -tags=e2e ./local/foo -count=1 -v -timeout 15m -parallel 1
```
From the repository root: `make -C e2e e2e-local`. From `e2e/`: `make e2e-local`.
## Minimal skeleton
```go
//go:build e2e
package local_debug_test
import (
"testing"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
func TestLocal_Scratch(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"local", "short"},
Runtime: "short",
Category: "local",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "Loc",
Class: e2eharness.ClassWarrior,
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
// … drive and assert …
t.Logf("PASS local scratch")
}
```
Authoring rules: AzerothGhost `e2e/LLM_GUIDE.md`. When to keep e2e long-term: `.agents/docs/e2e-policy.md`.
+111
View File
@@ -0,0 +1,111 @@
//go:build e2e
package smoke_test
import (
"math"
"testing"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// SMOKE-07 first: fresh-account create (run early — late runs saw flaky auth EOF
// after several login/relog cycles against a cold native stack).
func TestSmoke_FirstTimeCharacterCreate(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"smoke", "short", "protocol", "serial"}, Runtime: "short", Category: "smoke"})
// Prefix must stay short: auth rejects account names >17 chars (closes with EOF).
// MakeBotIdents builds Prefix + 2-digit index + 8 hex = Prefix+10; keep Prefix <= 7.
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SmCre",
Race: e2eharness.RaceHuman,
Class: e2eharness.ClassWarrior,
Level: 1,
})
if bot.GUID == 0 {
e2eharness.Preconditionf(t, "character create/login produced GUID 0")
}
if !e2eharness.SessionAlive(bot.Session) {
e2eharness.HarnessFailf(t, "session dead after character create")
}
t.Logf("PASS first-time create guid=%d name=%s", bot.GUID, bot.Name)
}
// SMOKE-01: valid login → enter world
func TestSmoke_ValidLoginEnterWorld(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"smoke", "short", "protocol", "serial"}, Runtime: "short", Category: "smoke"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SmLogin",
Level: 10,
})
if !e2eharness.SessionAlive(bot.Session) {
e2eharness.HarnessFailf(t, "session not alive after NewSolo")
}
if bot.GUID == 0 {
e2eharness.Preconditionf(t, "player GUID is 0 after enter world")
}
x, y, z, mapID := bot.Pos()
if math.IsNaN(float64(x)) || math.IsNaN(float64(y)) || math.IsNaN(float64(z)) {
e2eharness.HarnessFailf(t, "invalid position after login")
}
t.Logf("PASS login enter world guid=%d map=%d pos=(%.1f,%.1f,%.1f)", bot.GUID, mapID, x, y, z)
}
// SMOKE-02: probe world alive after login
func TestSmoke_ProbeWorldAliveAfterLogin(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"smoke", "short", "serial"}, Runtime: "short", Category: "smoke"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SmProbe",
Level: 10,
})
bot.AssertWorldAlive(t)
bot.GM(t, ".gm on")
t.Logf("PASS world alive after login")
}
// SMOKE-03: teleport pad success
func TestSmoke_TeleportPadSuccess(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"smoke", "short", "protocol", "serial"}, Runtime: "short", Category: "smoke"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SmTele",
Level: 80,
})
pad := e2eharness.PackagePad(t)
bot.TeleportPad(t, pad)
x, y, z, mapID := bot.Pos()
if mapID != pad.Map {
e2eharness.Preconditionf(t, "expected pad map %d after tele, got %d", pad.Map, mapID)
}
if math.IsNaN(float64(x)) || math.IsNaN(float64(y)) || math.IsNaN(float64(z)) {
e2eharness.HarnessFailf(t, "invalid pos after pad tele")
}
if !bot.Alive() {
e2eharness.HarnessFailf(t, "session/player not alive after pad tele")
}
t.Logf("PASS pad tele map=%d pos=(%.1f,%.1f,%.1f)", mapID, x, y, z)
}
// SMOKE-05: relog same character, world still alive
func TestSmoke_RelogSameCharacterWorldAlive(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"smoke", "short", "protocol", "serial"}, Runtime: "short", Category: "smoke"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SmRelog",
Level: 20,
})
guidBefore := bot.GUID
bot.Save(t)
bot.Relog(t)
// Relog already WaitForLogin; AssertWorldAlive probes the fresh session.
bot.AssertWorldAlive(t)
if bot.GUID != guidBefore {
e2eharness.HarnessFailf(t, "expected GUID %d after relog, got %d", guidBefore, bot.GUID)
}
t.Logf("PASS relog world alive guid=%d", bot.GUID)
}
+165
View File
@@ -0,0 +1,165 @@
//go:build e2e
package charm_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// Battle Shout ranks (player-cast, cancelable). Do not use GM .aura / 45614 Blending In
// (SPELL_ATTR0_NO_AURA_CANCEL → CMSG_CANCEL_AURA is a silent no-op).
var battleShoutRanks = []uint32{
47436, // rank 9 (WotLK)
47434, 25289, 11551, 11550, 11549, 6192, 5242, 6673,
}
func firstBattleShoutAura(bot *e2eharness.ScenarioBot) uint32 {
for _, id := range battleShoutRanks {
if bot.HasAura(id) {
return id
}
}
return 0
}
// CHARM-01: client-cast a cancelable buff then CMSG_CANCEL_AURA must remove it.
func TestCharm_ApplyAndCancelAuraOnSelf(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat"}, Runtime: "short", Category: "combat/charm"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "CharmAu",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
// Rage for Battle Shout (SPELL_FAILED_NO_POWER without it).
bot.GM(t, ".cheat power on")
bot.FlushWorld(t)
// Prefer highest rank first (learn-all knows it).
castID := battleShoutRanks[0]
bot.Learn(t, castID)
bot.CastMust(t, castID, 0, 10*time.Second)
deadline := time.Now().Add(3 * time.Second)
var auraID uint32
for time.Now().Before(deadline) {
auraID = firstBattleShoutAura(bot)
if auraID != 0 {
break
}
time.Sleep(40 * time.Millisecond)
}
if auraID == 0 {
e2eharness.Preconditionf(t, "no Battle Shout aura after cast %d", castID)
}
bot.CancelAura(t, auraID)
// Product oracle: CMSG_CANCEL_AURA must remove a cancelable positive aura.
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if firstBattleShoutAura(bot) == 0 {
break
}
time.Sleep(40 * time.Millisecond)
}
if still := firstBattleShoutAura(bot); still != 0 {
e2eharness.Assertf(t, "aura %d still present after CancelAura", still)
}
bot.AssertWorldAlive(t)
t.Logf("PASS apply/cancel aura %d removed", auraID)
}
// OPEN(e2e): re-enable when AC#25506 is fixed — Yogg-Saron mind-control disconnect must not crash.
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/25506
// These bodies were not a Yogg MC repro (Blending In + hard drop). Keep commented until a real charm path exists.
/*
func TestCharm_LogoutWhileAuraWorldAlive(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "combat", "issue", "serial"},
Runtime: "med",
Issue: 25506,
Category: "combat/charm",
})
probe := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "CharmPr", Level: 10})
victim := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "CharmVc", Level: 80})
victim.TeleportPad(t, e2eharness.PackagePad(t))
victim.ApplyAura(t, e2eharness.SpellBlendingInAura)
victim.Save(t)
e2eharness.HardDisconnectAndProbe(t, victim, probe, 25506)
t.Logf("PASS logout while aura world alive")
}
func TestCharm_HardDropWhileAuraNoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "combat", "issue", "serial"},
Runtime: "med",
Issue: 25506,
Category: "combat/charm",
})
probe := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "CharmHd", Level: 10})
victim := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "CharmHv", Level: 80})
victim.ApplyAura(t, e2eharness.SpellBlendingInAura)
e2eharness.HardDisconnectAndProbe(t, victim, probe, 25506)
t.Logf("PASS hard drop while aura no crash")
}
*/
// CHARM-04: multi-bot — one applies aura, other probes after victim leave.
func TestCharm_MultiBotProbeAfterVictimLeave(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "combat", "multi_bot", "serial"}, Runtime: "med", Category: "combat/charm"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "CharmMb", Count: 2, Level: 80})
a, b := bots[0], bots[1]
a.ApplyAura(t, e2eharness.SpellBlendingInAura)
a.LeaveGroup(t) // no-op if not grouped
e2eharness.HardDisconnectAndProbe(t, a, b, 0)
t.Logf("PASS multi-bot probe after victim leave")
}
// CHARM-05: CancelCast helper path — start a self-channel, then CancelCastWhenChanneling.
// Uses Hellfire (self-channel) instead of ground-targeted Rain of Fire: DEST casts flake on
// a noisy pad / after tele, and rank-1 RoF is often replaced after `.learn all my class`.
func TestCharm_CancelCastSafe(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/charm"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "CharmCc",
Class: e2eharness.ClassWarlock,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
// gm off + mana for a real channel (account GM still allows .modify).
e2eharness.CombatReady(t, bot.World, e2eharness.CombatReadyOpts{God: false, Power: true})
// Prefer max-rank Hellfire; fall back to rank 1. Match any channel spell (rank rewrite).
channelSpells := []uint32{e2eharness.SpellHellfireMax, e2eharness.SpellHellfire}
var canceled bool
for attempt := 0; attempt < 3 && !canceled; attempt++ {
spell := channelSpells[attempt%len(channelSpells)]
if err := bot.World.CastSpell(spell, bot.GUID); err != nil {
e2eharness.HarnessFailf(t, "CastSpell hellfire %d: %v", spell, err)
}
// spellID 0 = any channel (server may channel a different rank than cast id).
canceled = bot.CancelCastWhenChanneling(t, 0, 4*time.Second)
if !canceled {
bot.CancelCast(t)
// Top up mana and retry.
bot.GM(t, ".modify mana 999999")
bot.FlushWorld(t)
}
}
if !canceled {
e2eharness.Preconditionf(t, "CancelCast path: no channel observed after Hellfire retries (channel=%d)", bot.ChannelSpell())
return
}
bot.AssertWorldAlive(t)
t.Logf("PASS cancel cast path channeling=%v canceled=%v channel_spell=%d", bot.IsChanneling(), canceled, bot.ChannelSpell())
}
+136
View File
@@ -0,0 +1,136 @@
//go:build e2e
package death_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/client"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
const (
// Ghost auras commonly applied on player death (WotLK).
spellGhost = 8326
spellGhostNight = 20584
)
// DEATH-01 / CB-07: die → corpse path; ghost auras present before release.
func TestDeath_DieProducesGhostState(t *testing.T) {
// serial: concurrent .die on the same pad flakes (selection / GM thrash).
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/death"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "DieGh",
Level: 30,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.DieMust(t, 20*time.Second)
// Alive() is session usability, not player health. Oracle is Health()==0.
if hp := bot.World.Health(); hp != 0 {
e2eharness.Assertf(t, "player hp=%d after DieMust (want 0)", hp)
}
hasGhost := bot.HasAura(spellGhost) || bot.HasAura(spellGhostNight)
if !hasGhost {
// Ghost form aura is commonly applied on release, not on the corpse body.
t.Logf("NOTE: no ghost aura yet before release (hp=%d session_alive=%v)",
bot.World.Health(), bot.Alive())
}
t.Logf("PASS die → dead state (ghost_aura=%v hp=%d)", hasGhost, bot.World.Health())
}
// DEATH-02: release spirit reaches graveyard path (session stays usable).
func TestDeath_ReleaseSpiritWorldAlive(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/death"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "DieRel",
Level: 30,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.DieMust(t, 20*time.Second)
bot.ReleaseSpirit(t)
bot.AssertWorldAlive(t)
x, y, z, m := bot.Pos()
t.Logf("PASS DieAndRepop world alive pos=(%.0f,%.0f,%.0f map=%d)", x, y, z, m)
}
// DEATH-03: DieAndRepop full cycle + save.
func TestDeath_DieAndRepopCycle(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/death"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "DieCyc",
Level: 40,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.DieMust(t, 20*time.Second)
bot.ReleaseSpirit(t)
bot.AssertWorldAlive(t)
bot.Save(t)
t.Logf("PASS die+repop cycle")
}
// DEATH-05: death must not crash the worldserver.
func TestDeath_DeathDoesNotCrashWorld(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/death"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "DieOk",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.DieMust(t, 20*time.Second)
bot.ReleaseSpirit(t)
bot.AssertWorldAlive(t)
t.Logf("PASS death did not crash world")
}
// DEATH-04: reclaim corpse after death.
// Protocol: Die → capture corpse pos → ReleaseSpirit → wait SMSG_CORPSE_RECLAIM_DELAY
// → .go to corpse (InWorld + within 39yd) → CMSG_RECLAIM_CORPSE.
// Note: .die is Unit::Kill(self,self) → PvP corpse → server delay (often 30s) when
// Death.CorpseReclaimDelay.PvP=1; we wait the packet, not a guessed sleep.
func TestDeath_ReclaimCorpseAfterDeath(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"combat", "med", "serial"}, Runtime: "med", Category: "combat/death"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "DieRec",
Level: 30,
})
// Quiet death setup: combatstop before die (pad thrash); still PvP corpse from .die self-kill.
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.GM(t, ".combatstop")
bot.DieMust(t, 25*time.Second)
// Corpse sits at death position (capture after Die, before graveyard tele).
deathX, deathY, deathZ, deathMap := bot.Pos()
bot.ReleaseSpirit(t)
bot.AssertWorldAlive(t)
if err := bot.World.WaitForSessionPhase(client.PhaseInWorld, 10*time.Second); err != nil {
t.Logf("post-repop WaitInWorld: %v", err)
}
// Soft: ghost aura often applied on release (not required for reclaim opcode).
deadlineGhost := time.Now().Add(3 * time.Second)
for time.Now().Before(deadlineGhost) {
if bot.HasAura(spellGhost) || bot.HasAura(spellGhostNight) {
break
}
time.Sleep(40 * time.Millisecond)
}
// Wait server delay, then tele+reclaim (see ReclaimCorpseMust).
bot.ReclaimCorpseMust(t, deathX, deathY, deathZ, deathMap, 45*time.Second)
if bot.World.Health() == 0 {
e2eharness.Assertf(t, "reclaim finished still dead hp=%d delay_ms=%d",
bot.World.Health(), bot.World.CorpseReclaimDelayMs())
}
bot.AssertWorldAlive(t)
t.Logf("PASS reclaim corpse → alive (hp=%d delay_ms=%d)", bot.World.Health(), bot.World.CorpseReclaimDelayMs())
}
+151
View File
@@ -0,0 +1,151 @@
//go:build e2e
package pets_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
const spellSummonImp = 688
// summonWarlockPet learns Summon Imp, clears combat, casts (client then GM), waits for pet.
// Pad can be contested by leftover temp NPCs; combatstop is required for cast success.
func summonWarlockPet(t *testing.T, bot *e2eharness.ScenarioBot) uint64 {
t.Helper()
bot.CombatStop(t)
bot.Learn(t, spellSummonImp)
_ = bot.CastOrGM(t, spellSummonImp, 0, 20*time.Second)
// WaitPlayerPet covers UNIT_FIELD_SUMMON and SUMMONEDBY/CREATEDBY fallback.
return bot.WaitPlayerPet(t, 25*time.Second)
}
// PET-01: Warlock summon via learn-all + cast; wait player pet; dismiss.
func TestPets_SummonWaitDismiss(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "combat", "pets"}, Runtime: "med", Category: "combat/pets"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "PetSum",
Class: e2eharness.ClassWarlock,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.CombatReady(t)
pet := summonWarlockPet(t, bot)
if pet == 0 {
e2eharness.Preconditionf(t, "no player pet after summon imp")
}
bot.DismissPet(t, pet)
bot.WaitNoPlayerPet(t, 20*time.Second)
bot.AssertWorldAlive(t)
t.Logf("PASS summon/dismiss pet guid=0x%X", pet)
}
// PET-02: pet present after summon; world alive.
// WaitPlayerPet accepts UNIT_FIELD_SUMMON or SUMMONEDBY/CREATEDBY fallback — assert the
// waiter result, not only the field (imp summon often lags UNIT_FIELD_SUMMON updates).
func TestPets_PlayerPetGUIDAfterSummon(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "combat", "pets"}, Runtime: "med", Category: "combat/pets"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "PetGUID",
Class: e2eharness.ClassWarlock,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.CombatReady(t)
pet := summonWarlockPet(t, bot)
if pet == 0 {
e2eharness.Preconditionf(t, "no pet after WaitPlayerPet")
}
bot.CleanupOwnedSummons(t)
bot.AssertWorldAlive(t)
t.Logf("PASS pet=0x%X field_summon=0x%X", pet, bot.PlayerPetGUID())
}
// OPEN(e2e): re-enable when AC#27081 is fixed
// https://github.com/azerothcore/azerothcore-wotlk/issues/27081
// Must CastMust + PlayerPetGUID!=0; success=%v log is a soft-pass.
/*
func TestPets_DKRaiseDeadOpenWorld(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "combat", "pets", "issue"},
Runtime: "med",
Issue: 27081,
Category: "combat/pets",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "PetDK",
Class: e2eharness.ClassDeathKnight,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.AddItem(t, e2eharness.ItemCorpseDust, 5)
bot.Learn(t, e2eharness.SpellRaiseDead)
bot.CombatReady(t)
res := bot.Cast(t, e2eharness.SpellRaiseDead, 0, 15*time.Second)
bot.AssertWorldAlive(t)
if pet := bot.PlayerPetGUID(); pet != 0 {
bot.DismissPet(t, pet)
bot.WaitNoPlayerPet(t, 8*time.Second)
}
bot.CleanupOwnedSummons(t)
t.Logf("PASS Raise Dead open world success=%v pet=0x%X", res.Success, bot.PlayerPetGUID())
}
*/
// PET-04: pet attack command does not crash.
func TestPets_PetAttackCommand(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "combat", "pets"}, Runtime: "med", Category: "combat/pets"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "PetAtk",
Class: e2eharness.ClassWarlock,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.CombatReady(t)
pet := summonWarlockPet(t, bot)
if pet == 0 {
e2eharness.Preconditionf(t, "no pet for attack command")
}
dummy := bot.Spawn(t, e2eharness.CreatureTargetDummy, 15*time.Second)
// CombatReady again after GM spawn path.
bot.CombatReady(t)
bot.PetAttack(t, dummy)
bot.CleanupOwnedSummons(t)
bot.AssertWorldAlive(t)
t.Logf("PASS pet attack command pet=0x%X", pet)
}
// PET-05: dismiss clears pet.
func TestPets_DismissClearsPet(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "combat", "pets"}, Runtime: "med", Category: "combat/pets"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "PetDis",
Class: e2eharness.ClassWarlock,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.CombatReady(t)
pet := summonWarlockPet(t, bot)
if pet == 0 {
e2eharness.Preconditionf(t, "no pet to dismiss")
}
bot.DismissPet(t, pet)
bot.WaitNoPlayerPet(t, 20*time.Second)
bot.AssertNoPlayerPet(t)
t.Logf("PASS dismiss clears pet")
}
+195
View File
@@ -0,0 +1,195 @@
//go:build e2e
package threat_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// pullDummy is a high-HP training dummy for kill/engage-without-target oracles.
// L1 Target Dummy (2673) oneshots under L80 autoattack before combat is observed.
const pullDummy = e2eharness.CreatureHeroicTrainingDummy
// pullHostile is a real combat AI NPC (Crimson Templar). Training dummies never set
// UNIT_FIELD_TARGET, so target/taunt oracles must use a creature with a threat table.
const pullHostile = e2eharness.CreatureGroupLootFixture // 15209
// THREAT-01: engage hostile → unit in combat + targets player.
func TestThreat_EngageSetsTarget(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat"}, Runtime: "short", Category: "combat/threat"})
// L50 vs L60 templar: low player damage (no oneshot), god keeps player alive.
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "ThrEng",
Class: e2eharness.ClassWarrior,
Level: 50,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
mob := bot.Spawn(t, pullHostile, 15*time.Second)
bot.CombatReady(t)
bot.Engage(t, mob, 15*time.Second)
bot.WaitUnitCombat(t, mob, 15*time.Second)
// Product oracle: real AI must target the attacker after pull.
bot.WaitUnitTarget(t, mob, bot.GUID, 15*time.Second)
bot.AssertUnitTarget(t, mob, bot.GUID)
t.Logf("PASS engage combat target=0x%X player=0x%X", bot.UnitTarget(mob), bot.GUID)
}
// THREAT-02: two bots; Taunt switches UNIT_FIELD_TARGET to the tank.
//
// WotLK 3.3.5a (wowhead/evowow spell 355): Taunt is instant, 30 yd, **Requires Defensive Stance**
// (stance form 2 / spell 71) — NOT Battle Stance (2457). Casting Taunt in Battle Stance
// correctly returns SPELL_FAILED_ONLY_SHAPESHIFT (94). Correct tank flow:
//
// Defensive Stance → DPS pulls so mob is not already on tank → Taunt.
func TestThreat_TauntSwitchesTarget(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "combat", "multi_bot"}, Runtime: "med", Category: "combat/threat"})
// L80 so Taunt (physical) does not miss a scaled L80 mob (L12 vs L80 miss is near-certain).
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "ThrTnt",
Bots: []e2eharness.BotSpec{
{Role: "tank", Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
{Role: "dps", Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
},
})
tank := e2eharness.ByRole(t, bots, "tank")
dps := e2eharness.ByRole(t, bots, "dps")
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAll(t, bots, pad.X, pad.Y, pad.Z, pad.Map)
for _, b := range bots {
b.GM(t, ".combatstop")
b.GM(t, ".cheat god on")
b.GM(t, ".cheat power on")
}
// Real threat AI (not training dummy). Crimson Templar L60 works for engage+taunt at L80.
tank.DespawnNearbyEntry(t, pullHostile, 80)
mob := tank.Spawn(t, pullHostile, 15*time.Second)
tx, ty, tz, tm := tank.Pos()
dps.Teleport(t, tx+1, ty, tz, tm)
if dps.World.GetObject(mob) == nil {
seen := dps.WaitUnit(t, pullHostile, 15*time.Second)
if seen != 0 {
mob = seen
}
}
dps.CombatReady(t)
tank.CombatReady(t)
tank.GM(t, ".cheat power on")
tank.FlushWorld(t)
// Explicit learn — Defensive Stance / Taunt come from the L10 warrior quest, not only learn-all.
tank.Learn(t, e2eharness.SpellDefensiveStance)
tank.Learn(t, e2eharness.SpellTaunt)
// WotLK: Taunt Forms = Defensive Stance only (spell 71).
tank.CastMust(t, e2eharness.SpellDefensiveStance, 0, 10*time.Second)
stanceDeadline := time.Now().Add(5 * time.Second)
for time.Now().Before(stanceDeadline) {
if tank.HasAura(e2eharness.SpellDefensiveStance) {
break
}
time.Sleep(40 * time.Millisecond)
}
if !tank.HasAura(e2eharness.SpellDefensiveStance) {
e2eharness.Preconditionf(t, "tank missing Defensive Stance aura before Taunt")
}
// Soft pull: mob must be attacking DPS first (tooltip: no effect if already on you).
_ = dps.World.SetTarget(mob)
dps.GM(t, ".damage 1")
dps.WaitUnitTarget(t, mob, dps.GUID, 10*time.Second)
tank.Face(t, mob)
_ = tank.World.SetTarget(mob)
// Product path: client Taunt in Defensive Stance retargets the mob to the tank.
tank.CastMust(t, e2eharness.SpellTaunt, mob, 10*time.Second)
tank.WaitUnitTarget(t, mob, tank.GUID, 10*time.Second)
tank.AssertUnitTarget(t, mob, tank.GUID)
tank.AssertWorldAlive(t)
t.Logf("PASS taunt switched target to tank=0x%X", tank.GUID)
}
// THREAT-03: kill target clears combat eventually.
func TestThreat_KillClearsCombat(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat"}, Runtime: "short", Category: "combat/threat"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "ThrKill",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
dummy := bot.Spawn(t, pullDummy, 15*time.Second)
bot.CombatReady(t)
bot.Engage(t, dummy, 15*time.Second)
// Large chunks; Heroic dummy has huge HP pool.
bot.DamageKill(t, []uint64{dummy}, 50_000_000, 30*time.Second)
bot.WaitUnitDead(t, dummy, 15*time.Second)
// 31146 training dummies can leave the player flagged; the unit itself must drop combat.
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if !bot.UnitInCombat(dummy) {
break
}
time.Sleep(50 * time.Millisecond)
}
if bot.UnitInCombat(dummy) {
e2eharness.Assertf(t, "dummy 0x%X still UNIT_FLAG_IN_COMBAT after death", dummy)
}
bot.AssertWorldAlive(t)
t.Logf("PASS kill dummy dead combat=%v", bot.UnitInCombat(dummy))
}
// THREAT-04: multi-bot form party then pull (setup for threat tables).
func TestThreat_PartyPullSetup(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "multi_bot"}, Runtime: "short", Category: "combat/threat"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "ThrPty", Count: 2, Level: 80, LearnAllClass: true})
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAll(t, bots, pad.X, pad.Y, pad.Z, pad.Map)
for _, b := range bots {
b.GM(t, ".combatstop")
b.GM(t, ".cheat god on")
}
bots[0].DespawnNearbyEntry(t, pullDummy, 80)
e2eharness.FormParty(t, bots[0], bots[1])
dummy := bots[0].Spawn(t, pullDummy, 15*time.Second)
bots[0].CombatReady(t)
bots[0].Engage(t, dummy, 15*time.Second)
if !bots[0].InGroup() || !bots[1].InGroup() {
e2eharness.Assertf(t, "party gone after pull in0=%v in1=%v", bots[0].InGroup(), bots[1].InGroup())
}
if !bots[0].UnitInCombat(dummy) && e2eharness.UnitTargetGUID(bots[0].World, dummy) != bots[0].GUID {
e2eharness.Assertf(t, "dummy 0x%X not in combat and not targeting leader after party pull", dummy)
}
t.Logf("PASS party pull setup dummy=0x%X combat=%v", dummy, bots[0].UnitInCombat(dummy))
}
// THREAT-05: WaitUnitTarget helper after engage on a real threat AI.
func TestThreat_WaitUnitTargetHelper(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat"}, Runtime: "short", Category: "combat/threat"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "ThrWait",
Class: e2eharness.ClassWarrior,
Level: 50,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
mob := bot.Spawn(t, pullHostile, 15*time.Second)
bot.CombatReady(t)
bot.Engage(t, mob, 15*time.Second)
bot.WaitUnitTarget(t, mob, bot.GUID, 15*time.Second)
bot.AssertUnitTarget(t, mob, bot.GUID)
bot.AssertWorldAlive(t)
t.Logf("PASS WaitUnitTarget target=0x%X", bot.UnitTarget(mob))
}
@@ -0,0 +1,93 @@
//go:build e2e
package vehicles_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// VEH-* : EnterVehicle / ExitVehicle / IsOnVehicle via Stormwind Steed on PackagePad.
// spellArmistice (64373) is required by creature_template vehicle conditions
// on Stormwind Steed 33217. VehicleAI/SmartAI ExitVehicle the rider without it
// (conditions.comment: "Vehicle Stormwind Steed requires aura Armistice").
const spellArmistice uint32 = 64373
func spawnAndBoardSteed(t *testing.T, bot *e2eharness.ScenarioBot) (vehGUID, boarded uint64) {
t.Helper()
bot.ApplyAura(t, spellArmistice)
if !bot.HasAura(spellArmistice) {
e2eharness.Preconditionf(t, "Armistice %d not on player (steed vehicle condition)", spellArmistice)
}
vehGUID = bot.Spawn(t, e2eharness.CreatureStormwindSteed, 15*time.Second)
if vehGUID == 0 {
e2eharness.Preconditionf(t, "Stormwind Steed spawn returned guid 0")
}
boarded = bot.EnterVehicle(t, vehGUID, 12*time.Second)
if !bot.IsOnVehicle() {
e2eharness.Preconditionf(t, "not on steed 0x%X after enter (charm=0x%X)", vehGUID, bot.World.PlayerCharmGUID())
}
return vehGUID, boarded
}
// VEH-01: two bots at the pad; one boards a Stormwind Steed.
func TestVehicles_MultiBotColocated(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "multi_bot"}, Runtime: "short", Category: "combat/vehicles"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "VehCol", Count: 2, Level: 80})
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAllPad(t, bots, pad)
vehGUID, _ := spawnAndBoardSteed(t, bots[0])
bots[1].AssertWorldAlive(t)
t.Logf("PASS vehicle multi-bot colocation boarded=0x%X", vehGUID)
}
// VEH-02: relog on the pad, then board a Stormwind Steed.
func TestVehicles_RelogAfterTeleBaseline(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "protocol"}, Runtime: "short", Category: "combat/vehicles"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "VehRl", Level: 80})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.Save(t)
bot.Relog(t)
vehGUID, _ := spawnAndBoardSteed(t, bot)
t.Logf("PASS relog then board steed 0x%X", vehGUID)
}
// VEH-03: passenger hard-drop while on a vehicle must leave the probe world alive.
func TestVehicles_HardDisconnectWorldAlive(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/vehicles"})
probe := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "VehPr", Level: 10})
vic := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "VehVc", Level: 80})
vic.TeleportPad(t, e2eharness.PackagePad(t))
vehGUID, _ := spawnAndBoardSteed(t, vic)
e2eharness.HardDisconnectAndProbe(t, vic, probe, 0)
t.Logf("PASS passenger hard-drop world alive steed=0x%X", vehGUID)
}
// VEH-04: EnterVehicle → IsOnVehicle → ExitVehicle on Stormwind Steed fixture.
func TestVehicles_EnterExitStormwindSteed(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/vehicles"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "VehEn", Level: 80})
bot.TeleportPad(t, e2eharness.PackagePad(t))
if bot.IsOnVehicle() {
e2eharness.Preconditionf(t, "already on vehicle before enter")
}
vehGUID, _ := spawnAndBoardSteed(t, bot)
bot.ExitVehicle(t, 12*time.Second)
if bot.IsOnVehicle() {
e2eharness.Assertf(t, "still IsOnVehicle after ExitVehicle (0x%X)", bot.VehicleGUID())
}
bot.AssertWorldAlive(t)
t.Logf("PASS EnterVehicle/ExitVehicle Stormwind Steed 0x%X", vehGUID)
}
@@ -0,0 +1,81 @@
//go:build e2e
package charter_bank_test
import (
"database/sql"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// GUILD-01: charter buy + full turn-in via harness CreateGuildViaCharter (SetupGuildLeader).
func TestGuild_CharterBuy(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "guild"}, Runtime: "med", Category: "guild/charter_bank"})
leader, setup, charDB := e2eharness.SetupGuildLeader(t, "GldBuy")
if leader == nil || leader.GUID == 0 {
e2eharness.Preconditionf(t, "guild leader setup failed")
}
if setup.GuildName == "" {
e2eharness.Assertf(t, "SetupGuildLeader returned empty guild name (charter turn-in incomplete)")
}
if setup.PetitionGUID == 0 && setup.ItemLow == 0 {
e2eharness.Assertf(t, "SetupGuildLeader missing petition identity (charter buy incomplete)")
}
// guild_member insert is async (CharacterDatabase worker) after TURN_IN OK.
guid := leader.GUID & 0xffffffff
var guildID uint32
deadline := time.Now().Add(10 * time.Second)
var err error
for time.Now().Before(deadline) {
err = charDB.QueryRow(`SELECT guildid FROM guild_member WHERE guid=?`, guid).Scan(&guildID)
if err == nil && guildID != 0 {
break
}
err = charDB.QueryRow(`SELECT guildid FROM guild WHERE leaderguid=?`, guid).Scan(&guildID)
if err == nil && guildID != 0 {
break
}
time.Sleep(50 * time.Millisecond)
}
if guildID == 0 {
if err != nil && err != sql.ErrNoRows {
e2eharness.Assertf(t, "leader not in guild_member/guild after charter turn-in: err=%v guid=%d", err, guid)
} else {
e2eharness.Assertf(t, "leader not in guild_member/guild after charter turn-in (no row) guid=%d", guid)
}
}
t.Logf("PASS charter buy+turn-in guild=%q id=%d leader=%d petition=0x%X",
setup.GuildName, guildID, leader.GUID, setup.PetitionGUID)
}
// GUILD-03: multi-bot login for charter signs precondition.
func TestGuild_MultiBotLogin(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "guild", "multi_bot"}, Runtime: "med", Category: "guild/charter_bank"})
idents := e2eharness.MakeBotIdents("GldSign", 3)
auth, char := e2eharness.OpenTestDBs(t)
e2eharness.EnsureBotAccounts(t, auth, idents)
_ = char
sessions := e2eharness.LoginAllianceBots(t, idents)
if len(sessions) != 3 {
e2eharness.Preconditionf(t, "want 3 sessions, got %d", len(sessions))
}
t.Logf("PASS multi-bot guild login n=%d", len(sessions))
}
// GUILD-04: ModMoney for charter cost.
func TestGuild_MoneyForCharter(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "guild"}, Runtime: "short", Category: "guild/charter_bank"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "GldMny", Level: 20})
want := uint32(e2eharness.GuildCharterCostCopper + 1_000_000)
bot.ModMoney(t, want)
bot.AssertMoneyAtLeast(t, want)
t.Logf("PASS money for charter cost=%d", e2eharness.GuildCharterCostCopper)
}
@@ -0,0 +1,252 @@
//go:build e2e
package bind_reset_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// BIND-* : instance party baselines + ritual summon (3 roles) + #10708.
// game_tele name on this realm (not "Stockades"). Exterior entrance on map 0.
const teleTheStockade = "TheStockade"
// Stockade interior (map 34) — real dungeon instance, not PackagePad / outdoor isolation pads.
// Coords match areatrigger "Stormwind Stockades Entrance" (quieter than deeper hall packs).
// Used for ritual summon, bind soft checks, and co-located .summon into one instance copy.
var stockadeInterior = e2eharness.Position3{
X: 54.23, Y: 0.28, Z: -18.34, Map: 34,
}
// teleDungeonOrPrecondition runs a named tele and fails as precondition if position/map did not move.
func teleDungeonOrPrecondition(t *testing.T, bot *e2eharness.ScenarioBot, name string) (mapID uint32) {
t.Helper()
x0, y0, z0, m0 := bot.Pos()
bot.TeleNamed(t, name)
x1, y1, z1, m1 := bot.Pos()
moved := m1 != m0 || e2eharness.Distance3D(x0, y0, z0, x1, y1, z1) > 5
if !moved {
e2eharness.Preconditionf(t,
"named tele %q did not move player (still map=%d pos=%.1f,%.1f,%.1f) — missing game_tele?",
name, m1, x1, y1, z1)
}
return m1
}
// BIND-01: party of 2 formed (instance group precondition).
func TestBind_PartyFormedForInstance(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "instance", "multi_bot"}, Runtime: "short", Category: "instances/bind_reset"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "BindPty", Count: 2, Level: 80})
e2eharness.FormParty(t, bots[0], bots[1])
if !bots[0].InGroup() || !bots[1].InGroup() {
e2eharness.Assertf(t, "party not formed in0=%v in1=%v", bots[0].InGroup(), bots[1].InGroup())
}
t.Logf("PASS instance party formed")
}
// BIND-02: named dungeon tele (TheStockade) world alive.
func TestBind_NamedDungeonTele(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instance"}, Runtime: "med", Category: "instances/bind_reset"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "BindStk", Level: 80})
// AC game_tele is "TheStockade" (not "Stockades"); wrong name hangs TeleNamed ~60s.
m := teleDungeonOrPrecondition(t, bot, teleTheStockade)
bot.AssertWorldAlive(t)
t.Logf("PASS named dungeon tele map=%d", m)
}
// BIND-03: group shared tele attempt.
func TestBind_GroupTeleTogether(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instance", "multi_bot"}, Runtime: "med", Category: "instances/bind_reset"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "BindGrp", Count: 2, Level: 80})
e2eharness.FormParty(t, bots[0], bots[1])
var maps []uint32
for _, b := range bots {
maps = append(maps, teleDungeonOrPrecondition(t, b, teleTheStockade))
}
if maps[0] != maps[1] {
e2eharness.Assertf(t, "group not on same map after named tele leader=%d mate=%d", maps[0], maps[1])
}
if !bots[0].InGroup() || !bots[1].InGroup() {
e2eharness.Assertf(t, "party split after tele in0=%v in1=%v", bots[0].InGroup(), bots[1].InGroup())
}
t.Logf("PASS group tele together map=%d", maps[0])
}
// partyInStockadeForRitual places all three bots in the same Stockade instance.
// Separate .go xyz into map 34 can open different instance IDs — leader enters first, then
// GM .summon pulls helper and far into that copy.
//
// EffectSummonPlayer CheckCast requires a selectable same-raid target; casting while the
// far player is on another map fails under GM .cast and often under dungeon access checks.
// Issue the ritual / summon request while co-located in the instance, then send far outside
// before AcceptSummon when the test needs the exterior.
func partyInStockadeForRitual(t *testing.T, initiator, helper, far *e2eharness.ScenarioBot) {
t.Helper()
e2eharness.FormParty(t, initiator, helper, far)
// Initiator enters Stockade interior (real instance map 34) — not PackagePad.
initiator.Teleport(t, stockadeInterior.X, stockadeInterior.Y, stockadeInterior.Z, stockadeInterior.Map)
_, _, _, im := initiator.Pos()
if im != stockadeInterior.Map {
e2eharness.Preconditionf(t, "initiator not on Stockade map %d (got %d)", stockadeInterior.Map, im)
}
for _, mate := range []*e2eharness.ScenarioBot{helper, far} {
before := mate.World.TeleportSeq()
initiator.GM(t, ".summon "+mate.Name)
if err := mate.World.WaitForTeleportAfter(before, 15*time.Second); err != nil {
e2eharness.Preconditionf(t, "%s .summon into Stockade: %v", mate.Name, err)
}
_, _, _, mm := mate.Pos()
if mm != stockadeInterior.Map {
e2eharness.Preconditionf(t, "%s not on Stockade map after .summon (map=%d)", mate.Name, mm)
}
}
// Trash near the entrance can peel selection / break portal completion cast 7720.
for _, b := range []*e2eharness.ScenarioBot{initiator, helper, far} {
b.CombatStop(t)
b.FlushWorld(t)
}
}
// sendFarOutsideStockade moves far to the exterior entrance (map 0) while leaving a
// pending summon intact (do this only after SMSG_SUMMON_REQUEST).
func sendFarOutsideStockade(t *testing.T, far *e2eharness.ScenarioBot) {
t.Helper()
teleDungeonOrPrecondition(t, far, teleTheStockade)
_, _, _, m := far.Pos()
if m == stockadeInterior.Map {
e2eharness.Preconditionf(t, "far still on instance map %d after exterior tele", m)
}
}
// BIND-04: 3-role ritual summon inside Stockade, then far accepts from exterior.
//
// Flow (portal GO 179944, reqParticipants=2):
// 1. all three enter the same Stockade instance
// 2. initiator + helper run the portal; far receives SMSG_SUMMON_REQUEST
// 3. far leaves to exterior, then AcceptSummon back into the instance
func TestBind_RitualSummonAccept(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"short", "instance", "multi_bot", "serial"},
Runtime: "short",
Category: "instances/bind_reset",
})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "BindRt", Count: 3, Level: 80})
initiator, helper, far := bots[0], bots[1], bots[2]
partyInStockadeForRitual(t, initiator, helper, far)
waitSummon, cancelSummon := far.ArmSummonRequest()
defer cancelSummon()
e2eharness.RitualSummon(t, initiator, helper, far)
req, err := waitSummon(25 * time.Second)
if err != nil {
e2eharness.Preconditionf(t, "no SMSG_SUMMON_REQUEST after ritual: %v", err)
}
// Leave instance while summon is pending, then accept back in.
sendFarOutsideStockade(t, far)
far.AcceptSummon(t, req.SummonerGUID)
far.AssertWorldAlive(t)
initiator.AssertWorldAlive(t)
_, _, _, farMap := far.Pos()
if farMap != stockadeInterior.Map {
e2eharness.Assertf(t, "after AcceptSummon expected Stockade map %d, far map=%d",
stockadeInterior.Map, farMap)
}
fx, fy, fz, _ := far.Pos()
ix, iy, iz, _ := initiator.Pos()
if e2eharness.Distance3D(fx, fy, fz, ix, iy, iz) > 40 {
e2eharness.Assertf(t, "after AcceptSummon far still far from initiator (d=%.1f)",
e2eharness.Distance3D(fx, fy, fz, ix, iy, iz))
}
t.Logf("PASS ritual summon accept into instance summoner=0x%X farMap=%d", req.SummonerGUID, farMap)
}
// OPEN(e2e): re-enable when AC#10708 is fixed — post-reset AcceptSummon must co-locate maps.
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/10708
/*
func TestBind_ResetSummonExploit_10708(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"long", "instance", "issue", "multi_bot", "serial"},
Runtime: "long",
Issue: 10708,
Category: "instances/bind_reset",
})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "BindX7", Count: 3, Level: 80})
initiator, helper, far := bots[0], bots[1], bots[2]
partyInStockadeForRitual(t, initiator, helper, far)
waitSummon, cancelSummon := far.ArmSummonRequest()
defer cancelSummon()
e2eharness.RitualSummon(t, initiator, helper, far)
req, err := waitSummon(25 * time.Second)
if err != nil {
e2eharness.Preconditionf(t, "no SMSG_SUMMON_REQUEST: %v", err)
}
sendFarOutsideStockade(t, far)
initiator.LeaderResetInstances(t, 5*time.Second)
far.AcceptSummon(t, req.SummonerGUID)
far.AssertWorldAlive(t)
initiator.AssertWorldAlive(t)
_, _, _, farMap := far.Pos()
_, _, _, leadMap := initiator.Pos()
if farMap != leadMap {
e2eharness.Assertf(t, "after reset+AcceptSummon far map=%d initiator map=%d (summoner=0x%X)",
farMap, leadMap, req.SummonerGUID)
}
t.Logf("PASS #10708 path farMap=%d leadMap=%d", farMap, leadMap)
}
*/
// BIND-05: character_instance row present after instance map enter + save.
func TestBind_CharacterInstanceAfterEnter(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instance"}, Runtime: "med", Category: "instances/bind_reset"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "BindDB", Level: 80})
bot.Teleport(t, stockadeInterior.X, stockadeInterior.Y, stockadeInterior.Z, stockadeInterior.Map)
_, _, _, m := bot.Pos()
if m != stockadeInterior.Map {
e2eharness.Preconditionf(t, "expected Stockade map %d after .go xyz, got %d", stockadeInterior.Map, m)
}
bot.Save(t)
var n int
var lastErr error
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
lastErr = bot.CharDB.QueryRow(
`SELECT COUNT(*) FROM character_instance WHERE guid=?`, bot.GUID,
).Scan(&n)
if lastErr == nil && n > 0 {
break
}
time.Sleep(50 * time.Millisecond)
}
if lastErr != nil {
e2eharness.HarnessFailf(t, "character_instance query: %v", lastErr)
}
if n <= 0 {
e2eharness.Assertf(t, "expected character_instance row after Stockade enter+save, got count=%d map=%d", n, m)
}
bot.AssertWorldAlive(t)
t.Logf("PASS character_instance rows=%d map=%d", n, m)
}
@@ -0,0 +1,256 @@
//go:build e2e
package ulduar_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// OPEN(e2e): re-enable when AC#26266 is fixed — Charge near Kologarn must not drop below bridge Z.
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/26266
// Placement: charge pad ~15y west of Kologarn spawn (1797.15,-24.4,448.7) at (1782.15,-24.4,448.7).
/*
func TestUlduar_KologarnChargeWorldAlive(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"long", "instances", "issue"},
Runtime: "long",
Issue: 26266,
Category: "instances/northrend/ulduar",
})
posCharge := e2eharness.Position3{X: 1782.15, Y: -24.4027, Z: 448.741, Map: e2eharness.MapUlduar}
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "UldKol", Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true,
})
bot.TeleNamed(t, "Kologarn")
bot.Teleport(t, posCharge.X, posCharge.Y, posCharge.Z, posCharge.Map)
kolo := bot.WaitUnit(t, e2eharness.CreatureKologarn, 30*time.Second)
bot.Learn(t, e2eharness.SpellBattleStance)
bot.Learn(t, e2eharness.SpellCharge)
bot.CombatReadyFull(t)
bot.CastSelfGM(t, e2eharness.SpellBattleStance)
preX, preY, preZ, _ := bot.Pos()
bot.Face(t, kolo)
res, err := bot.TryCast(t, e2eharness.SpellCharge, kolo, 12*time.Second)
if err != nil {
e2eharness.Assertf(t, "Charge cast result timeout: %v", err)
} else if res == nil || !res.Success {
e2eharness.Assertf(t, "Charge fail reason=%s", e2eharness.SpellFailReasonName(res.FailReason))
}
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) {
x, y, z, _ := bot.Pos()
if e2eharness.Distance3D(preX, preY, preZ, x, y, z) > 1.0 {
break
}
time.Sleep(50 * time.Millisecond)
}
x, y, z, m := bot.Pos()
if z < preZ-20 {
e2eharness.Assertf(t, "Charge landed below bridge: z=%.1f preZ=%.1f pos=(%.1f,%.1f) map=%d", z, preZ, x, y, m)
}
bot.AssertWorldAlive(t)
t.Logf("PASS Kologarn charge path map=%d pos=(%.1f,%.1f,%.1f)", m, x, y, z)
}
*/
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27095
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27113
// Killing an older Allies of Nature set must not accelerate the next wave
// (only the current set's death reschedules EVENT_FREYA_ADDS_SPAM to 5s).
func TestAC_27095_FreyaAlliesSpawnRateReduction(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"long", "instances", "issue"},
Runtime: "long",
Issue: 27095,
Category: "instances/northrend/ulduar",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "Freya",
Level: 80,
})
const (
npcFreya10 = uint32(32906)
npcFreya25 = uint32(33360)
npcStormLasher = uint32(32919)
npcWaterSpirit = uint32(33202)
npcSnaplasher = uint32(32916)
npcConservator = uint32(33203)
npcDetonatingLasher = uint32(32918)
)
allyEntries := []uint32{
npcStormLasher, npcWaterSpirit, npcSnaplasher,
npcConservator, npcDetonatingLasher,
}
kindName := map[uint32]string{
npcStormLasher: "Trio", npcWaterSpirit: "Trio", npcSnaplasher: "Trio",
npcConservator: "Conservator", npcDetonatingLasher: "Lashers",
}
label := func(entry uint32) string {
if n, ok := kindName[entry]; ok {
return n
}
return "Unknown"
}
bot.TeleNamed(t, "Freya")
bot.GoCreatureID(t, npcFreya10)
bot.CombatReady(t)
freyaGUID := bot.WaitUnitAny(t, 30*time.Second, npcFreya10, npcFreya25)
bot.Engage(t, freyaGUID, 15*time.Second)
tr := e2eharness.NewSpawnSetTracker(allyEntries, 3*time.Second)
tr.KindOf = func(entry uint32) string { return label(entry) }
sets := tr.WaitSets(t, bot.World, 2, 4*time.Minute)
t.Logf("Set1=%s units=%d Set2=%s units=%d gap=%s",
sets[0].Kind, len(sets[0].Guids),
sets[1].Kind, len(sets[1].Guids),
sets[1].SpawnT.Sub(sets[0].SpawnT).Round(time.Millisecond))
// Detonating Lashers explode on death — if Set1 is Lashers, wait for Set3
// and kill Set2 (still: older set while a newer set is up).
if sets[0].Kind == "Lashers" {
sets = tr.WaitSets(t, bot.World, 3, 4*time.Minute)
}
var older, newer e2eharness.SpawnSet
if sets[0].Kind == "Lashers" {
older, newer = sets[1], sets[2]
} else {
older, newer = sets[0], sets[1]
}
if older.Kind == "Lashers" {
e2eharness.Preconditionf(t, "cannot find a non-Lasher older set to kill without collateral explosions")
}
time.Sleep(2 * time.Second)
tr.Poll(bot.World, time.Now())
var olderLive []uint64
for _, g := range older.Guids {
if hp, _ := bot.UnitHP(g); hp > 0 {
olderLive = append(olderLive, g)
}
}
if len(olderLive) == 0 {
switch older.Kind {
case "Trio":
olderLive = e2eharness.LivingByEntries(bot.World, 120, npcStormLasher, npcWaterSpirit, npcSnaplasher)
case "Conservator":
olderLive = e2eharness.LivingByEntries(bot.World, 120, npcConservator)
default:
olderLive = e2eharness.LivingByEntries(bot.World, 120, older.Entry)
}
}
var newerEntries []uint32
switch newer.Kind {
case "Trio":
newerEntries = []uint32{npcStormLasher, npcWaterSpirit, npcSnaplasher}
case "Conservator":
newerEntries = []uint32{npcConservator}
case "Lashers":
newerEntries = []uint32{npcDetonatingLasher}
default:
newerEntries = []uint32{newer.Entry}
}
newerN, _ := e2eharness.CountLivingWithRetry(bot.World, 120, newerEntries, 2*time.Second)
if len(olderLive) == 0 {
e2eharness.Preconditionf(t, "older set (%s) already dead before damage step", older.Kind)
}
if newerN == 0 {
e2eharness.Preconditionf(t, "newer set (%s) already dead before damage step", newer.Kind)
}
bot.DamageKill(t, olderLive, 10_000_000, 10*time.Second)
killT := time.Now()
knownAtKill := tr.Known()
for _, s := range bot.UnitsByEntry(120, allyEntries...) {
knownAtKill[s.GUID] = struct{}{}
}
fresh := bot.WaitNewUnits(t, knownAtKill, allyEntries, 90*time.Second)
if len(fresh) == 0 {
e2eharness.Preconditionf(t, "no new ally set spawned within 90s after older-set kill")
}
nextT := time.Now()
fromNewer := nextT.Sub(newer.SpawnT)
fromKill := nextT.Sub(killT)
t.Logf("next set=%s units=%d (Δ from newer spawn=%s, Δ from older kill=%s)",
label(fresh[0].Entry), len(fresh),
fromNewer.Round(time.Millisecond), fromKill.Round(time.Millisecond))
e2eharness.AssertIntervalNotAccelerated(t, 27095, fromKill, fromNewer, e2eharness.IntervalBugOpts{
MaxFromEvent: 20 * time.Second,
MaxFromBaseline: 45 * time.Second,
})
t.Logf("PASS AC#27095 next set not accelerated by killing older set")
}
// ULDUAR-03: Ulduar map enter via named tele stays in-world.
func TestUlduar_NamedTeleEnter(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instances"}, Runtime: "med", Category: "instances/northrend/ulduar"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "UldEnt",
Level: 80,
})
// Stock game_tele "Ulduar" is Storm Peaks entrance (map 571), not raid 603.
bot.TeleNamed(t, "Ulduar")
bot.AssertWorldAlive(t)
_, _, _, m := bot.Pos()
if m != e2eharness.MapNorthrend {
e2eharness.Assertf(t, "TeleNamed Ulduar map=%d want Northrend %d", m, e2eharness.MapNorthrend)
}
t.Logf("PASS Ulduar named tele map=%d (Storm Peaks entrance)", m)
}
// ULDUAR-04: engage + DamageKill path on a trash/dummy (raid helper training).
// L1 Target Dummy (2673) is oneshot by L80 before combat flag — use HeroicTrainingDummy.
func TestUlduar_DamageKillPathSafe(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instances"}, Runtime: "med", Category: "instances/northrend/ulduar"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "UldDmg",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
dummy := bot.Spawn(t, e2eharness.CreatureHeroicTrainingDummy, 15*time.Second)
bot.CombatReady(t)
bot.Engage(t, dummy, 20*time.Second)
bot.DamageKill(t, []uint64{dummy}, 50_000_000, 20*time.Second)
hp, _ := bot.UnitHP(dummy)
if hp > 0 {
e2eharness.Assertf(t, "dummy 0x%X still alive hp=%d after DamageKill", dummy, hp)
}
t.Logf("PASS DamageKill path dummy=0x%X dead", dummy)
}
// ULDUAR-05: dual-bot login near Freya does not thrash auth.
func TestUlduar_MultiBotLoginNearBossPad(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instances", "multi_bot"}, Runtime: "med", Category: "instances/northrend/ulduar"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "UldDuo",
Count: 2,
Level: 80,
})
for _, b := range bots {
b.TeleNamed(t, "Freya")
}
_, _, _, m0 := bots[0].Pos()
_, _, _, m1 := bots[1].Pos()
if m0 != e2eharness.MapUlduar || m1 != e2eharness.MapUlduar {
e2eharness.Assertf(t, "Freya pad maps leader=%d mate=%d want %d", m0, m1, e2eharness.MapUlduar)
}
t.Logf("PASS multi-bot Freya pad login n=%d map=%d", len(bots), m0)
}
+109
View File
@@ -0,0 +1,109 @@
//go:build e2e
package equip_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// EQUIP-01: add+inventory oracle (EquipEntry is EQUIP-04).
func TestEquip_AddItemInventorySeed(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items"}, Runtime: "short", Category: "items/equip"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "EqBasic",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.AddItemWait(t, e2eharness.ItemTargetDummy, 1)
waitInventoryAtLeast(t, bot, e2eharness.ItemTargetDummy, 1)
t.Logf("PASS add item inventory seed")
}
// EQUIP-02: AddItemWait returns bag/slot.
func TestEquip_AddItemWaitSlot(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items"}, Runtime: "short", Category: "items/equip"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "EqSlot",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bag, slot := bot.AddItemWait(t, e2eharness.ItemTargetDummy, 1)
bot.AssertInventoryAtLeast(t, e2eharness.ItemTargetDummy, 1)
t.Logf("PASS AddItemWait bag=%d slot=%d inventory>=1", bag, slot)
}
// EQUIP-03: multiple adds without crash.
func TestEquip_MultipleAddsNoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items"}, Runtime: "short", Category: "items/equip"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "EqMulti",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
for i := 0; i < 3; i++ {
bot.AddItem(t, e2eharness.ItemTargetDummy, 1)
}
bot.Save(t)
bot.AssertWorldAlive(t)
t.Logf("PASS multiple item adds")
}
// EQUIP-04: equip entry helper on a simple weapon if available via GM add+equip.
func TestEquip_EquipEntryHelper(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items"}, Runtime: "short", Category: "items/equip"})
const itemWornShortsword = 25 // Worn Shortsword, main-hand slot 15
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "EqWep",
Class: e2eharness.ClassWarrior,
Level: 10,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.EquipEntry(t, itemWornShortsword, 1)
slot := bot.WaitEquipped(t, itemWornShortsword, 5*time.Second)
t.Logf("PASS EquipEntry helper worn entry=%d visible slot=%d", itemWornShortsword, slot)
}
// EQUIP-05: bag seed + relog inventory path stays healthy.
func TestEquip_ItemSurvivesRelogPath(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items", "protocol"}, Runtime: "short", Category: "items/equip"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "EqRelog",
Level: 80,
})
// AddItem is fire-and-forget; CharDB after an immediate .save often still
// reads 0 on a fast runner. Wait for the push, then poll the persisted count.
bot.AddItemWait(t, e2eharness.ItemTargetDummy, 2)
waitInventoryAtLeast(t, bot, e2eharness.ItemTargetDummy, 2)
bot.Relog(t)
bot.AssertWorldAlive(t)
// Inventory should still be present after relog (CharDB / load path).
bot.AssertInventoryAtLeast(t, e2eharness.ItemTargetDummy, 2)
t.Logf("PASS item seed + relog inventory persists")
}
// waitInventoryAtLeast polls CharDB (InventoryCount Saves) until count >= min.
func waitInventoryAtLeast(t *testing.T, bot *e2eharness.ScenarioBot, entry uint32, least int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var got int
for time.Now().Before(deadline) {
got = bot.InventoryCount(t, entry)
if got >= least {
return
}
time.Sleep(100 * time.Millisecond)
}
e2eharness.Preconditionf(t, "inventory entry=%d count=%d want>=%d", entry, got, least)
}
@@ -0,0 +1,141 @@
//go:build e2e
package session_test
import (
"testing"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// SESS-01: position readable after login (seed of inventory/pos load).
func TestSession_PositionAfterLogin(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/session"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SessPos",
Level: 20,
})
x, y, z, mapID := bot.Pos()
if bot.GUID == 0 {
e2eharness.Preconditionf(t, "GUID 0 after login")
}
if mapID != e2eharness.MapEasternKingdoms {
e2eharness.Assertf(t, "login map=%d want %d pos=(%.1f,%.1f,%.1f)",
mapID, e2eharness.MapEasternKingdoms, x, y, z)
}
t.Logf("PASS pos after login map=%d (%.1f,%.1f,%.1f)", mapID, x, y, z)
}
// SESS-02: item + quest present after load path.
func TestSession_ItemAndQuestAfterLoad(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/session"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SessLd",
Level: 40,
})
bot.AddItemWait(t, e2eharness.ItemTargetDummy, 1)
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
bot.Save(t)
bot.AssertInventoryAtLeast(t, e2eharness.ItemTargetDummy, 1)
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
t.Logf("PASS item+quest after load/save")
}
// SESS-03: set gold via GM, save, relog — CharDB money must match.
func TestSession_MoneyMutateSaveRelog(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/session"})
const wantCopper uint32 = 50_000
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SessGd",
Level: 20,
})
bot.SetMoney(t, wantCopper)
bot.AssertMoneyEqual(t, wantCopper)
bot.Save(t)
bot.Relog(t)
bot.AssertWorldAlive(t)
// Product oracle: money must survive save+relog (not world-alive only).
bot.AssertMoneyEqual(t, wantCopper)
t.Logf("PASS money %d survived save+relog", wantCopper)
}
// OPEN(e2e): re-enable when AC#25793 is fixed — GM .gm visible off must persist extra_flags across relog.
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/25793
/*
func TestSession_GMVisibilitySurvivesRelog(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"short", "protocol", "issue"},
Runtime: "short",
Issue: 25793,
Category: "protocol/session",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SessGM",
Level: 10,
})
const playerExtraGMInvisible = uint16(0x0010)
bot.GM(t, ".gm visible off")
bot.Save(t)
var flags uint16
if err := bot.CharDB.QueryRow(
`SELECT extra_flags FROM characters WHERE guid=?`, bot.GUID,
).Scan(&flags); err != nil {
e2eharness.HarnessFailf(t, "read extra_flags: %v", err)
}
if flags&playerExtraGMInvisible == 0 {
e2eharness.Preconditionf(t, "after .gm visible off, extra_flags=0x%X missing bit 0x10", flags)
}
guid := bot.GUID
bot.Relog(t)
bot.AssertWorldAlive(t)
queryGUID := bot.GUID
if queryGUID == 0 {
queryGUID = guid
}
var after uint16
err := bot.CharDB.QueryRow(
`SELECT extra_flags FROM characters WHERE guid=?`, queryGUID,
).Scan(&after)
if err != nil {
err = bot.CharDB.QueryRow(
`SELECT extra_flags FROM characters WHERE guid=?`, guid,
).Scan(&after)
}
if err != nil {
e2eharness.HarnessFailf(t, "read extra_flags after relog: %v", err)
}
if after&playerExtraGMInvisible == 0 {
e2eharness.Assertf(t, "GM invisible did not stick after relog (extra_flags=0x%X)", after)
}
t.Logf("PASS GM visibility survived relog (extra_flags=0x%X)", after)
}
*/
// SESS-06: hard session drop leaves world probeable by another bot.
func TestSession_HardDropWorldStaysAlive(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol", "serial"}, Runtime: "short", Category: "protocol/session"})
probe := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SessPrb",
Level: 10,
})
victim := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "SessVic",
Level: 10,
})
// Hard close without graceful logout path. issue=0: no ConfirmedBugf.
e2eharness.HardDisconnectAndProbe(t, victim, probe, 0)
t.Logf("PASS hard drop did not kill world (probe OK)")
}
@@ -0,0 +1,102 @@
//go:build e2e
package teleport_test
import (
"math"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// TELE-01: cross-map teleport (EK → Northrend).
func TestTeleport_CrossMapEasternToNorthrend(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "TeleXM",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
_, _, _, m0 := bot.Pos()
bot.Teleport(t, 3758.2554, 3689.5754, 47.241505, e2eharness.MapNorthrend)
x, y, z, m1 := bot.Pos()
if m1 != e2eharness.MapNorthrend {
e2eharness.Preconditionf(t, "expected Northrend map %d, got %d (from %d)", e2eharness.MapNorthrend, m1, m0)
}
if math.IsNaN(float64(x)) {
e2eharness.HarnessFailf(t, "nan position after cross-map tele")
}
bot.AssertWorldAlive(t)
t.Logf("PASS cross-map tele map=%d pos=(%.1f,%.1f,%.1f)", m1, x, y, z)
}
// TELE-02: TeleNamed + world alive.
func TestTeleport_TeleNamedSafe(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "TeleNm",
Level: 80,
})
// Stormwind is a stable named tele on most AC installs.
bot.TeleNamed(t, "Stormwind")
bot.AssertWorldAlive(t)
_, _, _, m := bot.Pos()
if m != e2eharness.MapEasternKingdoms {
e2eharness.Assertf(t, "TeleNamed Stormwind map=%d want EK %d", m, e2eharness.MapEasternKingdoms)
}
t.Logf("PASS TeleNamed Stormwind map=%d", m)
}
// TELE-03: spam short teleports must not crash world.
func TestTeleport_SpamShortDoesNotCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "TeleSp",
Level: 80,
})
for i := 0; i < 5; i++ {
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.Teleport(t, -8949.95+float32(i), 554.0, 94.0, e2eharness.MapEasternKingdoms)
}
bot.AssertWorldAlive(t)
t.Logf("PASS spam short teleports")
}
// TELE-04: pad tele then named tele preserves session.
func TestTeleport_PadThenNamed(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "TelePN",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.TeleNamed(t, "Ironforge")
bot.AssertWorldAlive(t)
t.Logf("PASS pad then named tele")
}
// TELE-05: GoCreatureID lands near entry after spawn.
func TestTeleport_GoCreatureIDNearSpawn(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "TeleGo",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
guid := bot.Spawn(t, e2eharness.CreatureTargetDummy, 15*time.Second)
if guid == 0 {
e2eharness.Preconditionf(t, "failed to spawn target dummy")
}
bot.GoCreatureID(t, e2eharness.CreatureTargetDummy)
bot.AssertWorldAlive(t)
t.Logf("PASS GoCreatureID near dummy guid=0x%X", guid)
}
+108
View File
@@ -0,0 +1,108 @@
//go:build e2e
package escort_test
import (
"fmt"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// OPEN(e2e): replace this package with a real escort quest fixture (start → follow →
// complete/fail oracle). Until then only keep spawn/cache helpers that have hard asserts.
// ESCORT-02: unit still findable after spawn (hard GUID/cache oracle).
func TestEscort_UnitFindableAfterSpawn(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests"}, Runtime: "short", Category: "quests/escort"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "EscFd", Level: 80})
bot.TeleportPad(t, e2eharness.PackagePad(t))
guid := bot.Spawn(t, e2eharness.CreatureTargetDummy, 15*time.Second)
found := bot.FindUnit(e2eharness.CreatureTargetDummy, 50)
if found == 0 {
e2eharness.Assertf(t, "FindUnit 0 after spawn guid=0x%X", guid)
}
bot.AssertWorldAlive(t)
t.Logf("PASS unit findable found=0x%X spawn=0x%X", found, guid)
}
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/24450
// Quest 12512 Leave No One Behind: bandage Crusader Jonathan (28133) summons
// follower 28136 (SMART_ACTION_FOLLOW). Logout must despawn the follower
// (SmartAI::UpdateFollow when FindPlayer fails).
// https://www.wowhead.com/wotlk/quest=12512/leave-no-one-behind
func TestAC_24450_FollowDespawnsOnLogout(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "quests", "ai", "issue", "serial"},
Runtime: "med",
Issue: 24450,
Category: "quests/escort",
})
const (
questLeaveNoOneBehind = uint32(12512)
itemCrusadersBandage = uint32(38330)
spellCrusadersBandage = uint32(50662)
npcJonathanWorld = uint32(28133) // sitting world spawn
npcJonathanFollow = uint32(28136) // summoned follower
)
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "EscFol",
Level: 80,
})
bot.AddQuest(t, questLeaveNoOneBehind)
bot.AddItem(t, itemCrusadersBandage, 1)
bot.GoCreatureID(t, npcJonathanWorld)
worldNPC := bot.WaitUnit(t, npcJonathanWorld, 20*time.Second)
if worldNPC == 0 {
e2eharness.Preconditionf(t, "Crusader Jonathan %d not at world spawn", npcJonathanWorld)
}
bot.DespawnNearbyEntry(t, npcJonathanFollow, 80)
// 50662 is an item spell; client CastSpell without USE_ITEM never SPELL_GOs.
// Targeted `.cast` is the GM stand-in for using Crusader's Bandage on Jonathan.
known := map[uint64]struct{}{}
for _, u := range bot.UnitsByEntry(80, npcJonathanFollow) {
known[u.GUID] = struct{}{}
}
if err := bot.World.SetTarget(worldNPC); err != nil {
e2eharness.Preconditionf(t, "SetTarget Jonathan 0x%X: %v", worldNPC, err)
}
bot.GM(t, fmt.Sprintf(".cast %d", spellCrusadersBandage))
fresh := bot.WaitNewUnits(t, known, []uint32{npcJonathanFollow}, 20*time.Second)
if len(fresh) == 0 {
e2eharness.Preconditionf(t, "bandage did not summon a new follow-NPC %d", npcJonathanFollow)
}
follower := fresh[0].GUID
t.Logf("follower 28136 guid=0x%X after bandage", follower)
// UpdateFollow only ticks every 1s. A tight Relog can come back before
// FindPlayer fails, so stay offline past that timer, then login.
if err := bot.World.SendLogout(); err != nil {
e2eharness.HarnessFailf(t, "logout: %v", err)
}
if err := bot.World.WaitForLogout(30 * time.Second); err != nil {
t.Logf("logout wait: %v (continuing)", err)
}
bot.Close()
time.Sleep(2 * time.Second)
bot.Relog(t)
// Fresh snapshot: sitting Jonathan must be in the create set first.
if bot.WaitUnit(t, npcJonathanWorld, 20*time.Second) == 0 {
e2eharness.Preconditionf(t, "after login, world Jonathan %d not in object cache — snapshot not ready", npcJonathanWorld)
}
if obj := bot.World.GetObject(follower); obj != nil {
e2eharness.Assertf(t, "follow-NPC %d still in snapshot after logout (guid=0x%X)", npcJonathanFollow, follower)
}
if leftover := bot.FindUnit(npcJonathanFollow, 80); leftover != 0 {
e2eharness.Assertf(t, "follow-NPC %d still in world after logout (guid=0x%X)", npcJonathanFollow, leftover)
}
bot.AssertWorldAlive(t)
t.Logf("PASS AC#24450 follower %d despawned after logout", npcJonathanFollow)
}
@@ -0,0 +1,123 @@
//go:build e2e
package lifecycle_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// QLIFE-03 / #26549: STAY_ALIVE quest fails on death.
func TestAC_26549_StayAliveFailsOnDeath(t *testing.T) {
// serial: DieMust + CharDB save race under parallel pad thrash.
meta.Begin(t, meta.TestMeta{
// smoke: STAY_ALIVE fail-on-death is in core (#26989 / #26549).
Tags: []string{"short", "quests", "issue", "smoke", "serial"},
Runtime: "short",
Issue: 26549,
Category: "quests/lifecycle",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "QAlive",
Class: e2eharness.ClassWarrior,
Level: 30,
})
// AddQuest waits for INCOMPLETE in CharDB (async .quest add under thrash).
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
bot.Teleport(t, -9222.58, -2147.87, 63.814, e2eharness.MapEasternKingdoms)
bot.DieAndRepop(t)
// CharDB quest status is async after .save — always re-save and re-read.
st, ok := bot.QuestStatusAfterSave(t, e2eharness.QuestRethbanGauntlet)
if !ok {
e2eharness.HarnessFailf(t, "quest row missing after death")
}
if st != e2eharness.QuestStatusFailed {
// CharDB lag after FailQuest — poll, do not fixed-sleep once.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) && st != e2eharness.QuestStatusFailed {
time.Sleep(100 * time.Millisecond)
st, ok = bot.QuestStatusAfterSave(t, e2eharness.QuestRethbanGauntlet)
if !ok {
e2eharness.HarnessFailf(t, "quest row missing after death (retry)")
}
}
}
if st != e2eharness.QuestStatusFailed {
// ConfirmedBugf(t, 26549, ...) — core FailQuestsOnDeath is on this branch; hard-fail.
e2eharness.Assertf(t, "quest status=%d (%s) after death+repop, want FAILED(5)",
st, e2eharness.QuestStatusName(st))
}
t.Logf("PASS quest failed on death (status=%s)", e2eharness.QuestStatusName(st))
}
// QLIFE-01: AddQuest → incomplete status after save.
func TestQuest_AddQuestIncomplete(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests"}, Runtime: "short", Category: "quests/lifecycle"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "QAdd",
Level: 30,
})
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
t.Logf("PASS AddQuest → incomplete")
}
// QLIFE-02: quest status survives save round-trip.
func TestQuest_StatusSurvivesSave(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests"}, Runtime: "short", Category: "quests/lifecycle"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "QSave",
Level: 30,
})
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
st, ok := bot.QuestStatusAfterSave(t, e2eharness.QuestRethbanGauntlet)
if !ok {
e2eharness.HarnessFailf(t, "quest missing after save")
}
if st != e2eharness.QuestStatusIncomplete {
e2eharness.Assertf(t, "want incomplete after save, got %d", st)
}
t.Logf("PASS quest status survives save")
}
// QLIFE-04: quest status after relog remains Incomplete (not row-presence only).
func TestQuest_StatusSurvivesRelog(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests", "protocol"}, Runtime: "short", Category: "quests/lifecycle"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "QRelog",
Level: 30,
})
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
bot.Save(t)
bot.Relog(t)
bot.AssertWorldAlive(t)
// Product oracle: status byte must still be Incomplete after relog.
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
t.Logf("PASS quest incomplete after relog")
}
// QLIFE-05: second AddQuest of same ID is safe (no crash).
func TestQuest_ReAddSameQuestNoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests"}, Runtime: "short", Category: "quests/lifecycle"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "QDup",
Level: 30,
})
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
bot.AssertWorldAlive(t)
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
t.Logf("PASS re-add same quest no crash")
}
+126
View File
@@ -0,0 +1,126 @@
//go:build e2e
package group_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/client"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// GRP-01: FormParty → both InGroup, leader flag.
func TestGroup_FormPartyBasic(t *testing.T) {
// serial: SW pad thrash + concurrent party tests can drop invites under load.
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "social", "multi_bot", "serial"}, Runtime: "short", Category: "social/group"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "GrpForm",
Count: 2,
Level: 20,
})
leader, mate := bots[0], bots[1]
e2eharness.FormPartyAtPad(t, e2eharness.PackagePad(t), leader, mate)
if !leader.InGroup() || !mate.InGroup() {
e2eharness.Preconditionf(t, "expected both in group leader=%v mate=%v", leader.InGroup(), mate.InGroup())
}
if !leader.IsGroupLeader() {
e2eharness.Preconditionf(t, "leader bot not IsGroupLeader")
}
t.Logf("PASS FormParty members=%d", len(leader.GroupMembers())+1)
}
// GRP-02: leave group clears membership.
func TestGroup_LeaveClearsMembership(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "social", "multi_bot", "serial"}, Runtime: "short", Category: "social/group"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "GrpLv", Count: 2, Level: 20})
leader, mate := bots[0], bots[1]
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAllPad(t, bots, pad)
e2eharness.FormParty(t, leader, mate)
mate.LeaveGroup(t)
mate.WaitNotInGroup(t, 15*time.Second)
if mate.InGroup() {
e2eharness.Assertf(t, "mate still InGroup after leave")
}
t.Logf("PASS leave group")
}
// GRP-03: transfer leader.
func TestGroup_SetLeaderTransfer(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "social", "multi_bot", "serial"}, Runtime: "short", Category: "social/group"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "GrpLead", Count: 2, Level: 20})
a, b := bots[0], bots[1]
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAllPad(t, bots, pad)
e2eharness.FormParty(t, a, b)
a.SetLeader(t, b)
b.WaitIsGroupLeader(t, 15*time.Second)
t.Logf("PASS set leader transfer")
}
// GRP-04: rapid invite/decline loops must not crash world.
func TestGroup_RapidInviteDeclineNoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "social", "multi_bot", "serial"},
Runtime: "med",
Category: "social/group",
})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "GrpFast", Count: 2, Level: 20})
a, b := bots[0], bots[1]
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAllPad(t, bots, pad)
for i := 0; i < 5; i++ {
// Arm before Invite (no last-invite cache; WaitGroupInvite alone can miss a fast SMSG).
waitInv, cancelInv := b.ArmGroupInvite()
a.Invite(t, b)
if _, ok := waitInv(5 * time.Second); !ok {
cancelInv()
e2eharness.Assertf(t, "rapid invite loop %d: no SMSG_GROUP_INVITE", i)
return
}
cancelInv()
// Must wait for leader SMSG_GROUP_DECLINE — pending GetGroupInvite is not "in group",
// so WaitNotInGroup returns immediately and the next Invite hits ALREADY_IN_GROUP.
b.DeclineGroupFrom(t, a)
b.WaitNotInGroup(t, 5*time.Second)
}
e2eharness.ProbeWorldAlive(t, a, 0)
t.Logf("PASS rapid invite/decline no crash")
}
// GRP-05: set loot method NeedBeforeGreed.
func TestGroup_SetLootMethodNBG(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "social", "loot", "multi_bot", "serial"}, Runtime: "short", Category: "social/group"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "GrpLoot", Count: 2, Level: 20})
leader, mate := bots[0], bots[1]
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAllPad(t, bots, pad)
e2eharness.FormParty(t, leader, mate)
leader.SetLootMethod(t, client.LootMethodNeedBeforeGreed, 0, 2)
st := leader.WaitLootMethod(t, client.LootMethodNeedBeforeGreed, 10*time.Second)
t.Logf("PASS set loot method state_in_group=%v loot=%d", st.InGroup, st.LootMethod)
}
// GRP-06: disband party.
func TestGroup_DisbandParty(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "social", "multi_bot", "serial"}, Runtime: "short", Category: "social/group"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "GrpDis", Count: 2, Level: 20})
leader, mate := bots[0], bots[1]
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAllPad(t, bots, pad)
e2eharness.FormParty(t, leader, mate)
e2eharness.DisbandParty(t, bots...)
leader.WaitNotInGroup(t, 15*time.Second)
mate.WaitNotInGroup(t, 15*time.Second)
t.Logf("PASS disband party")
}
+320
View File
@@ -0,0 +1,320 @@
//go:build e2e
package loot_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/client"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// LOOT-01: Need vs Greed on a guaranteed Uncommon drop (Crimson Templar).
func TestLoot_NeedVsGreedWinnerBag(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "loot", "multi_bot", "serial"}, Runtime: "med", Category: "social/loot"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "LootNBG",
Bots: []e2eharness.BotSpec{
{Role: "leader", Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
{Role: "mate", Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
},
})
leader := e2eharness.ByRole(t, bots, "leader")
mate := e2eharness.ByRole(t, bots, "mate")
e2eharness.FormPartyAtPad(t, e2eharness.PackagePad(t), leader, mate)
// AC minimum valid threshold is Uncommon (2); greys/whites never start group rolls.
leader.SetLootMethod(t, client.LootMethodNeedBeforeGreed, 0, e2eharness.LootThresholdUncommon)
leader.WaitLootMethod(t, client.LootMethodNeedBeforeGreed, 10*time.Second)
// Mate ready on pad before kill so both are in range for group loot rolls.
mate.CombatReady(t)
guid := leader.SpawnKillLootable(t, e2eharness.CreatureGroupLootFixture, 45*time.Second)
// Do not re-teleport after kill — stay on the corpse (tele can drop client loot state).
leader.World.ClearActiveLootRolls()
mate.World.ClearActiveLootRolls()
waitRoll, cancelRoll := leader.ArmLootStartRoll()
t.Cleanup(cancelRoll)
items, ok := leader.TryOpenLoot(t, guid, 8*time.Second)
if !ok {
_ = leader.World.SetTarget(guid)
items, ok = leader.TryOpenLoot(t, guid, 8*time.Second)
}
if !ok {
e2eharness.Preconditionf(t, "NBG: group-loot fixture corpse not lootable guid=0x%X", guid)
return
}
t.Logf("loot window items=%d", len(items))
roll, gotRoll := waitRoll(0, 12*time.Second)
if !gotRoll {
// Known-good fixture (100% Uncommon crest); missing roll is setup/product failure.
leader.LootRelease(t, guid)
e2eharness.Preconditionf(t, "NBG: no SMSG_LOOT_START_ROLL on fixture %d (items=%d)", e2eharness.CreatureGroupLootFixture, len(items))
return
}
// Bag baseline before award so we can require inventory credit (not packet-only).
leadCount0 := leader.InventoryCount(t, roll.ItemID)
mateCount0 := mate.InventoryCount(t, roll.ItemID)
// Arm outcome before votes (Arm → Roll → Wait).
wonCh, _, cancelOut := leader.ArmLootRollOutcome(roll.ItemID)
t.Cleanup(cancelOut)
mate.RollGreed(t, roll)
leader.RollNeed(t, roll)
var won client.LootRollWon
select {
case won = <-wonCh:
case <-time.After(90 * time.Second):
e2eharness.Assertf(t, "LOOT_ROLL_WON not seen after Need/Greed item=%d", roll.ItemID)
return
}
if won.WinnerGUID != leader.GUID && won.WinnerGUID != mate.GUID {
e2eharness.Assertf(t, "unexpected winner 0x%X", won.WinnerGUID)
}
winner := leader
base := leadCount0
if won.WinnerGUID == mate.GUID {
winner = mate
base = mateCount0
}
// Poll bag credit briefly (item push can lag slightly behind LOOT_ROLL_WON).
deadline := time.Now().Add(10 * time.Second)
var n int
for time.Now().Before(deadline) {
n = winner.InventoryCount(t, roll.ItemID)
if n >= base+1 {
break
}
time.Sleep(50 * time.Millisecond)
}
if n < base+1 {
e2eharness.Assertf(t, "NBG winner bag credit missing item=%d count %d→%d want >=%d (winner=0x%X)",
roll.ItemID, base, n, base+1, won.WinnerGUID)
}
t.Logf("PASS need/greed roll item=%d winner=0x%X bag %d→%d", roll.ItemID, won.WinnerGUID, base, n)
leader.AssertWorldAlive(t)
}
// OPEN(e2e): re-enable when AC#26894 is fixed and GO 194821 UseGameObject path is available.
// Prefer real Gift of the Observer mid-roll leave (not corpse proxy). Soft-pass is forbidden.
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/26894
/*
func TestAC_26894_ChestLootPartyLeaveMidRoll(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "loot", "multi_bot", "serial", "issue"},
Runtime: "med",
Issue: 26894,
Category: "social/loot",
})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "LootLv",
Bots: []e2eharness.BotSpec{
{Role: "leader", Level: 80, LearnAllClass: true, Class: e2eharness.ClassWarrior},
{Role: "leaver", Level: 80, LearnAllClass: true, Class: e2eharness.ClassWarrior},
{Role: "stayer", Level: 80, LearnAllClass: true, Class: e2eharness.ClassWarrior},
},
})
leader := e2eharness.ByRole(t, bots, "leader")
leaver := e2eharness.ByRole(t, bots, "leaver")
stayer := e2eharness.ByRole(t, bots, "stayer")
e2eharness.FormPartyAtPad(t, e2eharness.PackagePad(t), leader, leaver, stayer)
leader.SetLootMethod(t, client.LootMethodGroupLoot, 0, e2eharness.LootThresholdUncommon)
leader.WaitLootMethod(t, client.LootMethodGroupLoot, 10*time.Second)
_ = leader.SpawnGameObject(t, e2eharness.GameObjectGiftOfTheObserver)
_ = e2eharness.TryNearbyGameObjectByEntry(t, leader.World, e2eharness.GameObjectGiftOfTheObserver, 5*time.Second)
leaver.CombatReady(t)
stayer.CombatReady(t)
guid := leader.SpawnKillLootable(t, e2eharness.CreatureGroupLootFixture, 45*time.Second)
waitRoll, cancelRoll := leader.ArmLootStartRoll()
t.Cleanup(cancelRoll)
if _, ok := leader.TryOpenLoot(t, guid, 8*time.Second); !ok {
e2eharness.Preconditionf(t, "#26894: fixture corpse not lootable guid=0x%X", guid)
}
roll, gotRoll := waitRoll(0, 12*time.Second)
if !gotRoll {
e2eharness.Preconditionf(t, "#26894: no roll window on fixture %d", e2eharness.CreatureGroupLootFixture)
}
wonCh, allCh, cancelOut := leader.ArmLootRollOutcome(roll.ItemID)
t.Cleanup(cancelOut)
leaver.LeaveGroup(t)
leaver.WaitNotInGroup(t, 15*time.Second)
leader.RollNeed(t, roll)
stayer.RollGreed(t, roll)
select {
case w := <-wonCh:
t.Logf("roll awarded winner=0x%X item=%d", w.WinnerGUID, w.ItemID)
case <-allCh:
t.Logf("all passed")
case <-time.After(90 * time.Second):
e2eharness.Assertf(t, "mid-roll leave: roll did not resolve itemGUID=0x%X", roll.ItemGUID)
}
e2eharness.ProbeWorldAlive(t, leader, 26894)
leader.AssertWorldAlive(t)
t.Logf("PASS mid-roll leave resolves roll")
}
*/
// OPEN(e2e): re-enable when AC#22000 is fixed — pass-on-loot must redistribute, not delete.
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/22000
// The ALL_PASSED packet is not that oracle. Keep commented until an item-survive assert exists.
/*
func TestLoot_PassOnLootRedistribution(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "loot", "multi_bot", "serial", "issue"}, Runtime: "med", Category: "social/loot", Issue: 22000})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "LootPas",
Count: 2,
Level: 80,
LearnAllClass: true,
Class: e2eharness.ClassWarrior,
})
leader, mate := bots[0], bots[1]
e2eharness.FormPartyAtPad(t, e2eharness.PackagePad(t), leader, mate)
leader.SetLootMethod(t, client.LootMethodGroupLoot, 0, e2eharness.LootThresholdUncommon)
leader.WaitLootMethod(t, client.LootMethodGroupLoot, 10*time.Second)
mate.CombatReady(t)
guid := leader.SpawnKillLootable(t, e2eharness.CreatureGroupLootFixture, 45*time.Second)
waitRoll, cancelRoll := leader.ArmLootStartRoll()
t.Cleanup(cancelRoll)
items, ok := leader.TryOpenLoot(t, guid, 8*time.Second)
if !ok {
e2eharness.Preconditionf(t, "pass-on-loot: fixture corpse not lootable")
return
}
roll, gotRoll := waitRoll(0, 12*time.Second)
if !gotRoll {
e2eharness.Preconditionf(t, "pass-on-loot: no rolls on fixture %d (items=%d)", e2eharness.CreatureGroupLootFixture, len(items))
return
}
// Fixture is judgeable once both bots pass — arm before votes; hard-fail if ALL_PASSED missing.
_, allCh, cancelOut := leader.ArmLootRollOutcome(roll.ItemID)
t.Cleanup(cancelOut)
leader.RollPass(t, roll)
mate.RollPass(t, roll)
select {
case <-allCh:
t.Logf("PASS all-pass packet for item=%d", roll.ItemID)
case <-time.After(90 * time.Second):
e2eharness.Assertf(t, "LOOT_ALL_PASSED not seen after both pass item=%d itemGUID=0x%X", roll.ItemID, roll.ItemGUID)
}
e2eharness.ProbeWorldAlive(t, leader, 22000)
}
*/
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/26862
// Creatures that spawn (or are damaged) below half HP must still grant loot on death.
func TestAC_26862_KillCreditLootSpawnBelowHalfHP(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "loot", "issue", "serial"},
Runtime: "med",
Issue: 26862,
Category: "social/loot",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "LootHP",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
entry := e2eharness.CreatureGroupLootFixture
guid, _ := bot.SpawnPersistent(t, entry, 15*time.Second)
bot.WaitUnitHPKnown(t, guid, 10*time.Second)
bot.DamageToFraction(t, guid, 0.49, 20*time.Second)
hp, maxHP := bot.UnitHP(guid)
if hp == 0 || maxHP == 0 || float64(hp)/float64(maxHP) > 0.5 {
e2eharness.Preconditionf(t, "#26862: want 0 < hp/max <= 0.5 before kill, got %d/%d", hp, maxHP)
}
bot.DamageKill(t, []uint64{guid}, 50_000_000, 25*time.Second)
bot.WaitUnitDead(t, guid, 20*time.Second)
bot.WaitUnitLootable(t, guid, 15*time.Second)
_ = bot.World.SetTarget(guid)
items, ok := bot.TryOpenLoot(t, guid, 10*time.Second)
if !ok {
e2eharness.Assertf(t, "below-half-HP kill: corpse not lootable (hp was %d/%d)", hp, maxHP)
}
bot.AssertWorldAlive(t)
t.Logf("PASS below-half-HP kill loot path items=%d", len(items))
}
// LOOT-06: master loot assign on group-loot fixture (guaranteed item slots).
func TestLoot_MasterLootAssign(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "loot", "multi_bot", "serial"}, Runtime: "med", Category: "social/loot"})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "LootML",
Bots: []e2eharness.BotSpec{
{Role: "master", Level: 80, LearnAllClass: true, Class: e2eharness.ClassWarrior},
{Role: "member", Level: 80, LearnAllClass: true, Class: e2eharness.ClassWarrior},
},
})
master := e2eharness.ByRole(t, bots, "master")
member := e2eharness.ByRole(t, bots, "member")
e2eharness.FormPartyAtPad(t, e2eharness.PackagePad(t), master, member)
// Threshold must be Uncommon+ for CMSG_LOOT_METHOD to apply (including master loot).
master.SetLootMethod(t, client.LootMethodMasterLoot, master.GUID, e2eharness.LootThresholdUncommon)
master.WaitLootMethod(t, client.LootMethodMasterLoot, 10*time.Second)
member.CombatReady(t)
guid := master.SpawnKillLootable(t, e2eharness.CreatureGroupLootFixture, 45*time.Second)
items, ok := master.TryOpenLoot(t, guid, 8*time.Second)
if !ok || len(items) == 0 {
e2eharness.Preconditionf(t, "master loot: no item slots on fixture %d", e2eharness.CreatureGroupLootFixture)
return
}
itemID := items[0].ItemID
before := member.InventoryCount(t, itemID)
master.MasterLootGive(t, guid, items[0].Index, member)
// Poll CharDB briefly — assign is async to bags.
deadline := time.Now().Add(15 * time.Second)
var after int
for time.Now().Before(deadline) {
after = member.InventoryCount(t, itemID)
if after > before {
break
}
time.Sleep(200 * time.Millisecond)
}
if after <= before {
e2eharness.Assertf(t, "master loot assign: member bag item=%d before=%d after=%d", itemID, before, after)
}
t.Logf("PASS master loot give entry=%d member_count %d→%d", itemID, before, after)
master.AssertWorldAlive(t)
}
// LOOT inventory oracle.
func TestLoot_InventoryCountOracle(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "loot", "items"}, Runtime: "short", Category: "social/loot"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "LootInv", Level: 40})
before := bot.InventoryCount(t, e2eharness.ItemTargetDummy)
bot.AddItemWait(t, e2eharness.ItemTargetDummy, 2)
bot.AssertInventoryAtLeast(t, e2eharness.ItemTargetDummy, before+2)
after := bot.InventoryCount(t, e2eharness.ItemTargetDummy)
if after < before+2 {
e2eharness.Assertf(t, "inventory count before=%d after=%d want +2", before, after)
}
t.Logf("PASS inventory count oracle %d→%d", before, after)
}
+299
View File
@@ -0,0 +1,299 @@
//go:build e2e
package trade_test
import (
"math"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/client"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// Cheap stackable trade bait (Linen Cloth).
const itemLinenCloth uint32 = 2589
func tradePair(t *testing.T, prefix string) (a, b *e2eharness.ScenarioBot) {
t.Helper()
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: prefix,
Bots: []e2eharness.BotSpec{
{Role: "trader_a", Level: 20},
{Role: "trader_b", Level: 20},
},
})
a = e2eharness.ByRole(t, bots, "trader_a")
b = e2eharness.ByRole(t, bots, "trader_b")
// Place both tightly (TRADE_DISTANCE) and combatstop — pad leftovers aggro trades.
pad := e2eharness.PackagePad(t)
a.Teleport(t, pad.X, pad.Y, pad.Z, pad.Map)
b.Teleport(t, pad.X+1.5, pad.Y, pad.Z, pad.Map)
a.CombatStop(t)
b.CombatStop(t)
return a, b
}
// walkAway sends real movement (start / heartbeat / stop), not a GM tele.
// Trade range is TRADE_DISTANCE (11.11y); yards should be larger than that.
func walkAway(t *testing.T, bot *e2eharness.ScenarioBot, from *e2eharness.ScenarioBot, yards float32) {
t.Helper()
ax, ay, az, _ := bot.Pos()
bx, by, _, _ := from.Pos()
dx, dy := ax-bx, ay-by
if n := float32(math.Hypot(float64(dx), float64(dy))); n < 0.1 {
dx, dy = 1, 0
} else {
dx, dy = dx/n, dy/n
}
o := float32(math.Atan2(float64(dy), float64(dx)))
midX, midY := ax+dx*yards*0.5, ay+dy*yards*0.5
destX, destY := ax+dx*yards, ay+dy*yards
if err := bot.World.SetFacingAt(ax, ay, az, o); err != nil {
e2eharness.HarnessFailf(t, "SetFacing: %v", err)
}
if err := bot.World.MoveForwardAt(ax, ay, az, o); err != nil {
e2eharness.HarnessFailf(t, "MoveForward: %v", err)
}
if err := bot.World.SendMovementHeartbeatAt(midX, midY, az, o); err != nil {
e2eharness.HarnessFailf(t, "heartbeat: %v", err)
}
if err := bot.World.MoveStopAt(destX, destY, az, o); err != nil {
e2eharness.HarnessFailf(t, "MoveStop: %v", err)
}
}
func inventoryCount(t *testing.T, bot *e2eharness.ScenarioBot, entry uint32) int {
t.Helper()
n := bot.InventoryCount(t, entry)
if n > 0 {
return n
}
// Packed ObjectGuid vs character_inventory.guid (low 32).
var n2 int
err := bot.CharDB.QueryRow(`
SELECT COALESCE(SUM(ii.count), 0)
FROM character_inventory ci
INNER JOIN item_instance ii ON ii.guid = ci.item
WHERE ci.guid=? AND ii.itemEntry=?`, bot.GUID&0xffffffff, entry).Scan(&n2)
if err != nil {
e2eharness.HarnessFailf(t, "inventoryCount low-guid: %v", err)
}
return n2
}
func waitInv(t *testing.T, bot *e2eharness.ScenarioBot, entry uint32, ok func(int) bool, timeout time.Duration) int {
t.Helper()
deadline := time.Now().Add(timeout)
var n int
for time.Now().Before(deadline) {
n = inventoryCount(t, bot, entry)
if ok(n) {
return n
}
time.Sleep(100 * time.Millisecond)
}
return n
}
func waitInvAtLeast(t *testing.T, bot *e2eharness.ScenarioBot, entry uint32, least int, timeout time.Duration) int {
t.Helper()
n := waitInv(t, bot, entry, func(got int) bool { return got >= least }, timeout)
if n < least {
e2eharness.Preconditionf(t, "inventory entry=%d count=%d want>=%d", entry, n, least)
}
return n
}
// Spec 1 — Item + gold dual-accept.
func TestTrade_ItemGoldDualAcceptInventories(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "trade", "multi_bot"}, Runtime: "short", Category: "social/trade"})
a, b := tradePair(t, "TrdOK")
const goldOffer uint32 = 5000
a.CombatStop(t)
b.CombatStop(t)
a.SetMoney(t, 50_000)
b.SetMoney(t, 10_000)
// CharDB oracle — live PLAYER_FIELD_COINAGE often lags under pad combat noise.
a.AssertMoneyAtLeast(t, 50_000)
b.AssertMoneyAtLeast(t, 10_000)
bag, slot := a.AddItemWait(t, itemLinenCloth, 1)
// CharDB lags the item-push; wait before using counts as the oracle.
aCount0 := waitInvAtLeast(t, a, itemLinenCloth, 1, 10*time.Second)
bCount0 := b.InventoryCount(t, itemLinenCloth)
aMoney0 := a.MoneyAfterSave(t)
bMoney0 := b.MoneyAfterSave(t)
e2eharness.OpenTrade(t, a, b)
a.SetTradeItem(t, 0, bag, slot)
a.SetTradeGold(t, goldOffer)
e2eharness.CompleteTrade(t, a, b)
aCount1 := waitInv(t, a, itemLinenCloth, func(n int) bool { return n == aCount0-1 }, 10*time.Second)
bCount1 := waitInv(t, b, itemLinenCloth, func(n int) bool { return n == bCount0+1 }, 10*time.Second)
aMoney1 := a.MoneyAfterSave(t)
bMoney1 := b.MoneyAfterSave(t)
if aCount1 != aCount0-1 {
e2eharness.Assertf(t, "A linen count %d→%d want -1", aCount0, aCount1)
}
if bCount1 != bCount0+1 {
e2eharness.Assertf(t, "B linen count %d→%d want +1", bCount0, bCount1)
}
// Money: A loses goldOffer, B gains goldOffer (tight bound after MoneyAfterSave).
const moneyTol int64 = 100
if int64(aMoney0)-int64(aMoney1) < int64(goldOffer)-moneyTol || int64(aMoney0)-int64(aMoney1) > int64(goldOffer)+moneyTol {
e2eharness.Assertf(t, "A money %d→%d want −%d (±%d)", aMoney0, aMoney1, goldOffer, moneyTol)
}
if int64(bMoney1)-int64(bMoney0) < int64(goldOffer)-moneyTol || int64(bMoney1)-int64(bMoney0) > int64(goldOffer)+moneyTol {
e2eharness.Assertf(t, "B money %d→%d want +%d (±%d)", bMoney0, bMoney1, goldOffer, moneyTol)
}
a.AssertWorldAlive(t)
b.AssertWorldAlive(t)
t.Logf("PASS dual-accept item+gold a_linen=%d→%d b_linen=%d→%d a$=%d→%d b$=%d→%d",
aCount0, aCount1, bCount0, bCount1, aMoney0, aMoney1, bMoney0, bMoney1)
}
// Spec 2 — Cancel mid-trade restores inventories.
func TestTrade_CancelMidTradeRestores(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "trade", "multi_bot"}, Runtime: "short", Category: "social/trade"})
a, b := tradePair(t, "TrdCan")
bag, slot := a.AddItemWait(t, itemLinenCloth, 1)
a.SetMoney(t, 20_000)
b.SetMoney(t, 5_000)
aCount0 := a.InventoryCount(t, itemLinenCloth)
bCount0 := b.InventoryCount(t, itemLinenCloth)
aMoney0 := a.MoneyAfterSave(t)
bMoney0 := b.MoneyAfterSave(t)
e2eharness.OpenTrade(t, a, b)
a.SetTradeItem(t, 0, bag, slot)
a.SetTradeGold(t, 1000)
a.CancelTrade(t)
_ = a.WaitTradeCancelled(t, 10*time.Second)
// B should also observe cancel/close (no fixed settle sleep).
_ = b.WaitTradeCancelled(t, 10*time.Second)
aCount1 := a.InventoryCount(t, itemLinenCloth)
bCount1 := b.InventoryCount(t, itemLinenCloth)
aMoney1 := a.MoneyAfterSave(t)
bMoney1 := b.MoneyAfterSave(t)
if aCount1 != aCount0 || bCount1 != bCount0 {
e2eharness.Assertf(t, "inventory changed after cancel A %d→%d B %d→%d", aCount0, aCount1, bCount0, bCount1)
}
if aMoney1 != aMoney0 || bMoney1 != bMoney0 {
e2eharness.Assertf(t, "money changed after cancel A %d→%d B %d→%d", aMoney0, aMoney1, bMoney0, bMoney1)
}
a.AssertWorldAlive(t)
t.Logf("PASS cancel restores inventories")
}
// Spec 3 — Move OOR mid-trade aborts cleanly.
func TestTrade_MoveOorMidTradeAborts(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "trade", "multi_bot", "issue"}, Runtime: "short", Issue: 25723, Category: "social/trade"})
a, b := tradePair(t, "TrdOOR")
bag, slot := a.AddItemWait(t, itemLinenCloth, 1)
aCount0 := a.InventoryCount(t, itemLinenCloth)
bCount0 := b.InventoryCount(t, itemLinenCloth)
e2eharness.OpenTrade(t, a, b)
a.SetTradeItem(t, 0, bag, slot)
// Walk past TRADE_DISTANCE (11.11y). Accept is the server's distance check.
walkAway(t, a, b, 15)
a.AcceptTrade(t)
info := a.WaitTradeStatus(t, client.TradeStatusTargetTooFar, 10*time.Second)
if info.Status != client.TradeStatusTargetTooFar {
e2eharness.Assertf(t, "want TARGET_TO_FAR after walk-OOR accept, got %s (do not client-cancel to invent PASS)",
client.TradeStatusName(info.Status))
}
aCount1 := a.InventoryCount(t, itemLinenCloth)
bCount1 := b.InventoryCount(t, itemLinenCloth)
if aCount1 != aCount0 || bCount1 != bCount0 {
e2eharness.Assertf(t, "inventory changed after OOR abort A %d→%d B %d→%d", aCount0, aCount1, bCount0, bCount1)
}
e2eharness.ProbeWorldAlive(t, b, 25723)
t.Logf("PASS OOR mid-trade abort status=%s open=%v", client.TradeStatusName(info.Status), a.World.TradeOpen())
}
// Spec 4 — Stackable merge.
func TestTrade_StackableMerge(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "trade", "multi_bot"}, Runtime: "short", Category: "social/trade"})
a, b := tradePair(t, "TrdStk")
// A has 5, B has 3; partial-stack trade API is not available — trade A's full stack of 5.
// After accept: totals conserved and B gains.
bag, slot := a.AddItemWait(t, itemLinenCloth, 5)
b.AddItemWait(t, itemLinenCloth, 3)
a0 := a.InventoryCount(t, itemLinenCloth)
b0 := b.InventoryCount(t, itemLinenCloth)
if a0 < 5 || b0 < 3 {
e2eharness.Preconditionf(t, "seed failed a=%d b=%d", a0, b0)
}
e2eharness.OpenTrade(t, a, b)
a.SetTradeItem(t, 0, bag, slot)
e2eharness.CompleteTrade(t, a, b)
a1 := a.InventoryCount(t, itemLinenCloth)
b1 := b.InventoryCount(t, itemLinenCloth)
if a1+b1 != a0+b0 {
e2eharness.Assertf(t, "linen not conserved %d+%d → %d+%d", a0, b0, a1, b1)
}
if b1 <= b0 {
e2eharness.Assertf(t, "B did not gain stack %d→%d", b0, b1)
}
t.Logf("PASS stackable merge/conserve a %d→%d b %d→%d", a0, a1, b0, b1)
}
// Spec 5 — Rapid open/close no crash (UAF stress).
func TestTrade_RapidOpenCloseNoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"short", "trade", "multi_bot", "issue"},
Runtime: "short",
Issue: 25723,
Category: "social/trade",
})
a, b := tradePair(t, "TrdSpm")
probe := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "TrdPrb", Level: 10})
probe.TeleportPad(t, e2eharness.PackagePad(t))
for i := 0; i < 8; i++ {
e2eharness.OpenTrade(t, a, b)
if i%3 == 0 {
a.CancelTrade(t)
_ = a.WaitTradeCancelled(t, 5*time.Second)
} else {
e2eharness.CompleteTrade(t, a, b)
}
// Re-seat both on pad without thrashing tele every cycle if already close.
if i%2 == 1 {
pad := e2eharness.PackagePad(t)
e2eharness.TeleportAllPad(t, []*e2eharness.ScenarioBot{a, b}, pad)
b.Teleport(t, pad.X+2, pad.Y, pad.Z, pad.Map)
}
}
// One walk-OOR accept in the mix (crash surface): TARGET_TO_FAR, no client cancel.
e2eharness.OpenTrade(t, a, b)
walkAway(t, a, b, 15)
a.AcceptTrade(t)
info := a.WaitTradeStatus(t, client.TradeStatusTargetTooFar, 10*time.Second)
if info.Status != client.TradeStatusTargetTooFar {
e2eharness.Assertf(t, "want TARGET_TO_FAR after walk-OOR in rapid stress, got %s",
client.TradeStatusName(info.Status))
}
e2eharness.ProbeWorldAlive(t, probe, 25723)
a.AssertWorldAlive(t)
b.AssertWorldAlive(t)
t.Logf("PASS rapid open/close no crash")
}
+247
View File
@@ -0,0 +1,247 @@
//go:build e2e
package aura_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/26130
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27021
// Mounting must not strip Blending In (45614) inside the quest area.
func TestAC_26130_BlendingInSurvivesMount(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"short", "spells", "issue"},
Runtime: "short",
Issue: 26130,
Category: "spells/aura",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "Blend",
Race: e2eharness.RaceOrc,
Class: e2eharness.ClassWarrior,
Level: 78,
})
bot.AddQuest(t, e2eharness.QuestBlendingIn)
// Temple City of En'kilah (issue repro coords).
bot.Teleport(t, 3758.2554, 3689.5754, 47.241505, e2eharness.MapNorthrend)
bot.ApplyAura(t, e2eharness.SpellBlendingInAura)
if !bot.HasAura(e2eharness.SpellBlendingInAura) {
e2eharness.Preconditionf(t, "ApplyAura did not yield Blending In %d", e2eharness.SpellBlendingInAura)
}
// Riding + Cold Weather Flying so a Northrend mount can apply.
bot.Learn(t, 33388) // Apprentice Riding
bot.Learn(t, 33391) // Journeyman Riding
bot.Learn(t, 34090) // Expert Riding
bot.Learn(t, 34091) // Artisan Riding
bot.Learn(t, 54197) // Cold Weather Flying
bot.Learn(t, e2eharness.SpellMountSwiftGryphon)
_ = bot.CastOrGM(t, e2eharness.SpellMountSwiftGryphon, 0, 10*time.Second)
if !bot.HasAura(e2eharness.SpellMountSwiftGryphon) {
bot.ApplyAura(t, e2eharness.SpellMountSwiftGryphon)
}
if !bot.HasAura(e2eharness.SpellMountSwiftGryphon) {
e2eharness.Preconditionf(t, "mount aura %d missing after CastOrGM/ApplyAura", e2eharness.SpellMountSwiftGryphon)
}
bot.AssertAuraRemains(t, e2eharness.SpellBlendingInAura, 800*time.Millisecond, 26130)
t.Logf("PASS AC#26130 Blending In aura survived mount")
}
// AURA-05: aura present after apply; gone after death+relog settle path.
func TestAura_ApplyAndQuery(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/aura"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "AuraQ",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.ApplyAura(t, e2eharness.SpellBlendingInAura)
if !bot.HasAura(e2eharness.SpellBlendingInAura) {
e2eharness.Preconditionf(t, "ApplyAura did not yield aura %d", e2eharness.SpellBlendingInAura)
}
t.Logf("PASS ApplyAura + HasAura")
}
// AURA-06: mid-aura relog keeps session healthy (duration continuity soft-check).
func TestAura_MidAuraRelogWorldAlive(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells", "protocol"}, Runtime: "short", Category: "spells/aura"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "AuraRl",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.ApplyAura(t, e2eharness.SpellBlendingInAura)
bot.Save(t)
bot.Relog(t)
bot.AssertWorldAlive(t)
// Aura may or may not persist depending on aura type; world alive is hard assert.
t.Logf("PASS mid-aura relog world alive has_aura=%v", bot.HasAura(e2eharness.SpellBlendingInAura))
}
// AURA-03: Fear is stripped by real spell damage while the victim lives.
// CastMust (not GM .damage). Player victim (dummies die, absorb, or flee).
// Orc: no Every Man for Himself. Entangling Roots after Fear: stay in front.
//
// L80 break threshold is warrior BaseHealth/4.75 ≈ 2648 (HandleBreakableCCAuraProc).
// Ice Lance 42914 is instant but often ~320–380 on a naked lock; 8 hits landed
// 2569 on CI and left Fear up. `.cheat cooldown` skips the 1.5s GCD so we can
// dump enough real CMSG_CAST_SPELL hits before the 10s PvP cap. Gone before 8s
// with an HP drop is the proc, not duration expiry.
func TestAura_BreakableCCRemovedByDamage(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "spells", "combat", "multi_bot"},
Runtime: "med",
Category: "spells/aura",
})
const (
spellFear = uint32(6215)
spellIceLance = uint32(42914) // rank 3, instant, no recovery
spellEntanglingRoots = uint32(53308) // long root; holds facing
itemArchus = uint32(50731) // best-effort +SP; not required
maxLances = 16
postHitWindow = 250 * time.Millisecond
fearBreakDeadline = 8 * time.Second
)
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "AuraCC",
Bots: []e2eharness.BotSpec{
{Role: "lock", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarlock, Level: 80},
{Role: "victim", Race: e2eharness.RaceOrc, Class: e2eharness.ClassWarrior, Level: 80},
},
})
lock := e2eharness.ByRole(t, bots, "lock")
victim := e2eharness.ByRole(t, bots, "victim")
pad := e2eharness.PackagePad(t)
lock.TeleportPad(t, pad)
victim.TeleportPad(t, pad)
lock.CombatReady(t)
lock.CheatPower(t)
// GCD is 1.5s; without this, 6 instants race the 10s Fear cap.
lock.GM(t, ".cheat cooldown on")
lock.FlushWorld(t)
lock.EquipEntry(t, itemArchus, 1)
victim.GM(t, ".gm off")
victim.GM(t, ".cheat god off")
e2eharness.EnableHostilePvP(t, lock, victim)
lock.WaitUnitGUID(t, victim.GUID, 10*time.Second)
lock.Learn(t, spellFear)
lock.Learn(t, spellIceLance)
var last e2eharness.SpellCastResult
feared := false
for attempt := 0; attempt < 5 && !feared; attempt++ {
if err := lock.World.SetTarget(victim.GUID); err != nil {
e2eharness.Preconditionf(t, "SetTarget victim: %v", err)
}
lock.Face(t, victim.GUID)
last = lock.Cast(t, spellFear, victim.GUID, 10*time.Second)
if !last.Success {
if e2eharness.SpellFailReasonName(last.FailReason) == "BAD_TARGETS" {
e2eharness.EnableHostilePvP(t, lock, victim)
}
continue
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if victim.HasAura(spellFear) {
feared = true
break
}
time.Sleep(40 * time.Millisecond)
}
}
if !feared {
e2eharness.Preconditionf(t, "Fear %d not on victim (last success=%v reason=%s)",
spellFear, last.Success, e2eharness.SpellFailReasonName(last.FailReason))
}
victim.ApplyAura(t, spellEntanglingRoots)
if !victim.HasAura(spellFear) {
e2eharness.Preconditionf(t, "Fear %d lost when applying Entangling Roots", spellFear)
}
if !victim.HasAura(spellEntanglingRoots) {
e2eharness.Preconditionf(t, "Entangling Roots %d missing (needed to hold facing)", spellEntanglingRoots)
}
fearedAt := time.Now()
hpBefore, maxHP := victim.World.Health(), victim.World.MaxHealth()
if maxHP == 0 || hpBefore == 0 {
e2eharness.Preconditionf(t, "victim hp unknown (%d/%d)", hpBefore, maxHP)
}
hits := 0
var brokenAt time.Time
for hits < maxLances {
if !victim.HasAura(spellFear) {
brokenAt = time.Now()
break
}
if err := lock.World.SetTarget(victim.GUID); err != nil {
e2eharness.Preconditionf(t, "SetTarget before Ice Lance: %v", err)
}
lock.Face(t, victim.GUID)
lock.CastMust(t, spellIceLance, victim.GUID, 10*time.Second)
hits++
hpNow := victim.World.Health()
t.Logf("lance %d hp %d→%d Δ=%d fear=%v", hits, hpBefore, hpNow, int(hpBefore)-int(hpNow), victim.HasAura(spellFear))
if victim.TryWaitAuraGone(t, spellFear, postHitWindow) {
brokenAt = time.Now()
break
}
}
hpAfter := victim.World.Health()
if brokenAt.IsZero() {
brokenAt = time.Now()
}
elapsed := brokenAt.Sub(fearedAt)
if hpAfter == 0 {
e2eharness.Assertf(t, "victim died (hp %d→0 / %d) after %d Ice Lance — death is not a CC-break proof",
hpBefore, maxHP, hits)
}
if hpAfter >= hpBefore {
e2eharness.Assertf(t, "victim HP did not drop (%d→%d / %d) after %d Ice Lance — need real damage",
hpBefore, hpAfter, maxHP, hits)
}
if elapsed >= fearBreakDeadline {
e2eharness.Assertf(t, "Fear dropped after %s (PvP duration 10s) — not a damage proof (hp %d→%d)",
elapsed.Round(time.Millisecond), hpBefore, hpAfter)
}
if victim.HasAura(spellFear) {
e2eharness.Assertf(t, "Fear %d still on victim after %d Ice Lance in %s (hp %d→%d / %d)",
spellFear, hits, elapsed.Round(time.Millisecond), hpBefore, hpAfter, maxHP)
}
lock.AssertWorldAlive(t)
t.Logf("PASS Fear broken by Ice Lance n=%d in %s victim hp %d→%d / %d",
hits, elapsed.Round(time.Millisecond), hpBefore, hpAfter, maxHP)
}
// AURA-01: exclusive / replace — apply stronger after weaker (soft observational).
func TestAura_ApplyMultipleDistinctAuras(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/aura"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "AuraMx",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.ApplyAura(t, e2eharness.SpellBlendingInAura)
bot.ApplyAura(t, e2eharness.SpellBattleStance)
if !bot.HasAura(e2eharness.SpellBlendingInAura) {
e2eharness.Preconditionf(t, "lost blending-in after second ApplyAura")
}
if !bot.HasAura(e2eharness.SpellBattleStance) {
e2eharness.Assertf(t, "Battle Stance missing after ApplyAura")
}
t.Logf("PASS multi-aura apply (stance present=%v)", bot.HasAura(e2eharness.SpellBattleStance))
}
+185
View File
@@ -0,0 +1,185 @@
//go:build e2e
package cast_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// CAST-01: live cast pipeline — Charge succeeds on spawned dummy after CombatReady.
// WotLK Charge (rank 3 = 11578): Requires Battle Stance, 8–25 yd, out of combat.
func TestCast_ChargeSucceedsOnDummy(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells", "combat"}, Runtime: "short", Category: "spells/cast"})
const spellChargeRank3 = uint32(11578)
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "CastOk",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
// PackagePad only for isolation. Charge needs pathable flat ground
// (PackagePad mountain pads return SPELL_FAILED_NOPATH=56).
bot.TeleportPad(t, e2eharness.PackagePad(t))
// Northshire open strip (map 0) — short, flat, mmaps-friendly.
const (
chargeX float32 = -8904.0
chargeY float32 = -128.0
chargeZ float32 = 81.0
chargeMap uint32 = 0
)
bot.Teleport(t, chargeX, chargeY, chargeZ, chargeMap)
bot.CombatStop(t)
bot.CombatReadyFull(t) // gm off + god + power + FlushWorld
bot.CombatStop(t)
bot.FlushWorld(t)
bot.Learn(t, e2eharness.SpellBattleStance)
bot.Learn(t, spellChargeRank3)
// Client Battle Stance so form is real (not CastOrGM fake-success).
bot.CastMust(t, e2eharness.SpellBattleStance, 0, 10*time.Second)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if bot.HasAura(e2eharness.SpellBattleStance) {
break
}
time.Sleep(40 * time.Millisecond)
}
if !bot.HasAura(e2eharness.SpellBattleStance) {
e2eharness.Preconditionf(t, "Battle Stance missing before Charge")
}
// 31146 is long-lived (2673 KillSelfs at 15s — Charge setup exceeds that).
dummy := bot.Spawn(t, e2eharness.CreatureHeroicTrainingDummy, 15*time.Second)
// Charge range is 8–25y; spawn is on the player — step back ~12y on flat ground.
bot.Teleport(t, chargeX+12, chargeY, chargeZ, chargeMap)
_ = bot.World.SetTarget(dummy)
bot.Face(t, dummy)
bot.CastMust(t, spellChargeRank3, dummy, 10*time.Second)
bot.AssertWorldAlive(t)
t.Logf("PASS Charge succeeded on dummy")
}
// CAST-02: out-of-range / fail path does not crash.
func TestCast_FailPathNoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/cast"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "CastFl",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
// Charge with no target: core often sends no SPELL_CAST_FAILED (TryCast times
// out). That is still a fail path. Require no SPELL_GO and a live world.
res, err := bot.TryCast(t, e2eharness.SpellCharge, 0, 5*time.Second)
if err != nil {
t.Logf("TryCast Charge target=0: %v (no cast result)", err)
} else if res.Success {
e2eharness.Assertf(t, "Charge with no target succeeded")
} else {
t.Logf("Charge no-target fail reason=%s", e2eharness.SpellFailReasonName(res.FailReason))
}
bot.AssertWorldAlive(t)
t.Logf("PASS cast fail path no crash")
}
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27061
// Raise Dead near a player corpse must succeed (ghoul) and must not crash
// WorldObjectSpellAreaTargetCheck on the Corpse grid.
func TestAC_27061_RaiseDeadNearCorpseNoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "spells", "issue", "serial"},
Runtime: "med",
Issue: 27061,
Category: "spells/cast",
})
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "Raise",
Bots: []e2eharness.BotSpec{
{Role: "dk", Race: e2eharness.RaceHuman, Class: e2eharness.ClassDeathKnight, Level: 80, LearnAllClass: true},
{Role: "corpse", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80},
{Role: "probe", Level: 10},
},
})
dk := e2eharness.ByRole(t, bots, "dk")
corpse := e2eharness.ByRole(t, bots, "corpse")
probe := e2eharness.ByRole(t, bots, "probe")
e2eharness.TeleportAllPad(t, []*e2eharness.ScenarioBot{dk, corpse}, e2eharness.PackagePad(t))
corpse.DieMust(t, 20*time.Second)
dk.Learn(t, e2eharness.SpellRaiseDead)
dk.AddItem(t, e2eharness.ItemCorpseDust, 5)
dk.CombatReady(t)
dk.CheatPower(t)
dk.FlushWorld(t)
res, err := dk.TryCast(t, e2eharness.SpellRaiseDead, 0, 20*time.Second)
if err != nil {
e2eharness.ProbeWorldAlive(t, probe, 27061)
e2eharness.HarnessFailf(t, "AC#27061: no Raise Dead cast result (world still up): %v", err)
}
e2eharness.ProbeWorldAlive(t, probe, 27061)
if !res.Success {
e2eharness.Assertf(t, "Raise Dead failed reason=%s (need SPELL_GO + ghoul)",
e2eharness.SpellFailReasonName(res.FailReason))
}
pet := dk.WaitPlayerPet(t, 20*time.Second)
if pet == 0 {
e2eharness.Assertf(t, "Raise Dead SPELL_GO but no ghoul (UNIT_FIELD_SUMMON / SUMMONEDBY)")
}
dk.CleanupOwnedSummons(t)
t.Logf("PASS AC#27061 Raise Dead near corpse spawned pet=0x%X", pet)
}
// CAST-04: client CastMust battle stance on self — aura must apply (no CastOrGM GM-fake).
func TestCast_BattleStanceSelf(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/cast"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "CastBS",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.Learn(t, e2eharness.SpellBattleStance)
// Client cast path only — CastMust fatals if SPELL_GO/success missing.
bot.CastMust(t, e2eharness.SpellBattleStance, 0, 10*time.Second)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if bot.HasAura(e2eharness.SpellBattleStance) {
break
}
time.Sleep(40 * time.Millisecond)
}
if !bot.HasAura(e2eharness.SpellBattleStance) {
e2eharness.Assertf(t, "battle stance aura %d missing after successful cast", e2eharness.SpellBattleStance)
}
bot.AssertWorldAlive(t)
t.Logf("PASS battle stance self-cast + aura")
}
// CAST-05: CastAtPosition ground AoE does not crash.
func TestCast_GroundAoENoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/cast"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "CastAoE",
Class: e2eharness.ClassWarlock,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
x, y, z, _ := bot.Pos()
_ = bot.CastAtPosition(t, e2eharness.SpellRainOfFire, x, y, z, 10*time.Second)
bot.AssertWorldAlive(t)
t.Logf("PASS ground AoE cast path")
}
@@ -0,0 +1,233 @@
//go:build e2e
package effects_test
import (
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// OPEN(e2e): re-enable when AC#26774 is fixed
// https://github.com/azerothcore/azerothcore-wotlk/issues/26774
// Must assert client item-use / dummy rank (not CastOrGM + any 2673).
/*
func TestEffects_TargetDummySummon(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"short", "spells", "issue"},
Runtime: "short",
Issue: 26774,
Category: "spells/effects",
})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "FxDummy",
Level: 80,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.AddItem(t, e2eharness.ItemTargetDummy, 1)
bot.Learn(t, e2eharness.SpellSummonTargetDummy)
_ = bot.CastOrGM(t, e2eharness.SpellSummonTargetDummy, 0, 10*time.Second)
u := bot.WaitUnit(t, e2eharness.CreatureTargetDummy, 15*time.Second)
if u == 0 {
e2eharness.Preconditionf(t, "target dummy not observed")
}
bot.AssertWorldAlive(t)
t.Logf("PASS target dummy summon guid=0x%X", u)
}
*/
// FX-02: Charge effect moves player (warrior).
// WotLK Charge rank 3 (11578): Battle Stance, 8–25 yd, out of combat.
func TestEffects_ChargeEffect(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells", "combat"}, Runtime: "short", Category: "spells/effects"})
const spellChargeRank3 = uint32(11578)
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "FxChg",
Class: e2eharness.ClassWarrior,
Level: 80,
LearnAllClass: true,
})
// PackagePad is isolation only — mountain pads return SPELL_FAILED_NOPATH (56).
// Northshire open strip (map 0) is flat and mmaps-friendly for Charge.
bot.TeleportPad(t, e2eharness.PackagePad(t))
const (
chargeX float32 = -8904.0
chargeY float32 = -128.0
chargeZ float32 = 81.0
chargeMap uint32 = 0
)
bot.Teleport(t, chargeX, chargeY, chargeZ, chargeMap)
bot.CombatStop(t)
bot.CombatReadyFull(t)
bot.CombatStop(t)
bot.FlushWorld(t)
bot.Learn(t, e2eharness.SpellBattleStance)
bot.Learn(t, spellChargeRank3)
bot.CastMust(t, e2eharness.SpellBattleStance, 0, 10*time.Second)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if bot.HasAura(e2eharness.SpellBattleStance) {
break
}
time.Sleep(40 * time.Millisecond)
}
if !bot.HasAura(e2eharness.SpellBattleStance) {
e2eharness.Preconditionf(t, "Battle Stance missing before Charge")
}
// 31146 is long-lived (2673 KillSelfs at 15s — Charge setup exceeds that).
dummy := bot.Spawn(t, e2eharness.CreatureHeroicTrainingDummy, 15*time.Second)
// 8–25 yd charge range on flat ground.
bot.Teleport(t, chargeX+12, chargeY, chargeZ, chargeMap)
x0, y0, z0, _ := bot.Pos()
_ = bot.World.SetTarget(dummy)
bot.Face(t, dummy)
bot.CastMust(t, spellChargeRank3, dummy, 10*time.Second)
// Snapshot is the step-back point; Charge must close toward the dummy.
deadline = time.Now().Add(2 * time.Second)
var moved float32
for time.Now().Before(deadline) {
x1, y1, z1, _ := bot.Pos()
moved = e2eharness.Distance3D(x0, y0, z0, x1, y1, z1)
if moved >= 1.0 {
break
}
time.Sleep(40 * time.Millisecond)
}
if moved < 1.0 {
e2eharness.Assertf(t, "Charge SPELL_GO ok but player did not leave step-back (moved=%.1f)", moved)
}
bot.AssertWorldAlive(t)
t.Logf("PASS charge effect moved=%.1fy", moved)
}
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/26997
// Sweeping Strikes + Execute on a ≤20% target with a second living ≤20% target
// nearby must not crash (CheckProc stored a raw Unit* that HandleProc deref'd).
// Training dummies absorb damage (npc_training_dummy::DamageTaken = 0) so they
// never enter Execute's 20% HealthState. Player-vs-player in Northshire fails
// SPELL_FAILED_BAD_TARGETS (sanctuary / PvP flag race). Use two hostile mobs.
func TestAC_26997_SweepingStrikesExecuteNoCrash(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "spells", "issue", "multi_bot", "serial"},
Runtime: "med",
Issue: 26997,
Category: "spells/effects",
})
const (
spellExecuteMax = uint32(47471) // WotLK Execute rank 9
// High-HP test dummies with empty ScriptName (they take damage). L1-L6
// world mobs die to a L80 autoattack during DamageToFraction.
unkillableDummy80 = uint32(32171)
unkillableDummy80Armor = uint32(32847) // different entry — Spawn despawns nearby same-entry
)
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
Prefix: "SwpEx",
Bots: []e2eharness.BotSpec{
{Role: "arms", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
{Role: "probe", Level: 10},
},
})
arms := e2eharness.ByRole(t, bots, "arms")
probe := e2eharness.ByRole(t, bots, "probe")
// Northshire strip — flat, melee range for Sweeping Strikes extra attack.
const (
x, y, z float32 = -8904.0, -128.0, 81.0
m uint32 = 0
)
arms.Teleport(t, x, y, z, m)
probe.Teleport(t, x-8, y, z, m)
arms.CombatReadyFull(t)
arms.Learn(t, e2eharness.SpellBattleStance)
arms.Learn(t, e2eharness.SpellSweepingStrikes)
arms.Learn(t, spellExecuteMax)
arms.CastMust(t, e2eharness.SpellBattleStance, 0, 10*time.Second)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if arms.HasAura(e2eharness.SpellBattleStance) {
break
}
time.Sleep(40 * time.Millisecond)
}
if !arms.HasAura(e2eharness.SpellBattleStance) {
e2eharness.Preconditionf(t, "Battle Stance missing before Execute")
}
c1 := arms.Spawn(t, unkillableDummy80, 15*time.Second)
c2 := arms.Spawn(t, unkillableDummy80Armor, 15*time.Second)
arms.CombatStop(t)
if c1 == 0 || c2 == 0 {
e2eharness.Preconditionf(t, "failed to spawn Execute targets c1=0x%X c2=0x%X", c1, c2)
}
arms.DamageToFraction(t, c1, 0.19, 20*time.Second)
arms.DamageToFraction(t, c2, 0.19, 20*time.Second)
for _, v := range []struct {
name string
g uint64
}{{"c1", c1}, {"c2", c2}} {
hp, maxHP := arms.UnitHP(v.g)
if maxHP == 0 || hp == 0 || float64(hp)/float64(maxHP) > 0.2 {
e2eharness.Preconditionf(t, "%s not in execute range (hp=%d/%d guid=0x%X)", v.name, hp, maxHP, v.g)
}
}
arms.CastMust(t, e2eharness.SpellSweepingStrikes, 0, 10*time.Second)
if !arms.HasAura(e2eharness.SpellSweepingStrikes) {
e2eharness.Preconditionf(t, "Sweeping Strikes aura %d missing", e2eharness.SpellSweepingStrikes)
}
_ = arms.World.SetTarget(c1)
arms.Face(t, c1)
res, err := arms.TryCast(t, spellExecuteMax, c1, 15*time.Second)
if err != nil {
e2eharness.ProbeWorldAlive(t, probe, 26997)
e2eharness.HarnessFailf(t, "AC#26997: no Execute cast result: %v", err)
}
e2eharness.ProbeWorldAlive(t, probe, 26997)
if !res.Success {
e2eharness.Assertf(t, "Execute failed reason=%s", e2eharness.SpellFailReasonName(res.FailReason))
}
t.Logf("PASS AC#26997 Sweeping Strikes Execute world alive (c1=0x%X c2=0x%X)", c1, c2)
}
// FX-04: grounding totem summon exists (#26584 ecosystem).
func TestEffects_GroundingTotemSummon(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/effects"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "FxTot",
Race: e2eharness.RaceOrc,
Class: e2eharness.ClassShaman,
Level: 80,
LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
bot.GiveTotems(t)
bot.CombatReady(t)
_ = bot.CastOrGM(t, e2eharness.SpellGroundingTotem, 0, 10*time.Second)
totem := bot.WaitUnit(t, e2eharness.CreatureGroundingTotem, 15*time.Second)
bot.AssertWorldAlive(t)
t.Logf("PASS grounding totem summon guid=0x%X", totem)
}
// FX-05: Create-item / learn path for dummy reagents stays healthy.
func TestEffects_AddItemCreatePath(t *testing.T) {
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells", "items"}, Runtime: "short", Category: "spells/effects"})
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "FxItem",
Level: 80,
})
bot.AddItemWait(t, e2eharness.ItemCorpseDust, 3)
bot.AssertInventoryAtLeast(t, e2eharness.ItemCorpseDust, 3)
t.Logf("PASS create-item seed path count>=3")
}
@@ -66,6 +66,7 @@ SmartAI::SmartAI(Creature* c) : CreatureAI(c)
mFollowCreditType = 0;
mFollowArrivedAlive = 0;
mFollowArrivedTimer = 0;
_followCheckTimer = 0;
mInvincibilityHpLevel = 0;
mJustReset = false;
@@ -1269,6 +1270,7 @@ void SmartAI::SetFollow(Unit* target, float dist, float angle, uint32 credit, ui
mFollowDist = dist;
mFollowAngle = angle;
mFollowArrivedTimer = 1000;
_followCheckTimer = 0;
mFollowCredit = credit;
mFollowArrivedEntry = end;
mFollowArrivedAlive = !aliveState; // negate - 0 is alive