Some checks failed
Release / release (push) Has been cancelled
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)
267 lines
9.2 KiB
Python
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"
|