187 lines
6.8 KiB
Python
187 lines
6.8 KiB
Python
"""Logic tests for the baal_xp leech route.
|
|
|
|
These cover the decisions the route makes from screen data - which portal to
|
|
enter, when a run has ended, which games are worth joining - using the real OCR
|
|
strings and screen captures that the failures were diagnosed from. They need no
|
|
game running.
|
|
|
|
The frame-based checks are skipped when their capture is not present; the pure
|
|
string/arithmetic checks always run.
|
|
"""
|
|
import os
|
|
import cv2
|
|
import pytest
|
|
|
|
from bot import Bot
|
|
from ui import game_browser as gb
|
|
|
|
FRAMES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "baal_xp")
|
|
|
|
|
|
@pytest.fixture
|
|
def bot():
|
|
# These helpers only read class-level constants, so an uninitialised
|
|
# instance binds them correctly without building the whole state machine.
|
|
return object.__new__(Bot)
|
|
|
|
|
|
def frame(name):
|
|
path = os.path.join(FRAMES, name)
|
|
if not os.path.exists(path):
|
|
pytest.skip(f"capture {name} not present")
|
|
return cv2.imread(path)
|
|
|
|
|
|
# --- portal label matching -------------------------------------------------
|
|
# A Baal town has several portals up; entering the wrong one earns nothing.
|
|
# The labels sit on a bright animated ring and OCR badly, so matching is fuzzy.
|
|
@pytest.mark.parametrize("label,expected", [
|
|
("THRONE OF DESTRUCTION (BUMBUM)", True),
|
|
("THIR(NE ()F I)F GTTRIICT T()N (IR A)", True), # heavily mangled, still ours
|
|
("THRONE OF DESTRUICTTION (CALIXTUS )", True),
|
|
("WORLDSTONE KEEP LEVEL 3 (GROZDANA)", False), # the level below the Throne
|
|
("FRIGID HIGHLANDS (ARTEMIS)", False), # a player's own portal
|
|
("HAMRMRIGATH ( THIE LIYWT THSHTEI )", False), # HARROGATH, i.e. back to town
|
|
("- S0 P4 - O5 Y", False),
|
|
("", False),
|
|
])
|
|
def test_portal_label_matching(bot, label, expected):
|
|
assert bot._baal_xp_label_matches(label, "THRONE", 0.7) is expected
|
|
|
|
|
|
# --- end of run ------------------------------------------------------------
|
|
DEPARTURES = [
|
|
"A1 eft OUF WOTIC. DIADIO'S INIONS WakET.",
|
|
"JOEBULACI IEft OUN WOTIC. DIADIO'S MINIONS WEAKET.",
|
|
"KIAUSena 1 OUF WOIIC. DIADIO'S MINIONS WEAK.",
|
|
]
|
|
CHAT_NOISE = ["YOU hAWe Ief: C1: LODDY", "NEW STATS", "LIFE: 551 / 551"]
|
|
|
|
|
|
def test_counts_departure_lines(bot):
|
|
assert bot._baal_xp_count_departures(DEPARTURES) == 3
|
|
|
|
|
|
def test_ignores_ordinary_chat(bot):
|
|
assert bot._baal_xp_count_departures(CHAT_NOISE) == 0
|
|
|
|
|
|
def test_baseline_excludes_departures_from_before_we_joined(bot):
|
|
"""The chat log persists, so pre-existing lines must not count."""
|
|
baseline = bot._baal_xp_count_departures(DEPARTURES)
|
|
assert max(0, bot._baal_xp_count_departures(DEPARTURES) - baseline) == 0
|
|
|
|
|
|
# --- screen reading --------------------------------------------------------
|
|
@pytest.mark.parametrize("name,expected", [
|
|
("party8.png", 8), ("party2.png", 2),
|
|
])
|
|
def test_party_portrait_count(bot, name, expected):
|
|
assert bot._baal_xp_party_size(frame(name)) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("name,expected", [
|
|
("town.png", True), ("throne.png", False),
|
|
])
|
|
def test_in_town_detection(bot, name, expected):
|
|
"""Drives whether the route hunts for a portal or is already past it."""
|
|
assert bot._baal_xp_in_town(frame(name)) is expected
|
|
|
|
|
|
@pytest.mark.parametrize("name,expected", [
|
|
("portal_one.png", 1),
|
|
("town_no_portal.png", 0), # a town with no portal up must find none
|
|
])
|
|
def test_portal_enumeration(bot, name, expected):
|
|
assert len(bot._baal_xp_find_portals(frame(name))) == expected
|
|
|
|
|
|
def test_finds_portals_when_several_are_up(bot):
|
|
"""town.png has two portals side by side. Matching only the single best
|
|
template hit picked an arbitrary one, which is how the bot ended up in
|
|
FRIGID HIGHLANDS instead of the Throne."""
|
|
assert len(bot._baal_xp_find_portals(frame("town.png"))) >= 1
|
|
|
|
|
|
# --- game selection --------------------------------------------------------
|
|
GAMES = [
|
|
{"name": "BAAL A", "row": 0, "y_center": 176},
|
|
{"name": "BAAL B", "row": 1, "y_center": 194},
|
|
]
|
|
|
|
|
|
def _with_counts(monkeypatch, counts):
|
|
monkeypatch.setattr(gb, "_read_player_count", lambda img, row: counts[row])
|
|
|
|
|
|
def test_skips_full_games(monkeypatch):
|
|
"""8 players is full - the join bounces straight back to the lobby."""
|
|
_with_counts(monkeypatch, [8, 5])
|
|
got = gb._rank_candidates(None, GAMES, "baal", 3, 7)
|
|
assert [c["name"] for c in got] == ["BAAL B"]
|
|
|
|
|
|
def test_skips_empty_games(monkeypatch):
|
|
"""One or two players means nobody is running.
|
|
|
|
The player band is a PREFERENCE, not a hard gate: when nothing in the band
|
|
is listed, the best remaining game is still returned so the join attempt
|
|
happens and the in-game checks (portal wait, party size, XP idle) decide
|
|
whether it is worth staying. The band itself still excludes them."""
|
|
_with_counts(monkeypatch, [1, 2])
|
|
got = gb._rank_candidates(None, GAMES, "baal", 3, 7)
|
|
assert [c["name"] for c in got] == ["BAAL B", "BAAL A"]
|
|
assert all(not (3 <= c["players"] <= 7) for c in got)
|
|
|
|
|
|
def test_ranks_fullest_first(monkeypatch):
|
|
_with_counts(monkeypatch, [4, 7])
|
|
got = gb._rank_candidates(None, GAMES, "baal", 3, 7)
|
|
assert [c["name"] for c in got] == ["BAAL B", "BAAL A"]
|
|
|
|
|
|
@pytest.mark.parametrize("name,expected", [("ITELEUBAAL-6", 6), ("BAAL Q", None)])
|
|
def test_series_number(name, expected):
|
|
assert gb._series_number(name) == expected
|
|
|
|
|
|
# --- route wiring ----------------------------------------------------------
|
|
# _baal_xp_only() gates the maintenance skip, the no-own-game path and the
|
|
# re-arm. If it silently returned False the whole restructure would no-op.
|
|
def test_leech_only_detection(bot):
|
|
bot._do_runs = {"run_baal_xp": True}
|
|
assert bot._baal_xp_only() is True
|
|
bot._do_runs = {"run_baal_xp": False} # flag is cleared when the run starts
|
|
assert bot._baal_xp_only() is True
|
|
bot._do_runs = {"run_baal_xp": True, "run_pindle": True}
|
|
assert bot._baal_xp_only() is False
|
|
|
|
|
|
def test_rearm_keeps_cycling_on_success(bot):
|
|
bot._do_runs = {"run_baal_xp": False}
|
|
bot._baal_xp_fails = 0
|
|
bot._baal_xp_rearm(ok=True)
|
|
assert bot._do_runs["run_baal_xp"] is True
|
|
|
|
|
|
def test_rearm_gives_up_after_repeated_failures(bot):
|
|
"""Otherwise a broken cycle spins forever and the controller's own
|
|
circuit breaker never gets a chance to restart D2R."""
|
|
bot._do_runs = {"run_baal_xp": False}
|
|
bot._baal_xp_fails = 0
|
|
for _ in range(Bot._BAAL_XP_MAX_CONSECUTIVE_FAILS - 1):
|
|
bot._do_runs["run_baal_xp"] = False
|
|
bot._baal_xp_rearm(ok=False)
|
|
assert bot._do_runs["run_baal_xp"] is True
|
|
bot._do_runs["run_baal_xp"] = False
|
|
bot._baal_xp_rearm(ok=False)
|
|
assert bot._do_runs["run_baal_xp"] is False
|
|
assert bot._baal_xp_fails == 0
|
|
|
|
|
|
def test_rearm_leaves_mixed_route_setups_alone(bot):
|
|
bot._do_runs = {"run_baal_xp": False, "run_pindle": True}
|
|
bot._baal_xp_fails = 0
|
|
bot._baal_xp_rearm(ok=True)
|
|
assert bot._do_runs["run_baal_xp"] is False
|