artifacts-dashboard/backend/tests/test_combat_strategy.py
Paweł Orzech f845647934
Some checks failed
Release / release (push) Has been cancelled
Initial release: Artifacts MMO Dashboard & Automation Platform
Full-stack dashboard for controlling, automating, and analyzing
Artifacts MMO characters via the game's HTTP API.

Backend (FastAPI):
- Async Artifacts API client with rate limiting and retry
- 6 automation strategies (combat, gathering, crafting, trading, task, leveling)
- Automation engine with runner, manager, cooldown tracker, pathfinder
- WebSocket relay (game server -> frontend)
- Game data cache, character snapshots, price history, analytics
- 9 API routers, 7 database tables, 3 Alembic migrations
- 108 unit tests

Frontend (Next.js 15 + shadcn/ui):
- Live character dashboard with HP/XP bars and cooldowns
- Character detail with stats, equipment, inventory, skills, manual actions
- Automation management with live log streaming
- Interactive canvas map with content-type coloring and zoom/pan
- Bank management, Grand Exchange with price charts
- Events, logs, analytics pages with Recharts
- WebSocket auto-reconnect with query cache invalidation
- Settings page, error boundaries, dark theme

Infrastructure:
- Docker Compose (dev + prod)
- GitHub Actions CI/CD
- Documentation (Architecture, Automation, Deployment, API)
2026-03-01 19:46:45 +01:00

267 lines
9.2 KiB
Python

