From 887538fe671c6adbbd48ba61a7cbc29fc69ff71b Mon Sep 17 00:00:00 2001 From: Anton Popovichenko Date: Thu, 27 Aug 2026 14:57:14 +0200 Subject: [PATCH] feat(Core/e2e): live protocol e2e suite (#27158) --- .agents/docs/e2e-policy.md | 172 +++++++ .editorconfig | 5 + .github/workflows/core-build-nopch.yml | 55 ++- .github/workflows/dashboard-ci.yml | 1 - .github/workflows/e2e-live.yml | 421 ++++++++++++++++++ .gitignore | 7 + AGENTS.md | 4 + e2e/.env.example | 12 + e2e/Makefile | 54 +++ e2e/README.md | 361 +++++++++++++++ e2e/go.mod | 14 + e2e/go.sum | 6 + e2e/go.work.example | 11 + e2e/internal/fixtures/pads.go | 24 + e2e/internal/meta/meta.go | 113 +++++ e2e/internal/meta/meta_test.go | 45 ++ e2e/local/README.md | 63 +++ e2e/smoke/smoke_e2e_test.go | 111 +++++ e2e/suites/combat/charm/charm_e2e_test.go | 165 +++++++ e2e/suites/combat/death/death_e2e_test.go | 136 ++++++ e2e/suites/combat/pets/pets_e2e_test.go | 151 +++++++ e2e/suites/combat/threat/threat_e2e_test.go | 195 ++++++++ .../combat/vehicles/vehicles_e2e_test.go | 93 ++++ .../guild/charter_bank/guild_e2e_test.go | 81 ++++ .../bind_reset/bind_reset_e2e_test.go | 252 +++++++++++ .../northrend/ulduar/ulduar_e2e_test.go | 256 +++++++++++ e2e/suites/items/equip/equip_e2e_test.go | 109 +++++ .../protocol/session/session_e2e_test.go | 141 ++++++ .../protocol/teleport/teleport_e2e_test.go | 102 +++++ e2e/suites/quests/escort/escort_e2e_test.go | 108 +++++ .../quests/lifecycle/lifecycle_e2e_test.go | 123 +++++ e2e/suites/social/group/group_e2e_test.go | 126 ++++++ e2e/suites/social/loot/loot_e2e_test.go | 320 +++++++++++++ e2e/suites/social/trade/trade_e2e_test.go | 299 +++++++++++++ e2e/suites/spells/aura/aura_e2e_test.go | 247 ++++++++++ e2e/suites/spells/cast/cast_e2e_test.go | 185 ++++++++ e2e/suites/spells/effects/effects_e2e_test.go | 233 ++++++++++ src/server/game/AI/SmartScripts/SmartAI.cpp | 2 + 38 files changed, 4798 insertions(+), 5 deletions(-) create mode 100644 .agents/docs/e2e-policy.md create mode 100644 .github/workflows/e2e-live.yml create mode 100644 e2e/.env.example create mode 100644 e2e/Makefile create mode 100644 e2e/README.md create mode 100644 e2e/go.mod create mode 100644 e2e/go.sum create mode 100644 e2e/go.work.example create mode 100644 e2e/internal/fixtures/pads.go create mode 100644 e2e/internal/meta/meta.go create mode 100644 e2e/internal/meta/meta_test.go create mode 100644 e2e/local/README.md create mode 100644 e2e/smoke/smoke_e2e_test.go create mode 100644 e2e/suites/combat/charm/charm_e2e_test.go create mode 100644 e2e/suites/combat/death/death_e2e_test.go create mode 100644 e2e/suites/combat/pets/pets_e2e_test.go create mode 100644 e2e/suites/combat/threat/threat_e2e_test.go create mode 100644 e2e/suites/combat/vehicles/vehicles_e2e_test.go create mode 100644 e2e/suites/guild/charter_bank/guild_e2e_test.go create mode 100644 e2e/suites/instances/bind_reset/bind_reset_e2e_test.go create mode 100644 e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go create mode 100644 e2e/suites/items/equip/equip_e2e_test.go create mode 100644 e2e/suites/protocol/session/session_e2e_test.go create mode 100644 e2e/suites/protocol/teleport/teleport_e2e_test.go create mode 100644 e2e/suites/quests/escort/escort_e2e_test.go create mode 100644 e2e/suites/quests/lifecycle/lifecycle_e2e_test.go create mode 100644 e2e/suites/social/group/group_e2e_test.go create mode 100644 e2e/suites/social/loot/loot_e2e_test.go create mode 100644 e2e/suites/social/trade/trade_e2e_test.go create mode 100644 e2e/suites/spells/aura/aura_e2e_test.go create mode 100644 e2e/suites/spells/cast/cast_e2e_test.go create mode 100644 e2e/suites/spells/effects/effects_e2e_test.go diff --git a/.agents/docs/e2e-policy.md b/.agents/docs/e2e-policy.md new file mode 100644 index 0000000000..ca4fa1c593 --- /dev/null +++ b/.agents/docs/e2e-policy.md @@ -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__`. 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__…`; 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__` 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. diff --git a/.editorconfig b/.editorconfig index 860e636265..9349644372 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/.github/workflows/core-build-nopch.yml b/.github/workflows/core-build-nopch.yml index 5feba90074..aff307ba31 100644 --- a/.github/workflows/core-build-nopch.yml +++ b/.github/workflows/core-build-nopch.yml @@ -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 }} diff --git a/.github/workflows/dashboard-ci.yml b/.github/workflows/dashboard-ci.yml index c1fa7151da..13ef2d63cf 100644 --- a/.github/workflows/dashboard-ci.yml +++ b/.github/workflows/dashboard-ci.yml @@ -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 diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml new file mode 100644 index 0000000000..0010e7bc92 --- /dev/null +++ b/.github/workflows/e2e-live.yml @@ -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 diff --git a/.gitignore b/.gitignore index 6e6ad78f44..8f26a9e16d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index fb1b06047f..1c3d4b3bbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//` (gitignored), named `..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. diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 0000000000..c976ff6b7a --- /dev/null +++ b/e2e/.env.example @@ -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 diff --git a/e2e/Makefile b/e2e/Makefile new file mode 100644 index 0000000000..83c76f1b58 --- /dev/null +++ b/e2e/Makefile @@ -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__* 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) ./... diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000000..879d5d86f6 --- /dev/null +++ b/e2e/README.md @@ -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//…` | Hierarchical scenarios (directory = category) | +| `suites//` | Suites by domain; issue guards named `TestAC__…` 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:`. diff --git a/e2e/go.mod b/e2e/go.mod new file mode 100644 index 0000000000..39b361ee81 --- /dev/null +++ b/e2e/go.mod @@ -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. diff --git a/e2e/go.sum b/e2e/go.sum new file mode 100644 index 0000000000..9d4358ea7f --- /dev/null +++ b/e2e/go.sum @@ -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= diff --git a/e2e/go.work.example b/e2e/go.work.example new file mode 100644 index 0000000000..711a86b71c --- /dev/null +++ b/e2e/go.work.example @@ -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 diff --git a/e2e/internal/fixtures/pads.go b/e2e/internal/fixtures/pads.go new file mode 100644 index 0000000000..559a10ff5b --- /dev/null +++ b/e2e/internal/fixtures/pads.go @@ -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 +) diff --git a/e2e/internal/meta/meta.go b/e2e/internal/meta/meta.go new file mode 100644 index 0000000000..a86d1492ec --- /dev/null +++ b/e2e/internal/meta/meta.go @@ -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 +} diff --git a/e2e/internal/meta/meta_test.go b/e2e/internal/meta/meta_test.go new file mode 100644 index 0000000000..70ec72fc78 --- /dev/null +++ b/e2e/internal/meta/meta_test.go @@ -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"}) +} diff --git a/e2e/local/README.md b/e2e/local/README.md new file mode 100644 index 0000000000..3664820abd --- /dev/null +++ b/e2e/local/README.md @@ -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`. diff --git a/e2e/smoke/smoke_e2e_test.go b/e2e/smoke/smoke_e2e_test.go new file mode 100644 index 0000000000..013d07580e --- /dev/null +++ b/e2e/smoke/smoke_e2e_test.go @@ -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) +} diff --git a/e2e/suites/combat/charm/charm_e2e_test.go b/e2e/suites/combat/charm/charm_e2e_test.go new file mode 100644 index 0000000000..933db110b5 --- /dev/null +++ b/e2e/suites/combat/charm/charm_e2e_test.go @@ -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()) +} diff --git a/e2e/suites/combat/death/death_e2e_test.go b/e2e/suites/combat/death/death_e2e_test.go new file mode 100644 index 0000000000..57822d3835 --- /dev/null +++ b/e2e/suites/combat/death/death_e2e_test.go @@ -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()) +} diff --git a/e2e/suites/combat/pets/pets_e2e_test.go b/e2e/suites/combat/pets/pets_e2e_test.go new file mode 100644 index 0000000000..0266df2602 --- /dev/null +++ b/e2e/suites/combat/pets/pets_e2e_test.go @@ -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") +} diff --git a/e2e/suites/combat/threat/threat_e2e_test.go b/e2e/suites/combat/threat/threat_e2e_test.go new file mode 100644 index 0000000000..7d369b6d5d --- /dev/null +++ b/e2e/suites/combat/threat/threat_e2e_test.go @@ -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)) +} diff --git a/e2e/suites/combat/vehicles/vehicles_e2e_test.go b/e2e/suites/combat/vehicles/vehicles_e2e_test.go new file mode 100644 index 0000000000..9eb52da483 --- /dev/null +++ b/e2e/suites/combat/vehicles/vehicles_e2e_test.go @@ -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) +} + + diff --git a/e2e/suites/guild/charter_bank/guild_e2e_test.go b/e2e/suites/guild/charter_bank/guild_e2e_test.go new file mode 100644 index 0000000000..a6e073ea62 --- /dev/null +++ b/e2e/suites/guild/charter_bank/guild_e2e_test.go @@ -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) +} diff --git a/e2e/suites/instances/bind_reset/bind_reset_e2e_test.go b/e2e/suites/instances/bind_reset/bind_reset_e2e_test.go new file mode 100644 index 0000000000..51570c3517 --- /dev/null +++ b/e2e/suites/instances/bind_reset/bind_reset_e2e_test.go @@ -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) +} + + diff --git a/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go b/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go new file mode 100644 index 0000000000..c76c384fdd --- /dev/null +++ b/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go @@ -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) +} diff --git a/e2e/suites/items/equip/equip_e2e_test.go b/e2e/suites/items/equip/equip_e2e_test.go new file mode 100644 index 0000000000..675951817e --- /dev/null +++ b/e2e/suites/items/equip/equip_e2e_test.go @@ -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) +} diff --git a/e2e/suites/protocol/session/session_e2e_test.go b/e2e/suites/protocol/session/session_e2e_test.go new file mode 100644 index 0000000000..14fa44ce24 --- /dev/null +++ b/e2e/suites/protocol/session/session_e2e_test.go @@ -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)") +} diff --git a/e2e/suites/protocol/teleport/teleport_e2e_test.go b/e2e/suites/protocol/teleport/teleport_e2e_test.go new file mode 100644 index 0000000000..d5d2a1a1f8 --- /dev/null +++ b/e2e/suites/protocol/teleport/teleport_e2e_test.go @@ -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) +} diff --git a/e2e/suites/quests/escort/escort_e2e_test.go b/e2e/suites/quests/escort/escort_e2e_test.go new file mode 100644 index 0000000000..75dde5d025 --- /dev/null +++ b/e2e/suites/quests/escort/escort_e2e_test.go @@ -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) +} diff --git a/e2e/suites/quests/lifecycle/lifecycle_e2e_test.go b/e2e/suites/quests/lifecycle/lifecycle_e2e_test.go new file mode 100644 index 0000000000..07a675dc1c --- /dev/null +++ b/e2e/suites/quests/lifecycle/lifecycle_e2e_test.go @@ -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") +} diff --git a/e2e/suites/social/group/group_e2e_test.go b/e2e/suites/social/group/group_e2e_test.go new file mode 100644 index 0000000000..5f87b21224 --- /dev/null +++ b/e2e/suites/social/group/group_e2e_test.go @@ -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") +} diff --git a/e2e/suites/social/loot/loot_e2e_test.go b/e2e/suites/social/loot/loot_e2e_test.go new file mode 100644 index 0000000000..29daa726f6 --- /dev/null +++ b/e2e/suites/social/loot/loot_e2e_test.go @@ -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) +} diff --git a/e2e/suites/social/trade/trade_e2e_test.go b/e2e/suites/social/trade/trade_e2e_test.go new file mode 100644 index 0000000000..72c164eedf --- /dev/null +++ b/e2e/suites/social/trade/trade_e2e_test.go @@ -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") +} diff --git a/e2e/suites/spells/aura/aura_e2e_test.go b/e2e/suites/spells/aura/aura_e2e_test.go new file mode 100644 index 0000000000..d8c8d84783 --- /dev/null +++ b/e2e/suites/spells/aura/aura_e2e_test.go @@ -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)) +} diff --git a/e2e/suites/spells/cast/cast_e2e_test.go b/e2e/suites/spells/cast/cast_e2e_test.go new file mode 100644 index 0000000000..7e79a4b20d --- /dev/null +++ b/e2e/suites/spells/cast/cast_e2e_test.go @@ -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") +} diff --git a/e2e/suites/spells/effects/effects_e2e_test.go b/e2e/suites/spells/effects/effects_e2e_test.go new file mode 100644 index 0000000000..5de3c9a901 --- /dev/null +++ b/e2e/suites/spells/effects/effects_e2e_test.go @@ -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") +} diff --git a/src/server/game/AI/SmartScripts/SmartAI.cpp b/src/server/game/AI/SmartScripts/SmartAI.cpp index 8d20fe6689..5ae819f63e 100644 --- a/src/server/game/AI/SmartScripts/SmartAI.cpp +++ b/src/server/game/AI/SmartScripts/SmartAI.cpp @@ -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