mirror of
https://gitcode.com/GitHub_Trending/az/azerothcore-wotlk.git
synced 2026-10-10 07:06:38 +08:00
feat(Core/e2e): live protocol e2e suite (#27158)
This commit is contained in:
@@ -0,0 +1,172 @@
|
|||||||
|
# E2E policy
|
||||||
|
|
||||||
|
Mandatory when writing or changing live-stack e2e (`e2e/`). **When** to write tests. **How:** AzerothGhost `LLM_GUIDE.md` / `EXAMPLES.md` (this file does not replace them). Inventory: `e2e/README.md`. Stack: AC 3.3.5a auth+world+MySQL; `E2E_*` may point at a gateway.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| Unit | Isolated C++/Go; no live stack. `src/test/`. |
|
||||||
|
| Integration | In-process multi-component; no client login. |
|
||||||
|
| E2E | `//go:build e2e` Go test: `e2eharness` bots on a running realm; assert protocol / object cache / DB. |
|
||||||
|
| Player-visible | A real client can observe it. |
|
||||||
|
| Tracked issue | AC issue/PR id for `ConfirmedBugf`. |
|
||||||
|
|
||||||
|
## When
|
||||||
|
|
||||||
|
First match wins:
|
||||||
|
|
||||||
|
| If | Then |
|
||||||
|
|----|------|
|
||||||
|
| Pure calc / parse / bit math / no world | UNIT. NEVER e2e. |
|
||||||
|
| Covered by existing unit/mocks (`SpellProc*`, …) without login/map/DBC fidelity | UNIT or in-process integration. E2E only if unit cannot reach the path. |
|
||||||
|
| SQL-only static (text, displayid, non-script loot) | No e2e. Manual optional. |
|
||||||
|
| SmartAI / conditions / waypoints; outcome only in-world | E2E SHOULD if player-visible and deterministic; else document manual repro. |
|
||||||
|
| Multi-system on a live session, or protocol/client-observed, or player-repro bugfix | E2E MUST (or update existing). Unit MAY still cover pure pieces. |
|
||||||
|
| Refactor, no behaviour intent | Update tests that break. Add e2e only if a critical system below lacks an oracle. |
|
||||||
|
|
||||||
|
Bias: player-visible and MVT fits → prefer e2e over unit-only. Not a MUST trigger and unsure → do not invent e2e; file a gap.
|
||||||
|
|
||||||
|
**MUST add or update e2e** when any of: player-visible bugfix (encode the repro); protocol change; multi-system (combat/aura/death/quest/loot/mount/pet/vehicle/instance/PvP/guild/relog/DB after `.save`); crash/hang/freeze on client action (`ProbeWorldAlive` / `AssertWorldAlive`); existing `TestAC_*` path touched (update, do not drop); critical system with no oracle; claimed blizzlike fix for a tracked issue (`ConfirmedBugf` until fixed).
|
||||||
|
|
||||||
|
**MUST NOT** treat “tested in-game” as a substitute when MVT is feasible.
|
||||||
|
|
||||||
|
Harness cannot express it: extend harness or file a harness gap; inventory the gap; still unit-test pure logic.
|
||||||
|
|
||||||
|
**NEVER / do instead**
|
||||||
|
|
||||||
|
| Situation | Do instead |
|
||||||
|
|-----------|------------|
|
||||||
|
| Pure math, flags, calendar | `src/test/` |
|
||||||
|
| Isolated spell-proc already unit-covered; no new player-visible interaction | extend unit |
|
||||||
|
| SQL-only static, no behavioural branch | SQL review |
|
||||||
|
| Rename / comments / refactor; no behaviour; not a critical system | existing tests |
|
||||||
|
| Human judgment, nondeterminism, hours-long lockouts | manual; optional one deterministic slice |
|
||||||
|
| Same oracle as an existing test | extend or share a helper |
|
||||||
|
| “World boots” with no behaviour oracle | not feature e2e |
|
||||||
|
| Invent harness APIs / test the harness | contribute to AzerothGhost |
|
||||||
|
|
||||||
|
SHOULD NOT e2e a GM command that is not on the player path (GM setup is fine; assert player-relevant state).
|
||||||
|
|
||||||
|
## Authoring (PR gate)
|
||||||
|
|
||||||
|
Consumer suite (`e2e/suites/` / `e2e/smoke/`), not only Ghost. `//go:build e2e`. Import `e2eharness` + blank MySQL driver.
|
||||||
|
|
||||||
|
Fixture: `NewSolo` / `NewScenario` (`BotSpec` / `ByRole` if roles differ). Prefer `ScenarioBot` over raw `Session` except guild charter/bank.
|
||||||
|
|
||||||
|
`meta.Begin` serial by default. Prefix ≤7 chars (auth name max 17 = Prefix+10). Tag `parallel` only when pad-safe.
|
||||||
|
|
||||||
|
Name: `TestArea_Behaviour` or `TestAC_<issue>_<Short>`. Comment the issue/PR URL.
|
||||||
|
|
||||||
|
Flow: fixture → place → setup (GM ok) → `CombatReady` if pull → drive → assert.
|
||||||
|
|
||||||
|
Place: `Teleport` / `TeleportPad` / `TeleNamed` / `GoCreatureID`. Melee: `GoCreatureID` after named tele. Tele clears object cache — re-`WaitUnit`.
|
||||||
|
|
||||||
|
Combat: `CombatReady` / `CombatReadyFull` before pulls. NEVER `.gm on` during aggro. Damage: `Damage` / `DamageKill` only — NEVER `.gm on` mid-fight for `.damage`.
|
||||||
|
|
||||||
|
Casts: `Cast` / `CastMust` / `CastOrGM` / `CastAtPosition`; fail reason via `SpellFailReasonName`.
|
||||||
|
|
||||||
|
Waiters: Arm → Send → Wait. NEVER re-arm during Wait. NEVER replace waiters with long fixed sleeps.
|
||||||
|
After fire-and-forget GM that later casts depend on (`.pvp on`, `.gm off`), `FlushWorld` on that bot (world-thread ack) or a state waiter (`WaitUnitPvP`).
|
||||||
|
|
||||||
|
Quest DB: only after `Save` / `QuestStatusAfterSave`. Spell-summon: NEVER `.npc add` instead of the spell. Set race so GM text uses the character’s language (Horde ≠ Common).
|
||||||
|
|
||||||
|
Oracle = behaviour (protocol / cache; DB after `Save`), not “no error”. SHOULD `t.Logf("PASS …")`. SHOULD log GUIDs/spell ids/statuses.
|
||||||
|
|
||||||
|
**MVT** (all required or it is not coverage): one primary oracle; real client path; setup failures are `Preconditionf`; correct severity; no footguns above; minutes not hours (long boss waves only if the oracle needs them); runnable with `E2E_*` on stock AC; name+comment findable from issue or mechanic.
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build e2e
|
||||||
|
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/N
|
||||||
|
func TestAC_N_ShortOracle(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "issue"}, Runtime: "short", Issue: N})
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "Short", Race: e2eharness.RaceHuman,
|
||||||
|
Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true,
|
||||||
|
})
|
||||||
|
// place → setup → (CombatReady) → drive → assert
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `go test -tags=e2e -run TestName -count=1 -v` green on fixed core (or only `CONFIRMED BUG` on unfixed). SHOULD `-count=2` stable. Document if exclusive realm needed.
|
||||||
|
|
||||||
|
NEVER: invent harness APIs; wrap GM/map without logs; bare `t.Fatalf("CONFIRMED BUG")`; copy whole examples; one test for five issues.
|
||||||
|
|
||||||
|
## Fail helpers
|
||||||
|
|
||||||
|
Use these, not bare `t.Fatalf`, for classified outcomes:
|
||||||
|
|
||||||
|
| Helper | Prefix | When |
|
||||||
|
|--------|--------|------|
|
||||||
|
| `Preconditionf` | `precondition:` | Setup never reached a judgeable state |
|
||||||
|
| `ConfirmedBugf(t, N, …)` | `AC#N CONFIRMED BUG:` | Tracked issue; core wrong (fails CI) |
|
||||||
|
| `HarnessFailf` / `Assertf` | `harness:` / `assert:` | Infra or fixed-core regression (fails CI) |
|
||||||
|
| `SoftWarnf` | `WARNING:` | Non-fatal soft deviation |
|
||||||
|
| Open / unfixed issue | — | Comment out the **entire** test: `TODO(e2e): re-enable when AC#N is fixed` + issue URL. Re-enable body MUST hard-fail. NEVER log+return soft-pass. |
|
||||||
|
|
||||||
|
## Critical systems
|
||||||
|
|
||||||
|
A PR that implements, fixes, or refactors a row MUST add or update e2e for that oracle, or keep an existing test that still asserts it. If harness cannot: gap in `e2e/README.md` + unit for pure logic.
|
||||||
|
|
||||||
|
| System | Oracle / drive |
|
||||||
|
|--------|----------------|
|
||||||
|
| Aura apply/strip/consume | apply → action → `AssertAuraRemains` / `AssertAuraConsumed` / aura waiters. Paths: `Spells`, aura scripts, `spell_*.cpp` |
|
||||||
|
| Cast / charge / pathing | `Cast`/`CastMust` + fail reason / `SMSG_SPELL_GO`; charge stays on bridge; ground AoE landing |
|
||||||
|
| Death / repop / corpse | `DieAndRepop` (or staged) + surviving system (STAY_ALIVE, Raise Dead) |
|
||||||
|
| Quest + persistence | `AddQuest` → action → `Save` → `AssertQuestStatus` |
|
||||||
|
| Relog / extra_flags / GM vis | set → `Save` → `Relog` → DB or protocol |
|
||||||
|
| Threat / evade / engage | `CombatReady` → `Engage` → still in combat; no evade from bad GM |
|
||||||
|
| Spell summon | cast the summon spell (not `.npc add`) + summoned unit properties |
|
||||||
|
| Instance boss AI | place → `CombatReady` → `Engage` → spawn/target/interval (`AssertIntervalNotAccelerated`) |
|
||||||
|
| Guild charter / bank | Session guild helpers |
|
||||||
|
| Account / realm GM | `.account set gmlevel` scoped to target realm (#27088 style) |
|
||||||
|
| Crash-prone cast | repro + `ProbeWorldAlive` / `AssertWorldAlive` |
|
||||||
|
| Pure helper extracted from the above | unit the helper **and** keep e2e on the player path |
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
Search consumer suite + Ghost `examples/` + `TestAC_*` for issue, spell, quest, creature, mechanic **before** adding.
|
||||||
|
|
||||||
|
Same oracle → same test (extend / table-drive). Same setup, different oracle → helper or `t.Run`, not a second login. NEVER copy Ghost `examples/` without changing the oracle. One tracked issue → one primary `TestAC_<id>_…`; extra edges as `t.Run` or siblings only if isolation requires. Same root cause + same assertion surface → merge.
|
||||||
|
|
||||||
|
Inventory (`e2e/README.md` + suite comments): category, one-sentence oracle, P0–P3, `covered` (test name) / `gap` / `blocked-harness` / `manual-only`, issue links, note. MUST update when adding coverage or finding a gap. `blocked-harness` MUST name the missing API (opcode, waiter, multi-realm, …). Closing a P0/P1 gap is preferred when touching that subsystem. When pruning, merge redundant tests and refresh the inventory.
|
||||||
|
|
||||||
|
Prefer one solid test per merged player-visible bugfix. Deepen critical categories over new weak ones. Every test: unique oracle + owner (issue or feature). Delete or merge tests that no longer map to a behaviour.
|
||||||
|
|
||||||
|
## Priority (next test)
|
||||||
|
|
||||||
|
Highest feasible MVT:
|
||||||
|
|
||||||
|
| Pri | Cover |
|
||||||
|
|-----|-------|
|
||||||
|
| **P0** | Crash / hang / corruption on client action; already-regressed player bug with issue id |
|
||||||
|
| **P1** | Aura strip/consume/persist; death+quest; relog flags; evade/engage; charge/position; totem/grounding; spell-summon (not GM spawn) |
|
||||||
|
| **P2** | Single-boss timers/waves/targeting; guild charter/bank; multi-bot PvP; realm-scoped GM / persisted visibility |
|
||||||
|
| **P3** | QoL, display, nondeterministic farm; full raid clears (out of default growth) |
|
||||||
|
|
||||||
|
On a PR: implement e2e for the highest-priority trigger the PR activates. Do not add P3 in the same change unless asked.
|
||||||
|
|
||||||
|
## Isolation, comments, flakes
|
||||||
|
|
||||||
|
Unique `Prefix`; cleanup via `t.Cleanup`. Do not depend on other tests’ characters/guilds/instance saves. Time oracles: bounded windows with documented thresholds, not “sleep 60s”.
|
||||||
|
|
||||||
|
Tracked issues: URL in comment + id in `ConfirmedBugf`. State expected green-on-fixed vs CONFIRMED-BUG-on-unfixed. Do not claim blizzlike without issue, PR, or reviewed wowhead/web evidence.
|
||||||
|
|
||||||
|
Flake: classify (infra, timing, pad collision, dirty world, core race). Fix waiters / Arm-Send-Wait / `CombatReady` / unique prefixes / re-wait after tele. Intermittent fail on a correct core is a harness/test bug until proven otherwise. Quarantine (`t.Skip` + reason) only for environmental flake, rare and ticketed. NEVER silent retries or endless timeout bumps.
|
||||||
|
|
||||||
|
## Open issues
|
||||||
|
|
||||||
|
| Core | Test |
|
||||||
|
|------|------|
|
||||||
|
| Unfixed | Comment out the whole test + TODO + URL. Re-enable MUST hard-fail. |
|
||||||
|
| Fixed | Uncomment; MUST go green. Keep `TestAC_<n>_` and comment. |
|
||||||
|
| Invalid / cannot repro | Delete the disabled block; no lying TODOs. |
|
||||||
|
| Env cannot run | `Preconditionf` or inventory `blocked-*`; not a soft PASS. |
|
||||||
|
|
||||||
|
NEVER let a test PASS while the product oracle is wrong. NEVER multi-retry + soft-exit to hide flakes or open bugs. NEVER delete a green regression because the issue closed.
|
||||||
|
|
||||||
|
Update tests in the same PR as the behaviour change. Harness renames: update consumers in the same landing window. Ghost `e2e/examples/` are patterns; AC regressions live in the consumer suite.
|
||||||
|
|
||||||
|
Scratch MUST be `e2e/local/` (gitignored except `local/README.md`). NEVER commit throwaways. Promote into `e2e/suites/` next to related tests in the same PR as the fix. Prefer live e2e over ad-hoc GM when the stack is up.
|
||||||
|
|
||||||
|
Local (from `e2e/`): `go test -tags=e2e ./...`. Official-repo PR and master CI: full suite after nopch clang-18 (reuses those binaries). Dispatch `-f scope=smoke` for a smaller run. Touch `.github/workflows/e2e-live.yml` only when changing CI.
|
||||||
@@ -7,6 +7,11 @@ insert_final_newline = true
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
max_line_length = 120
|
max_line_length = 120
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
tab_width = 4
|
||||||
|
|
||||||
[*.{json,ts,js,yml,sh}]
|
[*.{json,ts,js,yml,sh}]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ on:
|
|||||||
- opened
|
- opened
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- 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:
|
concurrency:
|
||||||
# One concurrency group per workflow + ref.
|
# One concurrency group per workflow + ref.
|
||||||
@@ -26,15 +35,34 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-24.04
|
|
||||||
compiler:
|
|
||||||
CC: clang-18
|
|
||||||
CXX: clang++-18
|
|
||||||
- os: ubuntu-26.04
|
- os: ubuntu-26.04
|
||||||
compiler:
|
compiler:
|
||||||
CC: gcc-15
|
CC: gcc-15
|
||||||
@@ -53,3 +81,22 @@ jobs:
|
|||||||
CC: ${{ matrix.compiler.CC }}
|
CC: ${{ matrix.compiler.CC }}
|
||||||
CXX: ${{ matrix.compiler.CXX }}
|
CXX: ${{ matrix.compiler.CXX }}
|
||||||
pch: false
|
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 }}
|
||||||
|
|||||||
@@ -279,7 +279,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
|
|
||||||
|
|
||||||
- name: Test worldserver with startup scripts
|
- name: Test worldserver with startup scripts
|
||||||
run: |
|
run: |
|
||||||
./acore.sh sm create world worldserver --bin-path ./env/dist/bin --provider pm2
|
./acore.sh sm create world worldserver --bin-path ./env/dist/bin --provider pm2
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
!/env/dist/.gitkeep
|
!/env/dist/.gitkeep
|
||||||
/env/user/*
|
/env/user/*
|
||||||
/.env*
|
/.env*
|
||||||
|
/e2e/.env
|
||||||
/apps/joiner
|
/apps/joiner
|
||||||
/deps/deno
|
/deps/deno
|
||||||
/data/sql/custom/*
|
/data/sql/custom/*
|
||||||
@@ -125,3 +126,9 @@ __pycache__/
|
|||||||
# !modules/yourmodule
|
# !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
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- Formatting follows `.editorconfig`: UTF-8, LF, max 120 cols, trailing newline, no trailing whitespace; 4-space indent for C++ (tabs forbidden), 2-space for JSON/YAML/sh/ts/js.
|
||||||
|
- **Prefer live-stack e2e to debug/validate player-visible behaviour** when a local auth+world+MySQL stack is available (protocol, combat, quests, loot, death, multi-bot). See `e2e/README.md` and AzerothGhost `e2e/LLM_GUIDE.md`. Do not invent e2e for pure unit-sized logic — see `.agents/docs/e2e-policy.md`.
|
||||||
|
- **Scratch e2e only under `e2e/local/`** (gitignored). Never commit throwaway debug tests. Promote keepers into `e2e/suites/` or `e2e/smoke/`.
|
||||||
- Planning docs go in `.agents/plans/<task-slug>/` (gitignored), named `<task-slug>.<TYPE>.md` (`PLAN`, `REQUIREMENTS`, `ANALYSIS`, …).
|
- Planning docs go in `.agents/plans/<task-slug>/` (gitignored), named `<task-slug>.<TYPE>.md` (`PLAN`, `REQUIREMENTS`, `ANALYSIS`, …).
|
||||||
|
|
||||||
## Mandatory reading per task
|
## 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`
|
- 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`
|
- 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
|
- 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`
|
- Capturing a lesson or adding/updating agent docs → `.agents/docs/README.md`
|
||||||
|
|
||||||
## Repository layout
|
## 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/shared/` — code shared by auth and world servers.
|
||||||
- `src/server/apps/{authserver,worldserver}/` — entry points (ports 3724 and 8085).
|
- `src/server/apps/{authserver,worldserver}/` — entry points (ports 3724 and 8085).
|
||||||
- `src/test/` — unit tests + mocks.
|
- `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).
|
- `data/sql/` — `base/` (historical schema), `updates/db_*/` (merged), `updates/pending_db_*/` (in-flight), `custom/` (gitignored).
|
||||||
- `modules/` — external modules (see below).
|
- `modules/` — external modules (see below).
|
||||||
- `apps/` — helper scripts; `apps/codestyle/` holds the lint scripts.
|
- `apps/` — helper scripts; `apps/codestyle/` holds the lint scripts.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# AzerothCore live-stack e2e (AzerothGhost harness)
|
||||||
|
# Requires: running auth+world+MySQL and E2E_* env vars (see .env.example).
|
||||||
|
|
||||||
|
GO ?= go
|
||||||
|
TAGS ?= e2e
|
||||||
|
TIMEOUT ?= 30m
|
||||||
|
# In-package test concurrency. Keep 1: PackagePad is sticky per package; parallel
|
||||||
|
# tests in the same package share one pad and thrash each other.
|
||||||
|
PARALLEL ?= 1
|
||||||
|
# Package concurrency (go test -p). IsolationPads has 27 unique pads; more packages
|
||||||
|
# than pads hash-share locations. Default 1 = safe unique pad assignment always.
|
||||||
|
P ?= 1
|
||||||
|
COUNT ?= 1
|
||||||
|
|
||||||
|
GOTEST = $(GO) test -tags=$(TAGS) -count=$(COUNT) -timeout $(TIMEOUT) -p $(P)
|
||||||
|
|
||||||
|
.PHONY: e2e-smoke e2e-category e2e-sub e2e-issue e2e-tags e2e-full e2e-list e2e-local e2e-mod
|
||||||
|
|
||||||
|
e2e-mod:
|
||||||
|
$(GO) mod tidy
|
||||||
|
|
||||||
|
e2e-list:
|
||||||
|
$(GOTEST) -list . ./...
|
||||||
|
|
||||||
|
e2e-smoke:
|
||||||
|
$(GOTEST) -v -parallel $(PARALLEL) ./smoke/
|
||||||
|
# Suites tagged smoke only (open-issue suites must not carry the smoke tag).
|
||||||
|
E2E_TAGS=smoke $(GOTEST) -v -parallel $(PARALLEL) ./suites/...
|
||||||
|
|
||||||
|
e2e-category:
|
||||||
|
@test -n "$(C)" || (echo "usage: make e2e-category C=quests"; exit 1)
|
||||||
|
$(GOTEST) -v -parallel $(PARALLEL) ./suites/$(C)/...
|
||||||
|
|
||||||
|
e2e-sub:
|
||||||
|
@test -n "$(C)" || (echo "usage: make e2e-sub C=spells/aura"; exit 1)
|
||||||
|
$(GOTEST) -v -parallel $(PARALLEL) ./suites/$(C)/...
|
||||||
|
|
||||||
|
e2e-issue:
|
||||||
|
@test -n "$(N)" || (echo "usage: make e2e-issue N=26549"; exit 1)
|
||||||
|
# Prefer TestAC_<n>_* names; E2E_ISSUE also selects by meta.Issue.
|
||||||
|
E2E_ISSUE=$(N) $(GOTEST) -v -parallel 1 -run 'TestAC_$(N)_' ./suites/...
|
||||||
|
|
||||||
|
e2e-tags:
|
||||||
|
@test -n "$(TAGS_FILTER)" || (echo "usage: make e2e-tags TAGS_FILTER=smoke,short"; exit 1)
|
||||||
|
E2E_TAGS=$(TAGS_FILTER) $(GOTEST) -v -parallel $(PARALLEL) ./...
|
||||||
|
|
||||||
|
# Scratch/debug packages under local/ (gitignored). Does not run smoke/suites.
|
||||||
|
e2e-local:
|
||||||
|
$(GOTEST) -v -parallel 1 ./local/...
|
||||||
|
|
||||||
|
# Default 120m unless the caller set TIMEOUT= on the command line.
|
||||||
|
e2e-full: TIMEOUT = 120m
|
||||||
|
e2e-full:
|
||||||
|
$(GOTEST) -v -parallel $(PARALLEL) ./...
|
||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
# AzerothCore live-stack e2e
|
||||||
|
|
||||||
|
Protocol-level regression tests for this repository. They import
|
||||||
|
[`github.com/azerothcore/AzerothGhost/e2e/e2eharness`](https://github.com/azerothcore/AzerothGhost)
|
||||||
|
and run against a **live** authserver + worldserver + MySQL.
|
||||||
|
|
||||||
|
Offline `go test ./...` (without `-tags=e2e`) skips these packages.
|
||||||
|
|
||||||
|
Authoring rules for new tests live in the harness:
|
||||||
|
|
||||||
|
| Doc | Audience |
|
||||||
|
|-----|----------|
|
||||||
|
| [LLM_GUIDE.md](https://github.com/azerothcore/AzerothGhost/blob/v1.0.8/e2e/LLM_GUIDE.md) | Compact MUST/NEVER + APIs (LLMs and humans) |
|
||||||
|
| [EXAMPLES.md](https://github.com/azerothcore/AzerothGhost/blob/v1.0.8/e2e/EXAMPLES.md) | Full recipes and skeletons |
|
||||||
|
| `.agents/docs/e2e-policy.md` | When to add e2e vs unit tests (agent/review policy) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Running AzerothCore **3.3.5a** authserver + worldserver.
|
||||||
|
2. MySQL with `acore_auth`, `acore_characters`, and `acore_world` (world DB is required for spawn cleanup and many fixtures).
|
||||||
|
3. Go **1.26+** and network reachability to auth (default `127.0.0.1:3724`).
|
||||||
|
|
||||||
|
Accounts are created by the harness (GM level 3, password `test`). Do not reuse real player accounts.
|
||||||
|
|
||||||
|
### Local harness (optional)
|
||||||
|
|
||||||
|
For co-development against a local AzerothGhost checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e
|
||||||
|
cp go.work.example go.work # gitignored; edit the replace path
|
||||||
|
# replace github.com/azerothcore/AzerothGhost => /path/to/AzerothGhost
|
||||||
|
```
|
||||||
|
|
||||||
|
`e2e/go.mod` pins `github.com/azerothcore/AzerothGhost v1.0.8` (see `go.sum`).
|
||||||
|
`go test` / `go mod download` fetch that module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Copy and adjust [`e2e/.env.example`](./.env.example). Stock AC and CI use `acore:acore`.
|
||||||
|
|
||||||
|
| Variable | Default (harness) | Meaning |
|
||||||
|
|----------|-------------------|---------|
|
||||||
|
| `E2E_AUTH_ADDR` | `127.0.0.1:3724` | Auth / realm-list address clients use |
|
||||||
|
| `E2E_AUTH_DSN` | `acore:acore@tcp(127.0.0.1:3306)/acore_auth` | Auth DB |
|
||||||
|
| `E2E_CHAR_DSN` | `acore:acore@tcp(127.0.0.1:3306)/acore_characters` | Characters DB |
|
||||||
|
| `E2E_WORLD_DSN` | `acore:acore@tcp(127.0.0.1:3306)/acore_world` | World DB (spawns, tele names, cleanup) |
|
||||||
|
|
||||||
|
Optional filters (`internal/meta`):
|
||||||
|
|
||||||
|
| Variable | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `E2E_TAGS` | Comma list; test must include **all** listed tags (AND) |
|
||||||
|
| `E2E_SKIP_TAGS` | Skip if test has **any** listed tag |
|
||||||
|
| `E2E_ISSUE` | Run only tests with matching `TestMeta.Issue` |
|
||||||
|
| `E2E_RUNTIME` | Run only `short` / `med` / `long` |
|
||||||
|
| `E2E_ALLOW_SOFT_PASS` | **`1` only for local debug.** SoftPass is fail-closed by default |
|
||||||
|
|
||||||
|
Export vars in your shell (or `set -a; source .env; set +a`) before `go test`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
**Recommended full suite** (pad-safe defaults: serial tests **and** packages):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e
|
||||||
|
# export E2E_* from .env.example first
|
||||||
|
go test -tags=e2e ./... -count=1 -v -timeout 120m -parallel 1 -p 1
|
||||||
|
# or:
|
||||||
|
make e2e-full
|
||||||
|
```
|
||||||
|
|
||||||
|
`-parallel 1` keeps in-package tests serial (`meta.Begin` is serial by default). `-p 1` runs one package at a time so IsolationPads never hash-share. Raising either without a unique preferred pad per concurrent package causes thrash.
|
||||||
|
|
||||||
|
### Make targets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e-smoke # smoke/ + suites tagged smoke
|
||||||
|
make e2e-category C=quests # one category tree
|
||||||
|
make e2e-sub C=spells/aura # one leaf suite
|
||||||
|
make e2e-issue N=26549 # TestAC_26549_* anywhere
|
||||||
|
make e2e-tags TAGS_FILTER=smoke,short
|
||||||
|
make e2e-full # ./... long timeout
|
||||||
|
make e2e-list # list tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Override parallel / timeout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e-full PARALLEL=1 P=1 TIMEOUT=120m
|
||||||
|
# P = go test -p (packages). Keep 1 unless every concurrent suite has a unique preferred pad.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single test / package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -tags=e2e ./suites/combat/pets -count=1 -v -timeout 30m -parallel 1
|
||||||
|
go test -tags=e2e ./suites/... -run TestPets_SummonWaitDismiss -count=1 -v -timeout 15m
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|------|------|
|
||||||
|
| `smoke/` | Fast login / tele / relog guards |
|
||||||
|
| `suites/<category>/…` | Hierarchical scenarios (directory = category) |
|
||||||
|
| `suites/<category>/` | Suites by domain; issue guards named `TestAC_<n>_…` live beside related tests |
|
||||||
|
| `local/` | **Scratch/debug only** — gitignored (except `local/README.md`); never commit throwaways |
|
||||||
|
| `internal/meta` | `TestMeta` + env tag filters + `Begin` (Parallel policy) |
|
||||||
|
| `internal/fixtures` | Re-exports pads / `PackagePad` for suites that prefer fixtures |
|
||||||
|
|
||||||
|
Every live test uses `//go:build e2e` and should call `meta.Begin(t, meta.TestMeta{…})` before expensive setup.
|
||||||
|
|
||||||
|
### Scratch / agent debug (`local/`)
|
||||||
|
|
||||||
|
When validating a fix on a **live** stack (player-visible combat, protocol, quests, multi-bot), prefer writing a small e2e under **`e2e/local/`** instead of ad-hoc GM spam or long manual checklists. That tree is **not committed**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create e.g. local/repro/repro_e2e_test.go (//go:build e2e)
|
||||||
|
make e2e-local
|
||||||
|
# or:
|
||||||
|
go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1
|
||||||
|
```
|
||||||
|
|
||||||
|
If the scenario should stay as a regression, **move** it into `suites/` next to related tests with proper `meta.Begin` tags — see `.agents/docs/e2e-policy.md`.
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
|
||||||
|
| Category | Oracle | Pri | Coverage | Issue |
|
||||||
|
|----------|--------|-----|----------|-------|
|
||||||
|
| smoke | login / pad tele / relog / world alive | P0 | covered (`TestSmoke_*`) | — |
|
||||||
|
| combat/charm | apply/cancel aura | P1 | covered; Yogg MC logout `blocked-harness` (no charm/MC drive) | #25506 |
|
||||||
|
| combat/death | die → ghost → release → reclaim | P1 | covered | — |
|
||||||
|
| combat/pets | summon / GUID / attack / dismiss | P1 | covered; dungeon Raise Dead `blocked-harness` (ready-check / instance summon) | #27081 |
|
||||||
|
| combat/threat | engage / taunt switch / kill clears combat | P1 | covered | — |
|
||||||
|
| combat/vehicles | spellclick steed enter/exit | P2 | covered | — |
|
||||||
|
| spells/aura | apply/query; CC broken by damage; mount persist | P1 | covered (`TestAC_26130_*`) | #26130 |
|
||||||
|
| spells/cast | Charge on dummy; fail path; stance; Raise Dead + ghoul | P1 | covered (`TestAC_27061_*`) | #27061 |
|
||||||
|
| spells/effects | Charge / grounding totem / Sweeping Strikes Execute | P1 | covered (`TestAC_26997_*`); dummy-summon `blocked-harness` (engineering dummy lifetime) | #26774 #26997 |
|
||||||
|
| social/group | form / leave / leader / loot method / disband | P2 | covered | — |
|
||||||
|
| social/loot | need/greed / master loot; below-half kill | P1 | covered (`TestAC_26862_*`); chest mid-roll `blocked-harness` (GO 194821 UseGameObject); pass-on-loot delete `blocked-harness` (item-survive after ALL_PASSED) | #26894 #26862 #22000 |
|
||||||
|
| social/trade | item+gold accept; cancel; walk-OOR TARGET_TO_FAR | P1 | covered | #25723 |
|
||||||
|
| quests/lifecycle | STAY_ALIVE fail on death; status after save/relog | P1 | covered (`TestAC_26549_*`) | #26549 |
|
||||||
|
| quests/escort | find spawned unit; follow-NPC despawns on logout | P2 | covered (`TestAC_24450_*`) | #24450 |
|
||||||
|
| items/equip | visible-item slot after EquipEntry; additem; survives relog | P2 | covered | — |
|
||||||
|
| protocol/session | pos; item/quest load; money save/relog | P1 | covered; GM vis persist `blocked-harness` (extra_flags after relog) | #25793 |
|
||||||
|
| protocol/teleport | cross-map; named; GoCreatureID | P1 | covered | — |
|
||||||
|
| guild/charter_bank | charter buy+turn-in | P2 | covered | — |
|
||||||
|
| instances/bind_reset | party tele; ritual summon | P2 | covered; post-reset summon `blocked-harness` (AcceptSummon after reset) | #10708 |
|
||||||
|
| instances/ulduar | named tele; Freya wave interval | P2 | covered (`TestAC_27095_*`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallelism and isolation
|
||||||
|
|
||||||
|
### Model
|
||||||
|
|
||||||
|
| Layer | Behaviour |
|
||||||
|
|-------|-----------|
|
||||||
|
| **Packages** | `go test -p N` runs packages concurrently. Default **`P=1`** in the Makefile — IsolationPads has 27 unique pads; more packages than pads **hash-share** and can thrash. Raise `P` only when every concurrent package has a unique preferred pad. |
|
||||||
|
| **Tests in a package** | `meta.Begin` is **serial by default** (no `t.Parallel`). Tag `parallel` only if pad-safe. Makefile `PARALLEL=1`. |
|
||||||
|
| **Pads** | `e2eharness.PackagePad(t)` is **sticky per suite folder** for the process lifetime. |
|
||||||
|
|
||||||
|
Use **`PackagePad`** for combat/social placement — not a shared Stormwind cell for every suite.
|
||||||
|
|
||||||
|
```go
|
||||||
|
pad := e2eharness.PackagePad(t)
|
||||||
|
bot.TeleportPad(t, pad)
|
||||||
|
// multi-bot:
|
||||||
|
e2eharness.FormPartyAtPad(t, pad, leader, mate)
|
||||||
|
e2eharness.TeleportAllPad(t, bots, pad)
|
||||||
|
```
|
||||||
|
|
||||||
|
`PadStormwindOutskirts` is a **legacy alias** of the AbandonHouse pad. Prefer `PackagePad(t)`.
|
||||||
|
|
||||||
|
### Isolation pads
|
||||||
|
|
||||||
|
Far-apart world locations (operator-captured). Combat-heavy packages have preferred 1:1 assignments; other packages take free pads or a stable hash share when the pool is exhausted.
|
||||||
|
|
||||||
|
| Pad name | Map | Notes |
|
||||||
|
|----------|-----|--------|
|
||||||
|
| `Tower1` | Eastern Kingdoms (0) | Preferred: `combat/threat` |
|
||||||
|
| `Tower2` | Eastern Kingdoms (0) | Preferred: `combat/death` |
|
||||||
|
| `AbandonHouse` | Eastern Kingdoms (0) | Preferred: `combat/pets`; legacy SW outskirts alias |
|
||||||
|
| `NagrandArena` | Outland (530) | Preferred: `combat/charm` |
|
||||||
|
| `FloatingIsland1` | Outland (530) | Preferred: `combat/vehicles` |
|
||||||
|
| `FloatingIsland2` | Outland (530) | Preferred: `social/loot` |
|
||||||
|
| `FloatingIsland3` | Outland (530) | Preferred: `social/group` |
|
||||||
|
| `InMountains1` | Kalimdor (1) | Preferred: `social/trade` |
|
||||||
|
| `InMountains2` | Kalimdor (1) | Preferred: `spells/cast` |
|
||||||
|
| `InMountains3` | Kalimdor (1) | Preferred: `spells/effects` |
|
||||||
|
| `ElwynnRidge` | Eastern Kingdoms (0) | Elwynn cliff |
|
||||||
|
| `BurningSteppes` | Eastern Kingdoms (0) | ~300y from ElwynnRidge |
|
||||||
|
| `Mulgore1` | Kalimdor (1) | Preferred: `quests/escort` (plains, spawn-safe) |
|
||||||
|
| `Mulgore3`–`Mulgore5`, `MulgoreNorth` | Kalimdor (1) | Mulgore / Thunder Bluff mesa |
|
||||||
|
| `Boulderslide` | Kalimdor (1) | Stonetalon Boulderslide Ravine |
|
||||||
|
| `Stonetalon1`, `Stonetalon2` | Kalimdor (1) | Stonetalon Mountains |
|
||||||
|
| `Talondeep` | Kalimdor (1) | Talondeep Path (near Ashenvale1) |
|
||||||
|
| `Ashenvale1` | Kalimdor (1) | Ashenvale |
|
||||||
|
| `FelwoodSouth`, `MorlosAran` | Kalimdor (1) | Felwood |
|
||||||
|
| `HyjalApproach`, `Hyjal1`, `Hyjal2` | Kalimdor (1) | Hyjal |
|
||||||
|
|
||||||
|
Unlisted suites (`smoke`, `spells/aura`, protocol, quests, items, instances, guild, …) receive the first free pad, then hash-share if needed. Logs include `PackagePad suite=… pad=…`.
|
||||||
|
|
||||||
|
**Do not** hardcode one shared coordinate for every new combat test. Content that *must* use a fixed world location (tabard designer, instance entrance, named tele) still may — then return to the package pad when possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cleanup rules
|
||||||
|
|
||||||
|
Live e2e mutates a real realm. Cleanup is mandatory.
|
||||||
|
|
||||||
|
### Persistent spawns (`.npc add` / `.gobject add`)
|
||||||
|
|
||||||
|
These write **DB rows**. Bare add without cleanup litters pads (e.g. Crimson Templar 15209, Gift of the Observer GO 194821).
|
||||||
|
|
||||||
|
| Do | Do not |
|
||||||
|
|----|--------|
|
||||||
|
| `bot.Spawn` / `SpawnKillLootable` / `SpawnGameObject` (register cleanup) | Bare `.npc add` / `.gobject add` |
|
||||||
|
| `DespawnCreatureSpawn` / `DespawnGameObjectSpawn` by **DB spawn id** | Assume process exit cleans world DB |
|
||||||
|
| Rely on SQL DELETE + soft live despawn (harness does both) | Only live delete after socket already closed |
|
||||||
|
|
||||||
|
Despawn path: **SQL DELETE always** (survives session close) + optional soft `.npc delete` / `.gobject delete` while the socket is open.
|
||||||
|
|
||||||
|
**Loot:** do not use `.npc add temp` for loot tests (`TEMPSUMMON_CORPSE_DESPAWN` removes the corpse; use `Spawn` / `SpawnKillLootable`).
|
||||||
|
|
||||||
|
### Pets, ghouls, guardians, totems
|
||||||
|
|
||||||
|
`NewScenario` / `NewSolo` register `CleanupOwnedSummons` on `t.Cleanup` (dismiss pet + despawn units with SUMMONEDBY/CREATEDBY = player).
|
||||||
|
|
||||||
|
After Raise Dead / heavy summon use, also call while still InWorld:
|
||||||
|
|
||||||
|
```go
|
||||||
|
bot.CleanupOwnedSummons(t)
|
||||||
|
```
|
||||||
|
|
||||||
|
### SoftPass (fail-closed)
|
||||||
|
|
||||||
|
`SoftPass` / `SoftPassf` **fail the test by default** so unjudgeable fixtures cannot greenwash CI. Only local debug:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
E2E_ALLOW_SOFT_PASS=1 go test -tags=e2e …
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer `Preconditionf` (setup blocked), `Assertf` / `ConfirmedBugf` (oracle), or `HarnessFailf` (infra).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Writing a test (short)
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package pets_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPets_Example(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"med", "combat", "pets"},
|
||||||
|
Runtime: "med",
|
||||||
|
Category: "combat/pets",
|
||||||
|
})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "PetEx",
|
||||||
|
Class: e2eharness.ClassWarlock,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
// setup (GM ok) → CombatReady before pulls → drive → assert
|
||||||
|
t.Logf("PASS …")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
|
||||||
|
1. `//go:build e2e` + MySQL blank import + `e2eharness`.
|
||||||
|
2. `meta.Begin` (tag filters; **serial by default** — tag `parallel` only if pad-safe).
|
||||||
|
3. Unique short `Prefix` (**≤ 7 chars**: account = Prefix + 2 digits + 8 hex, auth max 17);
|
||||||
|
place with **`PackagePad`**.
|
||||||
|
4. Flow: fixture → place → setup → `CombatReady` if pull → drive → assert.
|
||||||
|
5. Waiters (**Arm → Send → Wait**), not fixed long sleeps.
|
||||||
|
6. Severity helpers for fatals; quest DB only after `Save`.
|
||||||
|
7. Spawn helpers with cleanup; summons cleaned via `CleanupOwnedSummons`.
|
||||||
|
|
||||||
|
Full API surface: harness `LLM_GUIDE.md` / `EXAMPLES.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common flakes and fixes
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Fix |
|
||||||
|
|---------|--------------|-----|
|
||||||
|
| NPC ignores bot / no aggro | GM mode still on | `CombatReady` / `CombatReadyFull` before pull |
|
||||||
|
| Boss resets mid-fight | `.gm on` for `.damage` | `Damage` / `DamageKill` (mode off) |
|
||||||
|
| Melee out of range after `.tele` | Named tele short of target | `GoCreatureID` after `TeleNamed` |
|
||||||
|
| Unit not in cache after tele | Cache clear on transfer | Re-`WaitUnit` after tele / `WaitInWorld` |
|
||||||
|
| Random combat on pad | Leftover spawns / pets / other package thrash | Cleanup helpers; `-parallel 1`; PackagePad |
|
||||||
|
| Cast fails “in combat” on empty pad | Contested pad / leftover temp | `CombatStop`; despawn litter; unique pad |
|
||||||
|
| Loot roll never starts | Wrong creature or threshold | `CreatureGroupLootFixture` (15209) + `LootThresholdUncommon` |
|
||||||
|
| Quest status wrong in DB | Asserted before save | `Save` / `QuestStatusAfterSave` |
|
||||||
|
| GM commands silently ignored | Wrong race language | Set `Race` on bot (harness uses racial language) |
|
||||||
|
| `SOFT-PASS disabled` fatal | SoftPass without opt-in | Use real severity helpers, or `E2E_ALLOW_SOFT_PASS=1` locally only |
|
||||||
|
| `precondition:` spam | Stack/DB/env mismatch | Check `E2E_*`, world up, DSN DB names |
|
||||||
|
| Intermittent pass without code change | Sleeps / races | Replace sleeps with waiters; fix Arm→Send→Wait |
|
||||||
|
|
||||||
|
A test that fails intermittently on a **correct** core is a test/harness bug until proven otherwise. Do not hide flakes with longer sleeps or SoftPass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Severity markers
|
||||||
|
|
||||||
|
| Helper | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `Preconditionf` | Setup never reached a judgeable state |
|
||||||
|
| `ConfirmedBugf(t, issue, …)` | Core wrong for tracked AC issue/PR |
|
||||||
|
| `HarnessFailf` | Infra: timeout, SQL, empty cache, send error |
|
||||||
|
| `Assertf` / `AssertBugf` | Post-drive product oracle |
|
||||||
|
| `SoftWarnf` | Non-fatal note |
|
||||||
|
| `SoftPass` | **Fail-closed** unless `E2E_ALLOW_SOFT_PASS=1` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Policy (when to add e2e)
|
||||||
|
|
||||||
|
Use `.agents/docs/e2e-policy.md` for decision trees (e2e vs unit, mandatory triggers, MVT). This README is **how to run and structure** the suite; the harness guides are **how to author** scenarios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Details live in the workflow files only:
|
||||||
|
[`.github/workflows/e2e-live.yml`](../.github/workflows/e2e-live.yml),
|
||||||
|
[`.github/workflows/core-build-nopch.yml`](../.github/workflows/core-build-nopch.yml).
|
||||||
|
|
||||||
|
| How | Effect |
|
||||||
|
|-----|--------|
|
||||||
|
| Non-draft PR in **azerothcore/azerothcore-wotlk** | nopch `ubuntu-24.04`/clang-18 compiles + dry-run, then **full** e2e reuses those binaries |
|
||||||
|
| Merge to `master` | same clang-18 nopch build, then **full** e2e again (flake + merge-base drift) |
|
||||||
|
| Actions → **e2e-live** → Run workflow (official repo; needs workflow on default branch, or `gh workflow run … --ref e2e`) | Compiles on the runner; choose scope (smoke/full) |
|
||||||
|
|
||||||
|
Day-to-day development and agent debugging should use a **local** stack + `e2e/local/` or the committed suites — not CI setup docs.
|
||||||
|
|
||||||
|
Greppable failure prefixes: `precondition:`, `AC#N CONFIRMED BUG:`, `harness:`, `WARNING:`.
|
||||||
+14
@@ -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.
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"})
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package threat_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pullDummy is a high-HP training dummy for kill/engage-without-target oracles.
|
||||||
|
// L1 Target Dummy (2673) oneshots under L80 autoattack before combat is observed.
|
||||||
|
const pullDummy = e2eharness.CreatureHeroicTrainingDummy
|
||||||
|
|
||||||
|
// pullHostile is a real combat AI NPC (Crimson Templar). Training dummies never set
|
||||||
|
// UNIT_FIELD_TARGET, so target/taunt oracles must use a creature with a threat table.
|
||||||
|
const pullHostile = e2eharness.CreatureGroupLootFixture // 15209
|
||||||
|
|
||||||
|
// THREAT-01: engage hostile → unit in combat + targets player.
|
||||||
|
func TestThreat_EngageSetsTarget(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat"}, Runtime: "short", Category: "combat/threat"})
|
||||||
|
|
||||||
|
// L50 vs L60 templar: low player damage (no oneshot), god keeps player alive.
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "ThrEng",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 50,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
mob := bot.Spawn(t, pullHostile, 15*time.Second)
|
||||||
|
bot.CombatReady(t)
|
||||||
|
bot.Engage(t, mob, 15*time.Second)
|
||||||
|
bot.WaitUnitCombat(t, mob, 15*time.Second)
|
||||||
|
// Product oracle: real AI must target the attacker after pull.
|
||||||
|
bot.WaitUnitTarget(t, mob, bot.GUID, 15*time.Second)
|
||||||
|
bot.AssertUnitTarget(t, mob, bot.GUID)
|
||||||
|
t.Logf("PASS engage combat target=0x%X player=0x%X", bot.UnitTarget(mob), bot.GUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// THREAT-02: two bots; Taunt switches UNIT_FIELD_TARGET to the tank.
|
||||||
|
//
|
||||||
|
// WotLK 3.3.5a (wowhead/evowow spell 355): Taunt is instant, 30 yd, **Requires Defensive Stance**
|
||||||
|
// (stance form 2 / spell 71) — NOT Battle Stance (2457). Casting Taunt in Battle Stance
|
||||||
|
// correctly returns SPELL_FAILED_ONLY_SHAPESHIFT (94). Correct tank flow:
|
||||||
|
//
|
||||||
|
// Defensive Stance → DPS pulls so mob is not already on tank → Taunt.
|
||||||
|
func TestThreat_TauntSwitchesTarget(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "combat", "multi_bot"}, Runtime: "med", Category: "combat/threat"})
|
||||||
|
|
||||||
|
// L80 so Taunt (physical) does not miss a scaled L80 mob (L12 vs L80 miss is near-certain).
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "ThrTnt",
|
||||||
|
Bots: []e2eharness.BotSpec{
|
||||||
|
{Role: "tank", Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
|
||||||
|
{Role: "dps", Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
tank := e2eharness.ByRole(t, bots, "tank")
|
||||||
|
dps := e2eharness.ByRole(t, bots, "dps")
|
||||||
|
pad := e2eharness.PackagePad(t)
|
||||||
|
e2eharness.TeleportAll(t, bots, pad.X, pad.Y, pad.Z, pad.Map)
|
||||||
|
for _, b := range bots {
|
||||||
|
b.GM(t, ".combatstop")
|
||||||
|
b.GM(t, ".cheat god on")
|
||||||
|
b.GM(t, ".cheat power on")
|
||||||
|
}
|
||||||
|
// Real threat AI (not training dummy). Crimson Templar L60 works for engage+taunt at L80.
|
||||||
|
tank.DespawnNearbyEntry(t, pullHostile, 80)
|
||||||
|
mob := tank.Spawn(t, pullHostile, 15*time.Second)
|
||||||
|
tx, ty, tz, tm := tank.Pos()
|
||||||
|
dps.Teleport(t, tx+1, ty, tz, tm)
|
||||||
|
if dps.World.GetObject(mob) == nil {
|
||||||
|
seen := dps.WaitUnit(t, pullHostile, 15*time.Second)
|
||||||
|
if seen != 0 {
|
||||||
|
mob = seen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dps.CombatReady(t)
|
||||||
|
tank.CombatReady(t)
|
||||||
|
tank.GM(t, ".cheat power on")
|
||||||
|
tank.FlushWorld(t)
|
||||||
|
|
||||||
|
// Explicit learn — Defensive Stance / Taunt come from the L10 warrior quest, not only learn-all.
|
||||||
|
tank.Learn(t, e2eharness.SpellDefensiveStance)
|
||||||
|
tank.Learn(t, e2eharness.SpellTaunt)
|
||||||
|
|
||||||
|
// WotLK: Taunt Forms = Defensive Stance only (spell 71).
|
||||||
|
tank.CastMust(t, e2eharness.SpellDefensiveStance, 0, 10*time.Second)
|
||||||
|
stanceDeadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(stanceDeadline) {
|
||||||
|
if tank.HasAura(e2eharness.SpellDefensiveStance) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !tank.HasAura(e2eharness.SpellDefensiveStance) {
|
||||||
|
e2eharness.Preconditionf(t, "tank missing Defensive Stance aura before Taunt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft pull: mob must be attacking DPS first (tooltip: no effect if already on you).
|
||||||
|
_ = dps.World.SetTarget(mob)
|
||||||
|
dps.GM(t, ".damage 1")
|
||||||
|
dps.WaitUnitTarget(t, mob, dps.GUID, 10*time.Second)
|
||||||
|
|
||||||
|
tank.Face(t, mob)
|
||||||
|
_ = tank.World.SetTarget(mob)
|
||||||
|
// Product path: client Taunt in Defensive Stance retargets the mob to the tank.
|
||||||
|
tank.CastMust(t, e2eharness.SpellTaunt, mob, 10*time.Second)
|
||||||
|
tank.WaitUnitTarget(t, mob, tank.GUID, 10*time.Second)
|
||||||
|
tank.AssertUnitTarget(t, mob, tank.GUID)
|
||||||
|
tank.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS taunt switched target to tank=0x%X", tank.GUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// THREAT-03: kill target clears combat eventually.
|
||||||
|
func TestThreat_KillClearsCombat(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat"}, Runtime: "short", Category: "combat/threat"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "ThrKill",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
dummy := bot.Spawn(t, pullDummy, 15*time.Second)
|
||||||
|
bot.CombatReady(t)
|
||||||
|
bot.Engage(t, dummy, 15*time.Second)
|
||||||
|
// Large chunks; Heroic dummy has huge HP pool.
|
||||||
|
bot.DamageKill(t, []uint64{dummy}, 50_000_000, 30*time.Second)
|
||||||
|
bot.WaitUnitDead(t, dummy, 15*time.Second)
|
||||||
|
// 31146 training dummies can leave the player flagged; the unit itself must drop combat.
|
||||||
|
deadline := time.Now().Add(8 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !bot.UnitInCombat(dummy) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if bot.UnitInCombat(dummy) {
|
||||||
|
e2eharness.Assertf(t, "dummy 0x%X still UNIT_FLAG_IN_COMBAT after death", dummy)
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS kill dummy dead combat=%v", bot.UnitInCombat(dummy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// THREAT-04: multi-bot form party then pull (setup for threat tables).
|
||||||
|
func TestThreat_PartyPullSetup(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "multi_bot"}, Runtime: "short", Category: "combat/threat"})
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "ThrPty", Count: 2, Level: 80, LearnAllClass: true})
|
||||||
|
pad := e2eharness.PackagePad(t)
|
||||||
|
e2eharness.TeleportAll(t, bots, pad.X, pad.Y, pad.Z, pad.Map)
|
||||||
|
for _, b := range bots {
|
||||||
|
b.GM(t, ".combatstop")
|
||||||
|
b.GM(t, ".cheat god on")
|
||||||
|
}
|
||||||
|
bots[0].DespawnNearbyEntry(t, pullDummy, 80)
|
||||||
|
e2eharness.FormParty(t, bots[0], bots[1])
|
||||||
|
dummy := bots[0].Spawn(t, pullDummy, 15*time.Second)
|
||||||
|
bots[0].CombatReady(t)
|
||||||
|
bots[0].Engage(t, dummy, 15*time.Second)
|
||||||
|
if !bots[0].InGroup() || !bots[1].InGroup() {
|
||||||
|
e2eharness.Assertf(t, "party gone after pull in0=%v in1=%v", bots[0].InGroup(), bots[1].InGroup())
|
||||||
|
}
|
||||||
|
if !bots[0].UnitInCombat(dummy) && e2eharness.UnitTargetGUID(bots[0].World, dummy) != bots[0].GUID {
|
||||||
|
e2eharness.Assertf(t, "dummy 0x%X not in combat and not targeting leader after party pull", dummy)
|
||||||
|
}
|
||||||
|
t.Logf("PASS party pull setup dummy=0x%X combat=%v", dummy, bots[0].UnitInCombat(dummy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// THREAT-05: WaitUnitTarget helper after engage on a real threat AI.
|
||||||
|
func TestThreat_WaitUnitTargetHelper(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat"}, Runtime: "short", Category: "combat/threat"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "ThrWait",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 50,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
mob := bot.Spawn(t, pullHostile, 15*time.Second)
|
||||||
|
bot.CombatReady(t)
|
||||||
|
bot.Engage(t, mob, 15*time.Second)
|
||||||
|
bot.WaitUnitTarget(t, mob, bot.GUID, 15*time.Second)
|
||||||
|
bot.AssertUnitTarget(t, mob, bot.GUID)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS WaitUnitTarget target=0x%X", bot.UnitTarget(mob))
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package vehicles_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VEH-* : EnterVehicle / ExitVehicle / IsOnVehicle via Stormwind Steed on PackagePad.
|
||||||
|
|
||||||
|
// spellArmistice (64373) is required by creature_template vehicle conditions
|
||||||
|
// on Stormwind Steed 33217. VehicleAI/SmartAI ExitVehicle the rider without it
|
||||||
|
// (conditions.comment: "Vehicle Stormwind Steed requires aura Armistice").
|
||||||
|
const spellArmistice uint32 = 64373
|
||||||
|
|
||||||
|
func spawnAndBoardSteed(t *testing.T, bot *e2eharness.ScenarioBot) (vehGUID, boarded uint64) {
|
||||||
|
t.Helper()
|
||||||
|
bot.ApplyAura(t, spellArmistice)
|
||||||
|
if !bot.HasAura(spellArmistice) {
|
||||||
|
e2eharness.Preconditionf(t, "Armistice %d not on player (steed vehicle condition)", spellArmistice)
|
||||||
|
}
|
||||||
|
vehGUID = bot.Spawn(t, e2eharness.CreatureStormwindSteed, 15*time.Second)
|
||||||
|
if vehGUID == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "Stormwind Steed spawn returned guid 0")
|
||||||
|
}
|
||||||
|
boarded = bot.EnterVehicle(t, vehGUID, 12*time.Second)
|
||||||
|
if !bot.IsOnVehicle() {
|
||||||
|
e2eharness.Preconditionf(t, "not on steed 0x%X after enter (charm=0x%X)", vehGUID, bot.World.PlayerCharmGUID())
|
||||||
|
}
|
||||||
|
return vehGUID, boarded
|
||||||
|
}
|
||||||
|
|
||||||
|
// VEH-01: two bots at the pad; one boards a Stormwind Steed.
|
||||||
|
func TestVehicles_MultiBotColocated(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "multi_bot"}, Runtime: "short", Category: "combat/vehicles"})
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "VehCol", Count: 2, Level: 80})
|
||||||
|
pad := e2eharness.PackagePad(t)
|
||||||
|
e2eharness.TeleportAllPad(t, bots, pad)
|
||||||
|
vehGUID, _ := spawnAndBoardSteed(t, bots[0])
|
||||||
|
bots[1].AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS vehicle multi-bot colocation boarded=0x%X", vehGUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VEH-02: relog on the pad, then board a Stormwind Steed.
|
||||||
|
func TestVehicles_RelogAfterTeleBaseline(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "protocol"}, Runtime: "short", Category: "combat/vehicles"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "VehRl", Level: 80})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bot.Save(t)
|
||||||
|
bot.Relog(t)
|
||||||
|
vehGUID, _ := spawnAndBoardSteed(t, bot)
|
||||||
|
t.Logf("PASS relog then board steed 0x%X", vehGUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VEH-03: passenger hard-drop while on a vehicle must leave the probe world alive.
|
||||||
|
func TestVehicles_HardDisconnectWorldAlive(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/vehicles"})
|
||||||
|
|
||||||
|
probe := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "VehPr", Level: 10})
|
||||||
|
vic := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "VehVc", Level: 80})
|
||||||
|
vic.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
vehGUID, _ := spawnAndBoardSteed(t, vic)
|
||||||
|
e2eharness.HardDisconnectAndProbe(t, vic, probe, 0)
|
||||||
|
t.Logf("PASS passenger hard-drop world alive steed=0x%X", vehGUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VEH-04: EnterVehicle → IsOnVehicle → ExitVehicle on Stormwind Steed fixture.
|
||||||
|
func TestVehicles_EnterExitStormwindSteed(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "combat", "serial"}, Runtime: "short", Category: "combat/vehicles"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "VehEn", Level: 80})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
if bot.IsOnVehicle() {
|
||||||
|
e2eharness.Preconditionf(t, "already on vehicle before enter")
|
||||||
|
}
|
||||||
|
vehGUID, _ := spawnAndBoardSteed(t, bot)
|
||||||
|
bot.ExitVehicle(t, 12*time.Second)
|
||||||
|
if bot.IsOnVehicle() {
|
||||||
|
e2eharness.Assertf(t, "still IsOnVehicle after ExitVehicle (0x%X)", bot.VehicleGUID())
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS EnterVehicle/ExitVehicle Stormwind Steed 0x%X", vehGUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package charter_bank_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GUILD-01: charter buy + full turn-in via harness CreateGuildViaCharter (SetupGuildLeader).
|
||||||
|
func TestGuild_CharterBuy(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "guild"}, Runtime: "med", Category: "guild/charter_bank"})
|
||||||
|
|
||||||
|
leader, setup, charDB := e2eharness.SetupGuildLeader(t, "GldBuy")
|
||||||
|
if leader == nil || leader.GUID == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "guild leader setup failed")
|
||||||
|
}
|
||||||
|
if setup.GuildName == "" {
|
||||||
|
e2eharness.Assertf(t, "SetupGuildLeader returned empty guild name (charter turn-in incomplete)")
|
||||||
|
}
|
||||||
|
if setup.PetitionGUID == 0 && setup.ItemLow == 0 {
|
||||||
|
e2eharness.Assertf(t, "SetupGuildLeader missing petition identity (charter buy incomplete)")
|
||||||
|
}
|
||||||
|
// guild_member insert is async (CharacterDatabase worker) after TURN_IN OK.
|
||||||
|
guid := leader.GUID & 0xffffffff
|
||||||
|
var guildID uint32
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
var err error
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
err = charDB.QueryRow(`SELECT guildid FROM guild_member WHERE guid=?`, guid).Scan(&guildID)
|
||||||
|
if err == nil && guildID != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err = charDB.QueryRow(`SELECT guildid FROM guild WHERE leaderguid=?`, guid).Scan(&guildID)
|
||||||
|
if err == nil && guildID != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if guildID == 0 {
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
e2eharness.Assertf(t, "leader not in guild_member/guild after charter turn-in: err=%v guid=%d", err, guid)
|
||||||
|
} else {
|
||||||
|
e2eharness.Assertf(t, "leader not in guild_member/guild after charter turn-in (no row) guid=%d", guid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("PASS charter buy+turn-in guild=%q id=%d leader=%d petition=0x%X",
|
||||||
|
setup.GuildName, guildID, leader.GUID, setup.PetitionGUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GUILD-03: multi-bot login for charter signs precondition.
|
||||||
|
func TestGuild_MultiBotLogin(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "guild", "multi_bot"}, Runtime: "med", Category: "guild/charter_bank"})
|
||||||
|
|
||||||
|
idents := e2eharness.MakeBotIdents("GldSign", 3)
|
||||||
|
auth, char := e2eharness.OpenTestDBs(t)
|
||||||
|
e2eharness.EnsureBotAccounts(t, auth, idents)
|
||||||
|
_ = char
|
||||||
|
sessions := e2eharness.LoginAllianceBots(t, idents)
|
||||||
|
if len(sessions) != 3 {
|
||||||
|
e2eharness.Preconditionf(t, "want 3 sessions, got %d", len(sessions))
|
||||||
|
}
|
||||||
|
t.Logf("PASS multi-bot guild login n=%d", len(sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GUILD-04: ModMoney for charter cost.
|
||||||
|
func TestGuild_MoneyForCharter(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "guild"}, Runtime: "short", Category: "guild/charter_bank"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "GldMny", Level: 20})
|
||||||
|
want := uint32(e2eharness.GuildCharterCostCopper + 1_000_000)
|
||||||
|
bot.ModMoney(t, want)
|
||||||
|
bot.AssertMoneyAtLeast(t, want)
|
||||||
|
t.Logf("PASS money for charter cost=%d", e2eharness.GuildCharterCostCopper)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package bind_reset_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BIND-* : instance party baselines + ritual summon (3 roles) + #10708.
|
||||||
|
|
||||||
|
// game_tele name on this realm (not "Stockades"). Exterior entrance on map 0.
|
||||||
|
const teleTheStockade = "TheStockade"
|
||||||
|
|
||||||
|
// Stockade interior (map 34) — real dungeon instance, not PackagePad / outdoor isolation pads.
|
||||||
|
// Coords match areatrigger "Stormwind Stockades Entrance" (quieter than deeper hall packs).
|
||||||
|
// Used for ritual summon, bind soft checks, and co-located .summon into one instance copy.
|
||||||
|
var stockadeInterior = e2eharness.Position3{
|
||||||
|
X: 54.23, Y: 0.28, Z: -18.34, Map: 34,
|
||||||
|
}
|
||||||
|
|
||||||
|
// teleDungeonOrPrecondition runs a named tele and fails as precondition if position/map did not move.
|
||||||
|
func teleDungeonOrPrecondition(t *testing.T, bot *e2eharness.ScenarioBot, name string) (mapID uint32) {
|
||||||
|
t.Helper()
|
||||||
|
x0, y0, z0, m0 := bot.Pos()
|
||||||
|
bot.TeleNamed(t, name)
|
||||||
|
x1, y1, z1, m1 := bot.Pos()
|
||||||
|
moved := m1 != m0 || e2eharness.Distance3D(x0, y0, z0, x1, y1, z1) > 5
|
||||||
|
if !moved {
|
||||||
|
e2eharness.Preconditionf(t,
|
||||||
|
"named tele %q did not move player (still map=%d pos=%.1f,%.1f,%.1f) — missing game_tele?",
|
||||||
|
name, m1, x1, y1, z1)
|
||||||
|
}
|
||||||
|
return m1
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIND-01: party of 2 formed (instance group precondition).
|
||||||
|
func TestBind_PartyFormedForInstance(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "instance", "multi_bot"}, Runtime: "short", Category: "instances/bind_reset"})
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "BindPty", Count: 2, Level: 80})
|
||||||
|
e2eharness.FormParty(t, bots[0], bots[1])
|
||||||
|
if !bots[0].InGroup() || !bots[1].InGroup() {
|
||||||
|
e2eharness.Assertf(t, "party not formed in0=%v in1=%v", bots[0].InGroup(), bots[1].InGroup())
|
||||||
|
}
|
||||||
|
t.Logf("PASS instance party formed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIND-02: named dungeon tele (TheStockade) world alive.
|
||||||
|
func TestBind_NamedDungeonTele(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instance"}, Runtime: "med", Category: "instances/bind_reset"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "BindStk", Level: 80})
|
||||||
|
// AC game_tele is "TheStockade" (not "Stockades"); wrong name hangs TeleNamed ~60s.
|
||||||
|
m := teleDungeonOrPrecondition(t, bot, teleTheStockade)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS named dungeon tele map=%d", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIND-03: group shared tele attempt.
|
||||||
|
func TestBind_GroupTeleTogether(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instance", "multi_bot"}, Runtime: "med", Category: "instances/bind_reset"})
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "BindGrp", Count: 2, Level: 80})
|
||||||
|
e2eharness.FormParty(t, bots[0], bots[1])
|
||||||
|
var maps []uint32
|
||||||
|
for _, b := range bots {
|
||||||
|
maps = append(maps, teleDungeonOrPrecondition(t, b, teleTheStockade))
|
||||||
|
}
|
||||||
|
if maps[0] != maps[1] {
|
||||||
|
e2eharness.Assertf(t, "group not on same map after named tele leader=%d mate=%d", maps[0], maps[1])
|
||||||
|
}
|
||||||
|
if !bots[0].InGroup() || !bots[1].InGroup() {
|
||||||
|
e2eharness.Assertf(t, "party split after tele in0=%v in1=%v", bots[0].InGroup(), bots[1].InGroup())
|
||||||
|
}
|
||||||
|
t.Logf("PASS group tele together map=%d", maps[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// partyInStockadeForRitual places all three bots in the same Stockade instance.
|
||||||
|
// Separate .go xyz into map 34 can open different instance IDs — leader enters first, then
|
||||||
|
// GM .summon pulls helper and far into that copy.
|
||||||
|
//
|
||||||
|
// EffectSummonPlayer CheckCast requires a selectable same-raid target; casting while the
|
||||||
|
// far player is on another map fails under GM .cast and often under dungeon access checks.
|
||||||
|
// Issue the ritual / summon request while co-located in the instance, then send far outside
|
||||||
|
// before AcceptSummon when the test needs the exterior.
|
||||||
|
func partyInStockadeForRitual(t *testing.T, initiator, helper, far *e2eharness.ScenarioBot) {
|
||||||
|
t.Helper()
|
||||||
|
e2eharness.FormParty(t, initiator, helper, far)
|
||||||
|
|
||||||
|
// Initiator enters Stockade interior (real instance map 34) — not PackagePad.
|
||||||
|
initiator.Teleport(t, stockadeInterior.X, stockadeInterior.Y, stockadeInterior.Z, stockadeInterior.Map)
|
||||||
|
_, _, _, im := initiator.Pos()
|
||||||
|
if im != stockadeInterior.Map {
|
||||||
|
e2eharness.Preconditionf(t, "initiator not on Stockade map %d (got %d)", stockadeInterior.Map, im)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mate := range []*e2eharness.ScenarioBot{helper, far} {
|
||||||
|
before := mate.World.TeleportSeq()
|
||||||
|
initiator.GM(t, ".summon "+mate.Name)
|
||||||
|
if err := mate.World.WaitForTeleportAfter(before, 15*time.Second); err != nil {
|
||||||
|
e2eharness.Preconditionf(t, "%s .summon into Stockade: %v", mate.Name, err)
|
||||||
|
}
|
||||||
|
_, _, _, mm := mate.Pos()
|
||||||
|
if mm != stockadeInterior.Map {
|
||||||
|
e2eharness.Preconditionf(t, "%s not on Stockade map after .summon (map=%d)", mate.Name, mm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trash near the entrance can peel selection / break portal completion cast 7720.
|
||||||
|
for _, b := range []*e2eharness.ScenarioBot{initiator, helper, far} {
|
||||||
|
b.CombatStop(t)
|
||||||
|
b.FlushWorld(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendFarOutsideStockade moves far to the exterior entrance (map 0) while leaving a
|
||||||
|
// pending summon intact (do this only after SMSG_SUMMON_REQUEST).
|
||||||
|
func sendFarOutsideStockade(t *testing.T, far *e2eharness.ScenarioBot) {
|
||||||
|
t.Helper()
|
||||||
|
teleDungeonOrPrecondition(t, far, teleTheStockade)
|
||||||
|
_, _, _, m := far.Pos()
|
||||||
|
if m == stockadeInterior.Map {
|
||||||
|
e2eharness.Preconditionf(t, "far still on instance map %d after exterior tele", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIND-04: 3-role ritual summon inside Stockade, then far accepts from exterior.
|
||||||
|
//
|
||||||
|
// Flow (portal GO 179944, reqParticipants=2):
|
||||||
|
// 1. all three enter the same Stockade instance
|
||||||
|
// 2. initiator + helper run the portal; far receives SMSG_SUMMON_REQUEST
|
||||||
|
// 3. far leaves to exterior, then AcceptSummon back into the instance
|
||||||
|
func TestBind_RitualSummonAccept(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"short", "instance", "multi_bot", "serial"},
|
||||||
|
Runtime: "short",
|
||||||
|
Category: "instances/bind_reset",
|
||||||
|
})
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "BindRt", Count: 3, Level: 80})
|
||||||
|
initiator, helper, far := bots[0], bots[1], bots[2]
|
||||||
|
partyInStockadeForRitual(t, initiator, helper, far)
|
||||||
|
|
||||||
|
waitSummon, cancelSummon := far.ArmSummonRequest()
|
||||||
|
defer cancelSummon()
|
||||||
|
e2eharness.RitualSummon(t, initiator, helper, far)
|
||||||
|
req, err := waitSummon(25 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
e2eharness.Preconditionf(t, "no SMSG_SUMMON_REQUEST after ritual: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave instance while summon is pending, then accept back in.
|
||||||
|
sendFarOutsideStockade(t, far)
|
||||||
|
|
||||||
|
far.AcceptSummon(t, req.SummonerGUID)
|
||||||
|
far.AssertWorldAlive(t)
|
||||||
|
initiator.AssertWorldAlive(t)
|
||||||
|
|
||||||
|
_, _, _, farMap := far.Pos()
|
||||||
|
if farMap != stockadeInterior.Map {
|
||||||
|
e2eharness.Assertf(t, "after AcceptSummon expected Stockade map %d, far map=%d",
|
||||||
|
stockadeInterior.Map, farMap)
|
||||||
|
}
|
||||||
|
fx, fy, fz, _ := far.Pos()
|
||||||
|
ix, iy, iz, _ := initiator.Pos()
|
||||||
|
if e2eharness.Distance3D(fx, fy, fz, ix, iy, iz) > 40 {
|
||||||
|
e2eharness.Assertf(t, "after AcceptSummon far still far from initiator (d=%.1f)",
|
||||||
|
e2eharness.Distance3D(fx, fy, fz, ix, iy, iz))
|
||||||
|
}
|
||||||
|
t.Logf("PASS ritual summon accept into instance summoner=0x%X farMap=%d", req.SummonerGUID, farMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPEN(e2e): re-enable when AC#10708 is fixed — post-reset AcceptSummon must co-locate maps.
|
||||||
|
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/10708
|
||||||
|
/*
|
||||||
|
func TestBind_ResetSummonExploit_10708(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"long", "instance", "issue", "multi_bot", "serial"},
|
||||||
|
Runtime: "long",
|
||||||
|
Issue: 10708,
|
||||||
|
Category: "instances/bind_reset",
|
||||||
|
})
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{Prefix: "BindX7", Count: 3, Level: 80})
|
||||||
|
initiator, helper, far := bots[0], bots[1], bots[2]
|
||||||
|
partyInStockadeForRitual(t, initiator, helper, far)
|
||||||
|
|
||||||
|
waitSummon, cancelSummon := far.ArmSummonRequest()
|
||||||
|
defer cancelSummon()
|
||||||
|
e2eharness.RitualSummon(t, initiator, helper, far)
|
||||||
|
req, err := waitSummon(25 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
e2eharness.Preconditionf(t, "no SMSG_SUMMON_REQUEST: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFarOutsideStockade(t, far)
|
||||||
|
initiator.LeaderResetInstances(t, 5*time.Second)
|
||||||
|
|
||||||
|
far.AcceptSummon(t, req.SummonerGUID)
|
||||||
|
far.AssertWorldAlive(t)
|
||||||
|
initiator.AssertWorldAlive(t)
|
||||||
|
|
||||||
|
_, _, _, farMap := far.Pos()
|
||||||
|
_, _, _, leadMap := initiator.Pos()
|
||||||
|
if farMap != leadMap {
|
||||||
|
e2eharness.Assertf(t, "after reset+AcceptSummon far map=%d initiator map=%d (summoner=0x%X)",
|
||||||
|
farMap, leadMap, req.SummonerGUID)
|
||||||
|
}
|
||||||
|
t.Logf("PASS #10708 path farMap=%d leadMap=%d", farMap, leadMap)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// BIND-05: character_instance row present after instance map enter + save.
|
||||||
|
func TestBind_CharacterInstanceAfterEnter(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instance"}, Runtime: "med", Category: "instances/bind_reset"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "BindDB", Level: 80})
|
||||||
|
bot.Teleport(t, stockadeInterior.X, stockadeInterior.Y, stockadeInterior.Z, stockadeInterior.Map)
|
||||||
|
_, _, _, m := bot.Pos()
|
||||||
|
if m != stockadeInterior.Map {
|
||||||
|
e2eharness.Preconditionf(t, "expected Stockade map %d after .go xyz, got %d", stockadeInterior.Map, m)
|
||||||
|
}
|
||||||
|
bot.Save(t)
|
||||||
|
var n int
|
||||||
|
var lastErr error
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
lastErr = bot.CharDB.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM character_instance WHERE guid=?`, bot.GUID,
|
||||||
|
).Scan(&n)
|
||||||
|
if lastErr == nil && n > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
e2eharness.HarnessFailf(t, "character_instance query: %v", lastErr)
|
||||||
|
}
|
||||||
|
if n <= 0 {
|
||||||
|
e2eharness.Assertf(t, "expected character_instance row after Stockade enter+save, got count=%d map=%d", n, m)
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS character_instance rows=%d map=%d", n, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package ulduar_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OPEN(e2e): re-enable when AC#26266 is fixed — Charge near Kologarn must not drop below bridge Z.
|
||||||
|
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/26266
|
||||||
|
// Placement: charge pad ~15y west of Kologarn spawn (1797.15,-24.4,448.7) at (1782.15,-24.4,448.7).
|
||||||
|
/*
|
||||||
|
func TestUlduar_KologarnChargeWorldAlive(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"long", "instances", "issue"},
|
||||||
|
Runtime: "long",
|
||||||
|
Issue: 26266,
|
||||||
|
Category: "instances/northrend/ulduar",
|
||||||
|
})
|
||||||
|
posCharge := e2eharness.Position3{X: 1782.15, Y: -24.4027, Z: 448.741, Map: e2eharness.MapUlduar}
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "UldKol", Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleNamed(t, "Kologarn")
|
||||||
|
bot.Teleport(t, posCharge.X, posCharge.Y, posCharge.Z, posCharge.Map)
|
||||||
|
kolo := bot.WaitUnit(t, e2eharness.CreatureKologarn, 30*time.Second)
|
||||||
|
bot.Learn(t, e2eharness.SpellBattleStance)
|
||||||
|
bot.Learn(t, e2eharness.SpellCharge)
|
||||||
|
bot.CombatReadyFull(t)
|
||||||
|
bot.CastSelfGM(t, e2eharness.SpellBattleStance)
|
||||||
|
preX, preY, preZ, _ := bot.Pos()
|
||||||
|
bot.Face(t, kolo)
|
||||||
|
res, err := bot.TryCast(t, e2eharness.SpellCharge, kolo, 12*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
e2eharness.Assertf(t, "Charge cast result timeout: %v", err)
|
||||||
|
} else if res == nil || !res.Success {
|
||||||
|
e2eharness.Assertf(t, "Charge fail reason=%s", e2eharness.SpellFailReasonName(res.FailReason))
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(1500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
x, y, z, _ := bot.Pos()
|
||||||
|
if e2eharness.Distance3D(preX, preY, preZ, x, y, z) > 1.0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
x, y, z, m := bot.Pos()
|
||||||
|
if z < preZ-20 {
|
||||||
|
e2eharness.Assertf(t, "Charge landed below bridge: z=%.1f preZ=%.1f pos=(%.1f,%.1f) map=%d", z, preZ, x, y, m)
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS Kologarn charge path map=%d pos=(%.1f,%.1f,%.1f)", m, x, y, z)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27095
|
||||||
|
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27113
|
||||||
|
// Killing an older Allies of Nature set must not accelerate the next wave
|
||||||
|
// (only the current set's death reschedules EVENT_FREYA_ADDS_SPAM to 5s).
|
||||||
|
func TestAC_27095_FreyaAlliesSpawnRateReduction(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"long", "instances", "issue"},
|
||||||
|
Runtime: "long",
|
||||||
|
Issue: 27095,
|
||||||
|
Category: "instances/northrend/ulduar",
|
||||||
|
})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "Freya",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
|
||||||
|
const (
|
||||||
|
npcFreya10 = uint32(32906)
|
||||||
|
npcFreya25 = uint32(33360)
|
||||||
|
npcStormLasher = uint32(32919)
|
||||||
|
npcWaterSpirit = uint32(33202)
|
||||||
|
npcSnaplasher = uint32(32916)
|
||||||
|
npcConservator = uint32(33203)
|
||||||
|
npcDetonatingLasher = uint32(32918)
|
||||||
|
)
|
||||||
|
allyEntries := []uint32{
|
||||||
|
npcStormLasher, npcWaterSpirit, npcSnaplasher,
|
||||||
|
npcConservator, npcDetonatingLasher,
|
||||||
|
}
|
||||||
|
kindName := map[uint32]string{
|
||||||
|
npcStormLasher: "Trio", npcWaterSpirit: "Trio", npcSnaplasher: "Trio",
|
||||||
|
npcConservator: "Conservator", npcDetonatingLasher: "Lashers",
|
||||||
|
}
|
||||||
|
label := func(entry uint32) string {
|
||||||
|
if n, ok := kindName[entry]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.TeleNamed(t, "Freya")
|
||||||
|
bot.GoCreatureID(t, npcFreya10)
|
||||||
|
bot.CombatReady(t)
|
||||||
|
|
||||||
|
freyaGUID := bot.WaitUnitAny(t, 30*time.Second, npcFreya10, npcFreya25)
|
||||||
|
bot.Engage(t, freyaGUID, 15*time.Second)
|
||||||
|
|
||||||
|
tr := e2eharness.NewSpawnSetTracker(allyEntries, 3*time.Second)
|
||||||
|
tr.KindOf = func(entry uint32) string { return label(entry) }
|
||||||
|
sets := tr.WaitSets(t, bot.World, 2, 4*time.Minute)
|
||||||
|
t.Logf("Set1=%s units=%d Set2=%s units=%d gap=%s",
|
||||||
|
sets[0].Kind, len(sets[0].Guids),
|
||||||
|
sets[1].Kind, len(sets[1].Guids),
|
||||||
|
sets[1].SpawnT.Sub(sets[0].SpawnT).Round(time.Millisecond))
|
||||||
|
|
||||||
|
// Detonating Lashers explode on death — if Set1 is Lashers, wait for Set3
|
||||||
|
// and kill Set2 (still: older set while a newer set is up).
|
||||||
|
if sets[0].Kind == "Lashers" {
|
||||||
|
sets = tr.WaitSets(t, bot.World, 3, 4*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
var older, newer e2eharness.SpawnSet
|
||||||
|
if sets[0].Kind == "Lashers" {
|
||||||
|
older, newer = sets[1], sets[2]
|
||||||
|
} else {
|
||||||
|
older, newer = sets[0], sets[1]
|
||||||
|
}
|
||||||
|
if older.Kind == "Lashers" {
|
||||||
|
e2eharness.Preconditionf(t, "cannot find a non-Lasher older set to kill without collateral explosions")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
tr.Poll(bot.World, time.Now())
|
||||||
|
|
||||||
|
var olderLive []uint64
|
||||||
|
for _, g := range older.Guids {
|
||||||
|
if hp, _ := bot.UnitHP(g); hp > 0 {
|
||||||
|
olderLive = append(olderLive, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(olderLive) == 0 {
|
||||||
|
switch older.Kind {
|
||||||
|
case "Trio":
|
||||||
|
olderLive = e2eharness.LivingByEntries(bot.World, 120, npcStormLasher, npcWaterSpirit, npcSnaplasher)
|
||||||
|
case "Conservator":
|
||||||
|
olderLive = e2eharness.LivingByEntries(bot.World, 120, npcConservator)
|
||||||
|
default:
|
||||||
|
olderLive = e2eharness.LivingByEntries(bot.World, 120, older.Entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var newerEntries []uint32
|
||||||
|
switch newer.Kind {
|
||||||
|
case "Trio":
|
||||||
|
newerEntries = []uint32{npcStormLasher, npcWaterSpirit, npcSnaplasher}
|
||||||
|
case "Conservator":
|
||||||
|
newerEntries = []uint32{npcConservator}
|
||||||
|
case "Lashers":
|
||||||
|
newerEntries = []uint32{npcDetonatingLasher}
|
||||||
|
default:
|
||||||
|
newerEntries = []uint32{newer.Entry}
|
||||||
|
}
|
||||||
|
newerN, _ := e2eharness.CountLivingWithRetry(bot.World, 120, newerEntries, 2*time.Second)
|
||||||
|
if len(olderLive) == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "older set (%s) already dead before damage step", older.Kind)
|
||||||
|
}
|
||||||
|
if newerN == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "newer set (%s) already dead before damage step", newer.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.DamageKill(t, olderLive, 10_000_000, 10*time.Second)
|
||||||
|
killT := time.Now()
|
||||||
|
|
||||||
|
knownAtKill := tr.Known()
|
||||||
|
for _, s := range bot.UnitsByEntry(120, allyEntries...) {
|
||||||
|
knownAtKill[s.GUID] = struct{}{}
|
||||||
|
}
|
||||||
|
fresh := bot.WaitNewUnits(t, knownAtKill, allyEntries, 90*time.Second)
|
||||||
|
if len(fresh) == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "no new ally set spawned within 90s after older-set kill")
|
||||||
|
}
|
||||||
|
nextT := time.Now()
|
||||||
|
fromNewer := nextT.Sub(newer.SpawnT)
|
||||||
|
fromKill := nextT.Sub(killT)
|
||||||
|
t.Logf("next set=%s units=%d (Δ from newer spawn=%s, Δ from older kill=%s)",
|
||||||
|
label(fresh[0].Entry), len(fresh),
|
||||||
|
fromNewer.Round(time.Millisecond), fromKill.Round(time.Millisecond))
|
||||||
|
|
||||||
|
e2eharness.AssertIntervalNotAccelerated(t, 27095, fromKill, fromNewer, e2eharness.IntervalBugOpts{
|
||||||
|
MaxFromEvent: 20 * time.Second,
|
||||||
|
MaxFromBaseline: 45 * time.Second,
|
||||||
|
})
|
||||||
|
t.Logf("PASS AC#27095 next set not accelerated by killing older set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ULDUAR-03: Ulduar map enter via named tele stays in-world.
|
||||||
|
func TestUlduar_NamedTeleEnter(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instances"}, Runtime: "med", Category: "instances/northrend/ulduar"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "UldEnt",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
// Stock game_tele "Ulduar" is Storm Peaks entrance (map 571), not raid 603.
|
||||||
|
bot.TeleNamed(t, "Ulduar")
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
_, _, _, m := bot.Pos()
|
||||||
|
if m != e2eharness.MapNorthrend {
|
||||||
|
e2eharness.Assertf(t, "TeleNamed Ulduar map=%d want Northrend %d", m, e2eharness.MapNorthrend)
|
||||||
|
}
|
||||||
|
t.Logf("PASS Ulduar named tele map=%d (Storm Peaks entrance)", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ULDUAR-04: engage + DamageKill path on a trash/dummy (raid helper training).
|
||||||
|
// L1 Target Dummy (2673) is oneshot by L80 before combat flag — use HeroicTrainingDummy.
|
||||||
|
func TestUlduar_DamageKillPathSafe(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instances"}, Runtime: "med", Category: "instances/northrend/ulduar"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "UldDmg",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
dummy := bot.Spawn(t, e2eharness.CreatureHeroicTrainingDummy, 15*time.Second)
|
||||||
|
bot.CombatReady(t)
|
||||||
|
bot.Engage(t, dummy, 20*time.Second)
|
||||||
|
bot.DamageKill(t, []uint64{dummy}, 50_000_000, 20*time.Second)
|
||||||
|
hp, _ := bot.UnitHP(dummy)
|
||||||
|
if hp > 0 {
|
||||||
|
e2eharness.Assertf(t, "dummy 0x%X still alive hp=%d after DamageKill", dummy, hp)
|
||||||
|
}
|
||||||
|
t.Logf("PASS DamageKill path dummy=0x%X dead", dummy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ULDUAR-05: dual-bot login near Freya does not thrash auth.
|
||||||
|
func TestUlduar_MultiBotLoginNearBossPad(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"med", "instances", "multi_bot"}, Runtime: "med", Category: "instances/northrend/ulduar"})
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "UldDuo",
|
||||||
|
Count: 2,
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
for _, b := range bots {
|
||||||
|
b.TeleNamed(t, "Freya")
|
||||||
|
}
|
||||||
|
_, _, _, m0 := bots[0].Pos()
|
||||||
|
_, _, _, m1 := bots[1].Pos()
|
||||||
|
if m0 != e2eharness.MapUlduar || m1 != e2eharness.MapUlduar {
|
||||||
|
e2eharness.Assertf(t, "Freya pad maps leader=%d mate=%d want %d", m0, m1, e2eharness.MapUlduar)
|
||||||
|
}
|
||||||
|
t.Logf("PASS multi-bot Freya pad login n=%d map=%d", len(bots), m0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package equip_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EQUIP-01: add+inventory oracle (EquipEntry is EQUIP-04).
|
||||||
|
func TestEquip_AddItemInventorySeed(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items"}, Runtime: "short", Category: "items/equip"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "EqBasic",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bot.AddItemWait(t, e2eharness.ItemTargetDummy, 1)
|
||||||
|
waitInventoryAtLeast(t, bot, e2eharness.ItemTargetDummy, 1)
|
||||||
|
t.Logf("PASS add item inventory seed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EQUIP-02: AddItemWait returns bag/slot.
|
||||||
|
func TestEquip_AddItemWaitSlot(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items"}, Runtime: "short", Category: "items/equip"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "EqSlot",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bag, slot := bot.AddItemWait(t, e2eharness.ItemTargetDummy, 1)
|
||||||
|
bot.AssertInventoryAtLeast(t, e2eharness.ItemTargetDummy, 1)
|
||||||
|
t.Logf("PASS AddItemWait bag=%d slot=%d inventory>=1", bag, slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EQUIP-03: multiple adds without crash.
|
||||||
|
func TestEquip_MultipleAddsNoCrash(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items"}, Runtime: "short", Category: "items/equip"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "EqMulti",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
bot.AddItem(t, e2eharness.ItemTargetDummy, 1)
|
||||||
|
}
|
||||||
|
bot.Save(t)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS multiple item adds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EQUIP-04: equip entry helper on a simple weapon if available via GM add+equip.
|
||||||
|
func TestEquip_EquipEntryHelper(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items"}, Runtime: "short", Category: "items/equip"})
|
||||||
|
|
||||||
|
const itemWornShortsword = 25 // Worn Shortsword, main-hand slot 15
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "EqWep",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 10,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bot.EquipEntry(t, itemWornShortsword, 1)
|
||||||
|
slot := bot.WaitEquipped(t, itemWornShortsword, 5*time.Second)
|
||||||
|
t.Logf("PASS EquipEntry helper worn entry=%d visible slot=%d", itemWornShortsword, slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EQUIP-05: bag seed + relog inventory path stays healthy.
|
||||||
|
func TestEquip_ItemSurvivesRelogPath(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "items", "protocol"}, Runtime: "short", Category: "items/equip"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "EqRelog",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
// AddItem is fire-and-forget; CharDB after an immediate .save often still
|
||||||
|
// reads 0 on a fast runner. Wait for the push, then poll the persisted count.
|
||||||
|
bot.AddItemWait(t, e2eharness.ItemTargetDummy, 2)
|
||||||
|
waitInventoryAtLeast(t, bot, e2eharness.ItemTargetDummy, 2)
|
||||||
|
bot.Relog(t)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
// Inventory should still be present after relog (CharDB / load path).
|
||||||
|
bot.AssertInventoryAtLeast(t, e2eharness.ItemTargetDummy, 2)
|
||||||
|
t.Logf("PASS item seed + relog inventory persists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitInventoryAtLeast polls CharDB (InventoryCount Saves) until count >= min.
|
||||||
|
func waitInventoryAtLeast(t *testing.T, bot *e2eharness.ScenarioBot, entry uint32, least int) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
var got int
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
got = bot.InventoryCount(t, entry)
|
||||||
|
if got >= least {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
e2eharness.Preconditionf(t, "inventory entry=%d count=%d want>=%d", entry, got, least)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package session_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SESS-01: position readable after login (seed of inventory/pos load).
|
||||||
|
func TestSession_PositionAfterLogin(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/session"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "SessPos",
|
||||||
|
Level: 20,
|
||||||
|
})
|
||||||
|
x, y, z, mapID := bot.Pos()
|
||||||
|
if bot.GUID == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "GUID 0 after login")
|
||||||
|
}
|
||||||
|
if mapID != e2eharness.MapEasternKingdoms {
|
||||||
|
e2eharness.Assertf(t, "login map=%d want %d pos=(%.1f,%.1f,%.1f)",
|
||||||
|
mapID, e2eharness.MapEasternKingdoms, x, y, z)
|
||||||
|
}
|
||||||
|
t.Logf("PASS pos after login map=%d (%.1f,%.1f,%.1f)", mapID, x, y, z)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SESS-02: item + quest present after load path.
|
||||||
|
func TestSession_ItemAndQuestAfterLoad(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/session"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "SessLd",
|
||||||
|
Level: 40,
|
||||||
|
})
|
||||||
|
bot.AddItemWait(t, e2eharness.ItemTargetDummy, 1)
|
||||||
|
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
bot.Save(t)
|
||||||
|
bot.AssertInventoryAtLeast(t, e2eharness.ItemTargetDummy, 1)
|
||||||
|
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
|
||||||
|
t.Logf("PASS item+quest after load/save")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SESS-03: set gold via GM, save, relog — CharDB money must match.
|
||||||
|
func TestSession_MoneyMutateSaveRelog(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/session"})
|
||||||
|
|
||||||
|
const wantCopper uint32 = 50_000
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "SessGd",
|
||||||
|
Level: 20,
|
||||||
|
})
|
||||||
|
bot.SetMoney(t, wantCopper)
|
||||||
|
bot.AssertMoneyEqual(t, wantCopper)
|
||||||
|
bot.Save(t)
|
||||||
|
bot.Relog(t)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
// Product oracle: money must survive save+relog (not world-alive only).
|
||||||
|
bot.AssertMoneyEqual(t, wantCopper)
|
||||||
|
t.Logf("PASS money %d survived save+relog", wantCopper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPEN(e2e): re-enable when AC#25793 is fixed — GM .gm visible off must persist extra_flags across relog.
|
||||||
|
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/25793
|
||||||
|
/*
|
||||||
|
func TestSession_GMVisibilitySurvivesRelog(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"short", "protocol", "issue"},
|
||||||
|
Runtime: "short",
|
||||||
|
Issue: 25793,
|
||||||
|
Category: "protocol/session",
|
||||||
|
})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "SessGM",
|
||||||
|
Level: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const playerExtraGMInvisible = uint16(0x0010)
|
||||||
|
|
||||||
|
bot.GM(t, ".gm visible off")
|
||||||
|
bot.Save(t)
|
||||||
|
|
||||||
|
var flags uint16
|
||||||
|
if err := bot.CharDB.QueryRow(
|
||||||
|
`SELECT extra_flags FROM characters WHERE guid=?`, bot.GUID,
|
||||||
|
).Scan(&flags); err != nil {
|
||||||
|
e2eharness.HarnessFailf(t, "read extra_flags: %v", err)
|
||||||
|
}
|
||||||
|
if flags&playerExtraGMInvisible == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "after .gm visible off, extra_flags=0x%X missing bit 0x10", flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
guid := bot.GUID
|
||||||
|
bot.Relog(t)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
|
||||||
|
queryGUID := bot.GUID
|
||||||
|
if queryGUID == 0 {
|
||||||
|
queryGUID = guid
|
||||||
|
}
|
||||||
|
var after uint16
|
||||||
|
err := bot.CharDB.QueryRow(
|
||||||
|
`SELECT extra_flags FROM characters WHERE guid=?`, queryGUID,
|
||||||
|
).Scan(&after)
|
||||||
|
if err != nil {
|
||||||
|
err = bot.CharDB.QueryRow(
|
||||||
|
`SELECT extra_flags FROM characters WHERE guid=?`, guid,
|
||||||
|
).Scan(&after)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e2eharness.HarnessFailf(t, "read extra_flags after relog: %v", err)
|
||||||
|
}
|
||||||
|
if after&playerExtraGMInvisible == 0 {
|
||||||
|
e2eharness.Assertf(t, "GM invisible did not stick after relog (extra_flags=0x%X)", after)
|
||||||
|
}
|
||||||
|
t.Logf("PASS GM visibility survived relog (extra_flags=0x%X)", after)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SESS-06: hard session drop leaves world probeable by another bot.
|
||||||
|
func TestSession_HardDropWorldStaysAlive(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol", "serial"}, Runtime: "short", Category: "protocol/session"})
|
||||||
|
|
||||||
|
probe := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "SessPrb",
|
||||||
|
Level: 10,
|
||||||
|
})
|
||||||
|
victim := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "SessVic",
|
||||||
|
Level: 10,
|
||||||
|
})
|
||||||
|
// Hard close without graceful logout path. issue=0: no ConfirmedBugf.
|
||||||
|
e2eharness.HardDisconnectAndProbe(t, victim, probe, 0)
|
||||||
|
t.Logf("PASS hard drop did not kill world (probe OK)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package teleport_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TELE-01: cross-map teleport (EK → Northrend).
|
||||||
|
func TestTeleport_CrossMapEasternToNorthrend(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "TeleXM",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
_, _, _, m0 := bot.Pos()
|
||||||
|
bot.Teleport(t, 3758.2554, 3689.5754, 47.241505, e2eharness.MapNorthrend)
|
||||||
|
x, y, z, m1 := bot.Pos()
|
||||||
|
if m1 != e2eharness.MapNorthrend {
|
||||||
|
e2eharness.Preconditionf(t, "expected Northrend map %d, got %d (from %d)", e2eharness.MapNorthrend, m1, m0)
|
||||||
|
}
|
||||||
|
if math.IsNaN(float64(x)) {
|
||||||
|
e2eharness.HarnessFailf(t, "nan position after cross-map tele")
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS cross-map tele map=%d pos=(%.1f,%.1f,%.1f)", m1, x, y, z)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TELE-02: TeleNamed + world alive.
|
||||||
|
func TestTeleport_TeleNamedSafe(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "TeleNm",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
// Stormwind is a stable named tele on most AC installs.
|
||||||
|
bot.TeleNamed(t, "Stormwind")
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
_, _, _, m := bot.Pos()
|
||||||
|
if m != e2eharness.MapEasternKingdoms {
|
||||||
|
e2eharness.Assertf(t, "TeleNamed Stormwind map=%d want EK %d", m, e2eharness.MapEasternKingdoms)
|
||||||
|
}
|
||||||
|
t.Logf("PASS TeleNamed Stormwind map=%d", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TELE-03: spam short teleports must not crash world.
|
||||||
|
func TestTeleport_SpamShortDoesNotCrash(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "TeleSp",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bot.Teleport(t, -8949.95+float32(i), 554.0, 94.0, e2eharness.MapEasternKingdoms)
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS spam short teleports")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TELE-04: pad tele then named tele preserves session.
|
||||||
|
func TestTeleport_PadThenNamed(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "TelePN",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bot.TeleNamed(t, "Ironforge")
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS pad then named tele")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TELE-05: GoCreatureID lands near entry after spawn.
|
||||||
|
func TestTeleport_GoCreatureIDNearSpawn(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "protocol"}, Runtime: "short", Category: "protocol/teleport"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "TeleGo",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
guid := bot.Spawn(t, e2eharness.CreatureTargetDummy, 15*time.Second)
|
||||||
|
if guid == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "failed to spawn target dummy")
|
||||||
|
}
|
||||||
|
bot.GoCreatureID(t, e2eharness.CreatureTargetDummy)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS GoCreatureID near dummy guid=0x%X", guid)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package escort_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OPEN(e2e): replace this package with a real escort quest fixture (start → follow →
|
||||||
|
// complete/fail oracle). Until then only keep spawn/cache helpers that have hard asserts.
|
||||||
|
|
||||||
|
// ESCORT-02: unit still findable after spawn (hard GUID/cache oracle).
|
||||||
|
func TestEscort_UnitFindableAfterSpawn(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests"}, Runtime: "short", Category: "quests/escort"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{Prefix: "EscFd", Level: 80})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
guid := bot.Spawn(t, e2eharness.CreatureTargetDummy, 15*time.Second)
|
||||||
|
found := bot.FindUnit(e2eharness.CreatureTargetDummy, 50)
|
||||||
|
if found == 0 {
|
||||||
|
e2eharness.Assertf(t, "FindUnit 0 after spawn guid=0x%X", guid)
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS unit findable found=0x%X spawn=0x%X", found, guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/24450
|
||||||
|
// Quest 12512 Leave No One Behind: bandage Crusader Jonathan (28133) summons
|
||||||
|
// follower 28136 (SMART_ACTION_FOLLOW). Logout must despawn the follower
|
||||||
|
// (SmartAI::UpdateFollow when FindPlayer fails).
|
||||||
|
// https://www.wowhead.com/wotlk/quest=12512/leave-no-one-behind
|
||||||
|
func TestAC_24450_FollowDespawnsOnLogout(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"med", "quests", "ai", "issue", "serial"},
|
||||||
|
Runtime: "med",
|
||||||
|
Issue: 24450,
|
||||||
|
Category: "quests/escort",
|
||||||
|
})
|
||||||
|
|
||||||
|
const (
|
||||||
|
questLeaveNoOneBehind = uint32(12512)
|
||||||
|
itemCrusadersBandage = uint32(38330)
|
||||||
|
spellCrusadersBandage = uint32(50662)
|
||||||
|
npcJonathanWorld = uint32(28133) // sitting world spawn
|
||||||
|
npcJonathanFollow = uint32(28136) // summoned follower
|
||||||
|
)
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "EscFol",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.AddQuest(t, questLeaveNoOneBehind)
|
||||||
|
bot.AddItem(t, itemCrusadersBandage, 1)
|
||||||
|
bot.GoCreatureID(t, npcJonathanWorld)
|
||||||
|
worldNPC := bot.WaitUnit(t, npcJonathanWorld, 20*time.Second)
|
||||||
|
if worldNPC == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "Crusader Jonathan %d not at world spawn", npcJonathanWorld)
|
||||||
|
}
|
||||||
|
bot.DespawnNearbyEntry(t, npcJonathanFollow, 80)
|
||||||
|
|
||||||
|
// 50662 is an item spell; client CastSpell without USE_ITEM never SPELL_GOs.
|
||||||
|
// Targeted `.cast` is the GM stand-in for using Crusader's Bandage on Jonathan.
|
||||||
|
known := map[uint64]struct{}{}
|
||||||
|
for _, u := range bot.UnitsByEntry(80, npcJonathanFollow) {
|
||||||
|
known[u.GUID] = struct{}{}
|
||||||
|
}
|
||||||
|
if err := bot.World.SetTarget(worldNPC); err != nil {
|
||||||
|
e2eharness.Preconditionf(t, "SetTarget Jonathan 0x%X: %v", worldNPC, err)
|
||||||
|
}
|
||||||
|
bot.GM(t, fmt.Sprintf(".cast %d", spellCrusadersBandage))
|
||||||
|
fresh := bot.WaitNewUnits(t, known, []uint32{npcJonathanFollow}, 20*time.Second)
|
||||||
|
if len(fresh) == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "bandage did not summon a new follow-NPC %d", npcJonathanFollow)
|
||||||
|
}
|
||||||
|
follower := fresh[0].GUID
|
||||||
|
t.Logf("follower 28136 guid=0x%X after bandage", follower)
|
||||||
|
|
||||||
|
// UpdateFollow only ticks every 1s. A tight Relog can come back before
|
||||||
|
// FindPlayer fails, so stay offline past that timer, then login.
|
||||||
|
if err := bot.World.SendLogout(); err != nil {
|
||||||
|
e2eharness.HarnessFailf(t, "logout: %v", err)
|
||||||
|
}
|
||||||
|
if err := bot.World.WaitForLogout(30 * time.Second); err != nil {
|
||||||
|
t.Logf("logout wait: %v (continuing)", err)
|
||||||
|
}
|
||||||
|
bot.Close()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
bot.Relog(t)
|
||||||
|
// Fresh snapshot: sitting Jonathan must be in the create set first.
|
||||||
|
if bot.WaitUnit(t, npcJonathanWorld, 20*time.Second) == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "after login, world Jonathan %d not in object cache — snapshot not ready", npcJonathanWorld)
|
||||||
|
}
|
||||||
|
if obj := bot.World.GetObject(follower); obj != nil {
|
||||||
|
e2eharness.Assertf(t, "follow-NPC %d still in snapshot after logout (guid=0x%X)", npcJonathanFollow, follower)
|
||||||
|
}
|
||||||
|
if leftover := bot.FindUnit(npcJonathanFollow, 80); leftover != 0 {
|
||||||
|
e2eharness.Assertf(t, "follow-NPC %d still in world after logout (guid=0x%X)", npcJonathanFollow, leftover)
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS AC#24450 follower %d despawned after logout", npcJonathanFollow)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package lifecycle_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QLIFE-03 / #26549: STAY_ALIVE quest fails on death.
|
||||||
|
func TestAC_26549_StayAliveFailsOnDeath(t *testing.T) {
|
||||||
|
// serial: DieMust + CharDB save race under parallel pad thrash.
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
// smoke: STAY_ALIVE fail-on-death is in core (#26989 / #26549).
|
||||||
|
Tags: []string{"short", "quests", "issue", "smoke", "serial"},
|
||||||
|
Runtime: "short",
|
||||||
|
Issue: 26549,
|
||||||
|
Category: "quests/lifecycle",
|
||||||
|
})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "QAlive",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 30,
|
||||||
|
})
|
||||||
|
// AddQuest waits for INCOMPLETE in CharDB (async .quest add under thrash).
|
||||||
|
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
bot.Teleport(t, -9222.58, -2147.87, 63.814, e2eharness.MapEasternKingdoms)
|
||||||
|
|
||||||
|
bot.DieAndRepop(t)
|
||||||
|
// CharDB quest status is async after .save — always re-save and re-read.
|
||||||
|
st, ok := bot.QuestStatusAfterSave(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
if !ok {
|
||||||
|
e2eharness.HarnessFailf(t, "quest row missing after death")
|
||||||
|
}
|
||||||
|
if st != e2eharness.QuestStatusFailed {
|
||||||
|
// CharDB lag after FailQuest — poll, do not fixed-sleep once.
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && st != e2eharness.QuestStatusFailed {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
st, ok = bot.QuestStatusAfterSave(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
if !ok {
|
||||||
|
e2eharness.HarnessFailf(t, "quest row missing after death (retry)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if st != e2eharness.QuestStatusFailed {
|
||||||
|
// ConfirmedBugf(t, 26549, ...) — core FailQuestsOnDeath is on this branch; hard-fail.
|
||||||
|
e2eharness.Assertf(t, "quest status=%d (%s) after death+repop, want FAILED(5)",
|
||||||
|
st, e2eharness.QuestStatusName(st))
|
||||||
|
}
|
||||||
|
t.Logf("PASS quest failed on death (status=%s)", e2eharness.QuestStatusName(st))
|
||||||
|
}
|
||||||
|
|
||||||
|
// QLIFE-01: AddQuest → incomplete status after save.
|
||||||
|
func TestQuest_AddQuestIncomplete(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests"}, Runtime: "short", Category: "quests/lifecycle"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "QAdd",
|
||||||
|
Level: 30,
|
||||||
|
})
|
||||||
|
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
|
||||||
|
t.Logf("PASS AddQuest → incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QLIFE-02: quest status survives save round-trip.
|
||||||
|
func TestQuest_StatusSurvivesSave(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests"}, Runtime: "short", Category: "quests/lifecycle"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "QSave",
|
||||||
|
Level: 30,
|
||||||
|
})
|
||||||
|
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
st, ok := bot.QuestStatusAfterSave(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
if !ok {
|
||||||
|
e2eharness.HarnessFailf(t, "quest missing after save")
|
||||||
|
}
|
||||||
|
if st != e2eharness.QuestStatusIncomplete {
|
||||||
|
e2eharness.Assertf(t, "want incomplete after save, got %d", st)
|
||||||
|
}
|
||||||
|
t.Logf("PASS quest status survives save")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QLIFE-04: quest status after relog remains Incomplete (not row-presence only).
|
||||||
|
func TestQuest_StatusSurvivesRelog(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests", "protocol"}, Runtime: "short", Category: "quests/lifecycle"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "QRelog",
|
||||||
|
Level: 30,
|
||||||
|
})
|
||||||
|
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
|
||||||
|
bot.Save(t)
|
||||||
|
bot.Relog(t)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
// Product oracle: status byte must still be Incomplete after relog.
|
||||||
|
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
|
||||||
|
t.Logf("PASS quest incomplete after relog")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QLIFE-05: second AddQuest of same ID is safe (no crash).
|
||||||
|
func TestQuest_ReAddSameQuestNoCrash(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "quests"}, Runtime: "short", Category: "quests/lifecycle"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "QDup",
|
||||||
|
Level: 30,
|
||||||
|
})
|
||||||
|
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
bot.AddQuest(t, e2eharness.QuestRethbanGauntlet)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
bot.AssertQuestStatus(t, e2eharness.QuestRethbanGauntlet, e2eharness.QuestStatusIncomplete)
|
||||||
|
t.Logf("PASS re-add same quest no crash")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package cast_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CAST-01: live cast pipeline — Charge succeeds on spawned dummy after CombatReady.
|
||||||
|
// WotLK Charge (rank 3 = 11578): Requires Battle Stance, 8–25 yd, out of combat.
|
||||||
|
func TestCast_ChargeSucceedsOnDummy(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells", "combat"}, Runtime: "short", Category: "spells/cast"})
|
||||||
|
|
||||||
|
const spellChargeRank3 = uint32(11578)
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "CastOk",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
// PackagePad only for isolation. Charge needs pathable flat ground
|
||||||
|
// (PackagePad mountain pads return SPELL_FAILED_NOPATH=56).
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
// Northshire open strip (map 0) — short, flat, mmaps-friendly.
|
||||||
|
const (
|
||||||
|
chargeX float32 = -8904.0
|
||||||
|
chargeY float32 = -128.0
|
||||||
|
chargeZ float32 = 81.0
|
||||||
|
chargeMap uint32 = 0
|
||||||
|
)
|
||||||
|
bot.Teleport(t, chargeX, chargeY, chargeZ, chargeMap)
|
||||||
|
bot.CombatStop(t)
|
||||||
|
bot.CombatReadyFull(t) // gm off + god + power + FlushWorld
|
||||||
|
bot.CombatStop(t)
|
||||||
|
bot.FlushWorld(t)
|
||||||
|
bot.Learn(t, e2eharness.SpellBattleStance)
|
||||||
|
bot.Learn(t, spellChargeRank3)
|
||||||
|
// Client Battle Stance so form is real (not CastOrGM fake-success).
|
||||||
|
bot.CastMust(t, e2eharness.SpellBattleStance, 0, 10*time.Second)
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if bot.HasAura(e2eharness.SpellBattleStance) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !bot.HasAura(e2eharness.SpellBattleStance) {
|
||||||
|
e2eharness.Preconditionf(t, "Battle Stance missing before Charge")
|
||||||
|
}
|
||||||
|
// 31146 is long-lived (2673 KillSelfs at 15s — Charge setup exceeds that).
|
||||||
|
dummy := bot.Spawn(t, e2eharness.CreatureHeroicTrainingDummy, 15*time.Second)
|
||||||
|
// Charge range is 8–25y; spawn is on the player — step back ~12y on flat ground.
|
||||||
|
bot.Teleport(t, chargeX+12, chargeY, chargeZ, chargeMap)
|
||||||
|
_ = bot.World.SetTarget(dummy)
|
||||||
|
bot.Face(t, dummy)
|
||||||
|
bot.CastMust(t, spellChargeRank3, dummy, 10*time.Second)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS Charge succeeded on dummy")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAST-02: out-of-range / fail path does not crash.
|
||||||
|
func TestCast_FailPathNoCrash(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/cast"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "CastFl",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
// Charge with no target: core often sends no SPELL_CAST_FAILED (TryCast times
|
||||||
|
// out). That is still a fail path. Require no SPELL_GO and a live world.
|
||||||
|
res, err := bot.TryCast(t, e2eharness.SpellCharge, 0, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("TryCast Charge target=0: %v (no cast result)", err)
|
||||||
|
} else if res.Success {
|
||||||
|
e2eharness.Assertf(t, "Charge with no target succeeded")
|
||||||
|
} else {
|
||||||
|
t.Logf("Charge no-target fail reason=%s", e2eharness.SpellFailReasonName(res.FailReason))
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS cast fail path no crash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27061
|
||||||
|
// Raise Dead near a player corpse must succeed (ghoul) and must not crash
|
||||||
|
// WorldObjectSpellAreaTargetCheck on the Corpse grid.
|
||||||
|
func TestAC_27061_RaiseDeadNearCorpseNoCrash(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"med", "spells", "issue", "serial"},
|
||||||
|
Runtime: "med",
|
||||||
|
Issue: 27061,
|
||||||
|
Category: "spells/cast",
|
||||||
|
})
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "Raise",
|
||||||
|
Bots: []e2eharness.BotSpec{
|
||||||
|
{Role: "dk", Race: e2eharness.RaceHuman, Class: e2eharness.ClassDeathKnight, Level: 80, LearnAllClass: true},
|
||||||
|
{Role: "corpse", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80},
|
||||||
|
{Role: "probe", Level: 10},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dk := e2eharness.ByRole(t, bots, "dk")
|
||||||
|
corpse := e2eharness.ByRole(t, bots, "corpse")
|
||||||
|
probe := e2eharness.ByRole(t, bots, "probe")
|
||||||
|
e2eharness.TeleportAllPad(t, []*e2eharness.ScenarioBot{dk, corpse}, e2eharness.PackagePad(t))
|
||||||
|
corpse.DieMust(t, 20*time.Second)
|
||||||
|
|
||||||
|
dk.Learn(t, e2eharness.SpellRaiseDead)
|
||||||
|
dk.AddItem(t, e2eharness.ItemCorpseDust, 5)
|
||||||
|
dk.CombatReady(t)
|
||||||
|
dk.CheatPower(t)
|
||||||
|
dk.FlushWorld(t)
|
||||||
|
|
||||||
|
res, err := dk.TryCast(t, e2eharness.SpellRaiseDead, 0, 20*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
e2eharness.ProbeWorldAlive(t, probe, 27061)
|
||||||
|
e2eharness.HarnessFailf(t, "AC#27061: no Raise Dead cast result (world still up): %v", err)
|
||||||
|
}
|
||||||
|
e2eharness.ProbeWorldAlive(t, probe, 27061)
|
||||||
|
if !res.Success {
|
||||||
|
e2eharness.Assertf(t, "Raise Dead failed reason=%s (need SPELL_GO + ghoul)",
|
||||||
|
e2eharness.SpellFailReasonName(res.FailReason))
|
||||||
|
}
|
||||||
|
pet := dk.WaitPlayerPet(t, 20*time.Second)
|
||||||
|
if pet == 0 {
|
||||||
|
e2eharness.Assertf(t, "Raise Dead SPELL_GO but no ghoul (UNIT_FIELD_SUMMON / SUMMONEDBY)")
|
||||||
|
}
|
||||||
|
dk.CleanupOwnedSummons(t)
|
||||||
|
t.Logf("PASS AC#27061 Raise Dead near corpse spawned pet=0x%X", pet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAST-04: client CastMust battle stance on self — aura must apply (no CastOrGM GM-fake).
|
||||||
|
func TestCast_BattleStanceSelf(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/cast"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "CastBS",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bot.Learn(t, e2eharness.SpellBattleStance)
|
||||||
|
// Client cast path only — CastMust fatals if SPELL_GO/success missing.
|
||||||
|
bot.CastMust(t, e2eharness.SpellBattleStance, 0, 10*time.Second)
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if bot.HasAura(e2eharness.SpellBattleStance) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !bot.HasAura(e2eharness.SpellBattleStance) {
|
||||||
|
e2eharness.Assertf(t, "battle stance aura %d missing after successful cast", e2eharness.SpellBattleStance)
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS battle stance self-cast + aura")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAST-05: CastAtPosition ground AoE does not crash.
|
||||||
|
func TestCast_GroundAoENoCrash(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/cast"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "CastAoE",
|
||||||
|
Class: e2eharness.ClassWarlock,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
x, y, z, _ := bot.Pos()
|
||||||
|
_ = bot.CastAtPosition(t, e2eharness.SpellRainOfFire, x, y, z, 10*time.Second)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS ground AoE cast path")
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package effects_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||||
|
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OPEN(e2e): re-enable when AC#26774 is fixed
|
||||||
|
// https://github.com/azerothcore/azerothcore-wotlk/issues/26774
|
||||||
|
// Must assert client item-use / dummy rank (not CastOrGM + any 2673).
|
||||||
|
/*
|
||||||
|
func TestEffects_TargetDummySummon(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"short", "spells", "issue"},
|
||||||
|
Runtime: "short",
|
||||||
|
Issue: 26774,
|
||||||
|
Category: "spells/effects",
|
||||||
|
})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "FxDummy",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bot.AddItem(t, e2eharness.ItemTargetDummy, 1)
|
||||||
|
bot.Learn(t, e2eharness.SpellSummonTargetDummy)
|
||||||
|
_ = bot.CastOrGM(t, e2eharness.SpellSummonTargetDummy, 0, 10*time.Second)
|
||||||
|
u := bot.WaitUnit(t, e2eharness.CreatureTargetDummy, 15*time.Second)
|
||||||
|
if u == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "target dummy not observed")
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS target dummy summon guid=0x%X", u)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// FX-02: Charge effect moves player (warrior).
|
||||||
|
// WotLK Charge rank 3 (11578): Battle Stance, 8–25 yd, out of combat.
|
||||||
|
func TestEffects_ChargeEffect(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells", "combat"}, Runtime: "short", Category: "spells/effects"})
|
||||||
|
|
||||||
|
const spellChargeRank3 = uint32(11578)
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "FxChg",
|
||||||
|
Class: e2eharness.ClassWarrior,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
// PackagePad is isolation only — mountain pads return SPELL_FAILED_NOPATH (56).
|
||||||
|
// Northshire open strip (map 0) is flat and mmaps-friendly for Charge.
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
const (
|
||||||
|
chargeX float32 = -8904.0
|
||||||
|
chargeY float32 = -128.0
|
||||||
|
chargeZ float32 = 81.0
|
||||||
|
chargeMap uint32 = 0
|
||||||
|
)
|
||||||
|
bot.Teleport(t, chargeX, chargeY, chargeZ, chargeMap)
|
||||||
|
bot.CombatStop(t)
|
||||||
|
bot.CombatReadyFull(t)
|
||||||
|
bot.CombatStop(t)
|
||||||
|
bot.FlushWorld(t)
|
||||||
|
bot.Learn(t, e2eharness.SpellBattleStance)
|
||||||
|
bot.Learn(t, spellChargeRank3)
|
||||||
|
bot.CastMust(t, e2eharness.SpellBattleStance, 0, 10*time.Second)
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if bot.HasAura(e2eharness.SpellBattleStance) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !bot.HasAura(e2eharness.SpellBattleStance) {
|
||||||
|
e2eharness.Preconditionf(t, "Battle Stance missing before Charge")
|
||||||
|
}
|
||||||
|
// 31146 is long-lived (2673 KillSelfs at 15s — Charge setup exceeds that).
|
||||||
|
dummy := bot.Spawn(t, e2eharness.CreatureHeroicTrainingDummy, 15*time.Second)
|
||||||
|
// 8–25 yd charge range on flat ground.
|
||||||
|
bot.Teleport(t, chargeX+12, chargeY, chargeZ, chargeMap)
|
||||||
|
x0, y0, z0, _ := bot.Pos()
|
||||||
|
_ = bot.World.SetTarget(dummy)
|
||||||
|
bot.Face(t, dummy)
|
||||||
|
bot.CastMust(t, spellChargeRank3, dummy, 10*time.Second)
|
||||||
|
// Snapshot is the step-back point; Charge must close toward the dummy.
|
||||||
|
deadline = time.Now().Add(2 * time.Second)
|
||||||
|
var moved float32
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
x1, y1, z1, _ := bot.Pos()
|
||||||
|
moved = e2eharness.Distance3D(x0, y0, z0, x1, y1, z1)
|
||||||
|
if moved >= 1.0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if moved < 1.0 {
|
||||||
|
e2eharness.Assertf(t, "Charge SPELL_GO ok but player did not leave step-back (moved=%.1f)", moved)
|
||||||
|
}
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS charge effect moved=%.1fy", moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/26997
|
||||||
|
// Sweeping Strikes + Execute on a ≤20% target with a second living ≤20% target
|
||||||
|
// nearby must not crash (CheckProc stored a raw Unit* that HandleProc deref'd).
|
||||||
|
// Training dummies absorb damage (npc_training_dummy::DamageTaken = 0) so they
|
||||||
|
// never enter Execute's 20% HealthState. Player-vs-player in Northshire fails
|
||||||
|
// SPELL_FAILED_BAD_TARGETS (sanctuary / PvP flag race). Use two hostile mobs.
|
||||||
|
func TestAC_26997_SweepingStrikesExecuteNoCrash(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{
|
||||||
|
Tags: []string{"med", "spells", "issue", "multi_bot", "serial"},
|
||||||
|
Runtime: "med",
|
||||||
|
Issue: 26997,
|
||||||
|
Category: "spells/effects",
|
||||||
|
})
|
||||||
|
|
||||||
|
const (
|
||||||
|
spellExecuteMax = uint32(47471) // WotLK Execute rank 9
|
||||||
|
// High-HP test dummies with empty ScriptName (they take damage). L1-L6
|
||||||
|
// world mobs die to a L80 autoattack during DamageToFraction.
|
||||||
|
unkillableDummy80 = uint32(32171)
|
||||||
|
unkillableDummy80Armor = uint32(32847) // different entry — Spawn despawns nearby same-entry
|
||||||
|
)
|
||||||
|
|
||||||
|
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "SwpEx",
|
||||||
|
Bots: []e2eharness.BotSpec{
|
||||||
|
{Role: "arms", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
|
||||||
|
{Role: "probe", Level: 10},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
arms := e2eharness.ByRole(t, bots, "arms")
|
||||||
|
probe := e2eharness.ByRole(t, bots, "probe")
|
||||||
|
|
||||||
|
// Northshire strip — flat, melee range for Sweeping Strikes extra attack.
|
||||||
|
const (
|
||||||
|
x, y, z float32 = -8904.0, -128.0, 81.0
|
||||||
|
m uint32 = 0
|
||||||
|
)
|
||||||
|
arms.Teleport(t, x, y, z, m)
|
||||||
|
probe.Teleport(t, x-8, y, z, m)
|
||||||
|
|
||||||
|
arms.CombatReadyFull(t)
|
||||||
|
arms.Learn(t, e2eharness.SpellBattleStance)
|
||||||
|
arms.Learn(t, e2eharness.SpellSweepingStrikes)
|
||||||
|
arms.Learn(t, spellExecuteMax)
|
||||||
|
arms.CastMust(t, e2eharness.SpellBattleStance, 0, 10*time.Second)
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if arms.HasAura(e2eharness.SpellBattleStance) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !arms.HasAura(e2eharness.SpellBattleStance) {
|
||||||
|
e2eharness.Preconditionf(t, "Battle Stance missing before Execute")
|
||||||
|
}
|
||||||
|
|
||||||
|
c1 := arms.Spawn(t, unkillableDummy80, 15*time.Second)
|
||||||
|
c2 := arms.Spawn(t, unkillableDummy80Armor, 15*time.Second)
|
||||||
|
arms.CombatStop(t)
|
||||||
|
if c1 == 0 || c2 == 0 {
|
||||||
|
e2eharness.Preconditionf(t, "failed to spawn Execute targets c1=0x%X c2=0x%X", c1, c2)
|
||||||
|
}
|
||||||
|
arms.DamageToFraction(t, c1, 0.19, 20*time.Second)
|
||||||
|
arms.DamageToFraction(t, c2, 0.19, 20*time.Second)
|
||||||
|
for _, v := range []struct {
|
||||||
|
name string
|
||||||
|
g uint64
|
||||||
|
}{{"c1", c1}, {"c2", c2}} {
|
||||||
|
hp, maxHP := arms.UnitHP(v.g)
|
||||||
|
if maxHP == 0 || hp == 0 || float64(hp)/float64(maxHP) > 0.2 {
|
||||||
|
e2eharness.Preconditionf(t, "%s not in execute range (hp=%d/%d guid=0x%X)", v.name, hp, maxHP, v.g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arms.CastMust(t, e2eharness.SpellSweepingStrikes, 0, 10*time.Second)
|
||||||
|
if !arms.HasAura(e2eharness.SpellSweepingStrikes) {
|
||||||
|
e2eharness.Preconditionf(t, "Sweeping Strikes aura %d missing", e2eharness.SpellSweepingStrikes)
|
||||||
|
}
|
||||||
|
_ = arms.World.SetTarget(c1)
|
||||||
|
arms.Face(t, c1)
|
||||||
|
res, err := arms.TryCast(t, spellExecuteMax, c1, 15*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
e2eharness.ProbeWorldAlive(t, probe, 26997)
|
||||||
|
e2eharness.HarnessFailf(t, "AC#26997: no Execute cast result: %v", err)
|
||||||
|
}
|
||||||
|
e2eharness.ProbeWorldAlive(t, probe, 26997)
|
||||||
|
if !res.Success {
|
||||||
|
e2eharness.Assertf(t, "Execute failed reason=%s", e2eharness.SpellFailReasonName(res.FailReason))
|
||||||
|
}
|
||||||
|
t.Logf("PASS AC#26997 Sweeping Strikes Execute world alive (c1=0x%X c2=0x%X)", c1, c2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FX-04: grounding totem summon exists (#26584 ecosystem).
|
||||||
|
func TestEffects_GroundingTotemSummon(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells"}, Runtime: "short", Category: "spells/effects"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "FxTot",
|
||||||
|
Race: e2eharness.RaceOrc,
|
||||||
|
Class: e2eharness.ClassShaman,
|
||||||
|
Level: 80,
|
||||||
|
LearnAllClass: true,
|
||||||
|
})
|
||||||
|
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||||
|
bot.GiveTotems(t)
|
||||||
|
bot.CombatReady(t)
|
||||||
|
_ = bot.CastOrGM(t, e2eharness.SpellGroundingTotem, 0, 10*time.Second)
|
||||||
|
totem := bot.WaitUnit(t, e2eharness.CreatureGroundingTotem, 15*time.Second)
|
||||||
|
bot.AssertWorldAlive(t)
|
||||||
|
t.Logf("PASS grounding totem summon guid=0x%X", totem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FX-05: Create-item / learn path for dummy reagents stays healthy.
|
||||||
|
func TestEffects_AddItemCreatePath(t *testing.T) {
|
||||||
|
meta.Begin(t, meta.TestMeta{Tags: []string{"short", "spells", "items"}, Runtime: "short", Category: "spells/effects"})
|
||||||
|
|
||||||
|
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||||
|
Prefix: "FxItem",
|
||||||
|
Level: 80,
|
||||||
|
})
|
||||||
|
bot.AddItemWait(t, e2eharness.ItemCorpseDust, 3)
|
||||||
|
bot.AssertInventoryAtLeast(t, e2eharness.ItemCorpseDust, 3)
|
||||||
|
t.Logf("PASS create-item seed path count>=3")
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ SmartAI::SmartAI(Creature* c) : CreatureAI(c)
|
|||||||
mFollowCreditType = 0;
|
mFollowCreditType = 0;
|
||||||
mFollowArrivedAlive = 0;
|
mFollowArrivedAlive = 0;
|
||||||
mFollowArrivedTimer = 0;
|
mFollowArrivedTimer = 0;
|
||||||
|
_followCheckTimer = 0;
|
||||||
mInvincibilityHpLevel = 0;
|
mInvincibilityHpLevel = 0;
|
||||||
|
|
||||||
mJustReset = false;
|
mJustReset = false;
|
||||||
@@ -1269,6 +1270,7 @@ void SmartAI::SetFollow(Unit* target, float dist, float angle, uint32 credit, ui
|
|||||||
mFollowDist = dist;
|
mFollowDist = dist;
|
||||||
mFollowAngle = angle;
|
mFollowAngle = angle;
|
||||||
mFollowArrivedTimer = 1000;
|
mFollowArrivedTimer = 1000;
|
||||||
|
_followCheckTimer = 0;
|
||||||
mFollowCredit = credit;
|
mFollowCredit = credit;
|
||||||
mFollowArrivedEntry = end;
|
mFollowArrivedEntry = end;
|
||||||
mFollowArrivedAlive = !aliveState; // negate - 0 is alive
|
mFollowArrivedAlive = !aliveState; // negate - 0 is alive
|
||||||
|
|||||||
Reference in New Issue
Block a user