"""Tests for the CombatStrategy state machine."""
import pytest
from app.engine.strategies.base import ActionType
from app.engine.strategies.combat import CombatStrategy
from app.schemas.game import InventorySlot
class TestCombatStrategyMovement:
"""Tests for movement-related transitions."""
@pytest.mark.asyncio
async def test_move_to_monster(self, make_character, pathfinder_with_maps):
"""When not at monster location, the strategy should return MOVE."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy({"monster_code": "chicken"}, pf)
char = make_character(x=0, y=0)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.MOVE
assert plan.params == {"x": 5, "y": 5}
@pytest.mark.asyncio
async def test_idle_when_no_monster_found(self, make_character, pathfinder_with_maps):
"""When no matching monster tile exists, the strategy should IDLE."""
pf = pathfinder_with_maps([
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy({"monster_code": "dragon"}, pf)
char = make_character(x=0, y=0)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.IDLE
class TestCombatStrategyFighting:
"""Tests for combat behavior at the monster tile."""
@pytest.mark.asyncio
async def test_fight_when_at_monster(self, make_character, pathfinder_with_maps):
"""When at monster and healthy, the strategy should return FIGHT."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy({"monster_code": "chicken"}, pf)
char = make_character(x=5, y=5, hp=100, max_hp=100)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.FIGHT
@pytest.mark.asyncio
async def test_fight_transitions_to_check_health(self, make_character, pathfinder_with_maps):
"""After returning FIGHT, the internal state should advance to CHECK_HEALTH."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy({"monster_code": "chicken"}, pf)
char = make_character(x=5, y=5, hp=100, max_hp=100)
await strategy.next_action(char)
assert strategy.get_state() == "check_health"
class TestCombatStrategyHealing:
"""Tests for healing behavior."""
@pytest.mark.asyncio
async def test_heal_when_low_hp(self, make_character, pathfinder_with_maps):
"""When HP is below threshold, the strategy should return REST."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy(
{"monster_code": "chicken", "auto_heal_threshold": 50},
pf,
)
char = make_character(x=5, y=5, hp=30, max_hp=100)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.REST
@pytest.mark.asyncio
async def test_heal_with_consumable(self, make_character, pathfinder_with_maps):
"""When heal_method is consumable and character has the item, USE_ITEM is returned."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy(
{
"monster_code": "chicken",
"auto_heal_threshold": 50,
"heal_method": "consumable",
"consumable_code": "cooked_chicken",
},
pf,
)
char = make_character(
x=5,
y=5,
hp=30,
max_hp=100,
inventory=[InventorySlot(slot=0, code="cooked_chicken", quantity=5)],
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.USE_ITEM
assert plan.params["code"] == "cooked_chicken"
@pytest.mark.asyncio
async def test_heal_consumable_fallback_to_rest(self, make_character, pathfinder_with_maps):
"""When heal_method is consumable but character lacks the item, fallback to REST."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy(
{
"monster_code": "chicken",
"auto_heal_threshold": 50,
"heal_method": "consumable",
"consumable_code": "cooked_chicken",
},
pf,
)
char = make_character(x=5, y=5, hp=30, max_hp=100, inventory=[])
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.REST
@pytest.mark.asyncio
async def test_no_heal_at_threshold(self, make_character, pathfinder_with_maps):
"""When HP is exactly at threshold, the strategy should FIGHT (not heal)."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy(
{"monster_code": "chicken", "auto_heal_threshold": 50},
pf,
)
# HP at exactly 50%
char = make_character(x=5, y=5, hp=50, max_hp=100)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.FIGHT
class TestCombatStrategyDeposit:
"""Tests for inventory deposit behavior."""
@pytest.mark.asyncio
async def test_deposit_when_inventory_full(self, make_character, pathfinder_with_maps):
"""When inventory is nearly full, the strategy should move to bank and deposit."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy(
{"monster_code": "chicken", "min_inventory_slots": 3},
pf,
)
# Fill inventory: 20 max, with 18 slots used => 2 free < 3 min
items = [InventorySlot(slot=i, code=f"loot_{i}", quantity=1) for i in range(18)]
char = make_character(
x=5, y=5,
hp=100, max_hp=100,
inventory_max_items=20,
inventory=items,
)
# First call: at monster, healthy, so it will FIGHT
plan1 = await strategy.next_action(char)
assert plan1.action_type == ActionType.FIGHT
# After fight, the state goes to CHECK_HEALTH. Simulate post-fight:
# healthy + low inventory => should move to bank
plan2 = await strategy.next_action(char)
assert plan2.action_type == ActionType.MOVE
assert plan2.params == {"x": 10, "y": 0}
@pytest.mark.asyncio
async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps):
"""When at bank with items, the strategy should DEPOSIT_ITEM."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = CombatStrategy(
{"monster_code": "chicken", "min_inventory_slots": 3},
pf,
)
items = [InventorySlot(slot=i, code=f"loot_{i}", quantity=1) for i in range(18)]
char = make_character(
x=5, y=5,
hp=100, max_hp=100,
inventory_max_items=20,
inventory=items,
)
# Fight -> check_health -> check_inventory -> move_to_bank
await strategy.next_action(char) # FIGHT
await strategy.next_action(char) # MOVE to bank
# Now simulate being at the bank
char_at_bank = make_character(
x=10, y=0,
hp=100, max_hp=100,
inventory_max_items=20,
inventory=items,
)
plan = await strategy.next_action(char_at_bank)
assert plan.action_type == ActionType.DEPOSIT_ITEM
assert plan.params["code"] == "loot_0"
@pytest.mark.asyncio
async def test_no_deposit_when_disabled(self, make_character, pathfinder_with_maps):
"""When deposit_loot=False, full inventory should not trigger deposit."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
])
strategy = CombatStrategy(
{"monster_code": "chicken", "deposit_loot": False},
pf,
)
items = [InventorySlot(slot=i, code=f"loot_{i}", quantity=1) for i in range(20)]
char = make_character(
x=5, y=5,
hp=100, max_hp=100,
inventory_max_items=20,
inventory=items,
)
# Should fight and then loop back to fight (no bank trip)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.FIGHT
class TestCombatStrategyGetState:
"""Tests for get_state() reporting."""
def test_initial_state(self, pathfinder_with_maps):
"""Initial state should be move_to_monster."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
])
strategy = CombatStrategy({"monster_code": "chicken"}, pf)
assert strategy.get_state() == "move_to_monster"