artifacts-dashboard/backend/app/engine/strategies/leveling.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

371 lines
14 KiB
Python

import logging
from enum import Enum
from app.engine.pathfinder import Pathfinder
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.schemas.game import CharacterSchema, ResourceSchema
logger = logging.getLogger(__name__)
# All skills in the game with their gathering/crafting type
_GATHERING_SKILLS = {"mining", "woodcutting", "fishing"}
_CRAFTING_SKILLS = {"weaponcrafting", "gearcrafting", "jewelrycrafting", "cooking", "alchemy"}
_ALL_SKILLS = _GATHERING_SKILLS | _CRAFTING_SKILLS
class _LevelingState(str, Enum):
"""Internal state machine states for the leveling loop."""
EVALUATE = "evaluate"
MOVE_TO_TARGET = "move_to_target"
GATHER = "gather"
FIGHT = "fight"
CHECK_HEALTH = "check_health"
HEAL = "heal"
CHECK_INVENTORY = "check_inventory"
MOVE_TO_BANK = "move_to_bank"
DEPOSIT = "deposit"
class LevelingStrategy(BaseStrategy):
"""Composite leveling strategy that picks the most optimal activity for XP.
Analyzes the character's skill levels and focuses on the skill that
needs the most attention, or a specific target skill if configured.
Configuration keys (see :class:`~app.schemas.automation.LevelingConfig`):
- target_skill: str (default "") -- specific skill to level (empty = auto-pick lowest)
- min_level: int (default 0) -- stop suggestion below this level
- max_level: int (default 0) -- stop when skill reaches this level (0 = no limit)
"""
def __init__(
self,
config: dict,
pathfinder: Pathfinder,
resources_data: list[ResourceSchema] | None = None,
) -> None:
super().__init__(config, pathfinder)
self._state = _LevelingState.EVALUATE
# Config
self._target_skill: str = config.get("target_skill", "")
self._min_level: int = config.get("min_level", 0)
self._max_level: int = config.get("max_level", 0)
# Resolved from game data
self._resources_data: list[ResourceSchema] = resources_data or []
# Runtime state
self._chosen_skill: str = ""
self._chosen_resource_code: str = ""
self._chosen_monster_code: str = ""
self._target_pos: tuple[int, int] | None = None
self._bank_pos: tuple[int, int] | None = None
self._evaluated: bool = False
def get_state(self) -> str:
if self._chosen_skill:
return f"{self._state.value}:{self._chosen_skill}"
return self._state.value
def set_resources_data(self, resources_data: list[ResourceSchema]) -> None:
"""Set resource data for optimal target selection."""
self._resources_data = resources_data
async def next_action(self, character: CharacterSchema) -> ActionPlan:
self._resolve_bank(character)
match self._state:
case _LevelingState.EVALUATE:
return self._handle_evaluate(character)
case _LevelingState.MOVE_TO_TARGET:
return self._handle_move_to_target(character)
case _LevelingState.GATHER:
return self._handle_gather(character)
case _LevelingState.FIGHT:
return self._handle_fight(character)
case _LevelingState.CHECK_HEALTH:
return self._handle_check_health(character)
case _LevelingState.HEAL:
return self._handle_heal(character)
case _LevelingState.CHECK_INVENTORY:
return self._handle_check_inventory(character)
case _LevelingState.MOVE_TO_BANK:
return self._handle_move_to_bank(character)
case _LevelingState.DEPOSIT:
return self._handle_deposit(character)
case _:
return ActionPlan(ActionType.IDLE, reason="Unknown leveling state")
# ------------------------------------------------------------------
# State handlers
# ------------------------------------------------------------------
def _handle_evaluate(self, character: CharacterSchema) -> ActionPlan:
"""Decide which skill to level and find the best target."""
if self._target_skill:
skill = self._target_skill
else:
skill = self._find_lowest_skill(character)
if not skill:
return ActionPlan(
ActionType.COMPLETE,
reason="No skill found to level",
)
skill_level = self._get_skill_level(character, skill)
# Check max_level constraint
if self._max_level > 0 and skill_level >= self._max_level:
if self._target_skill:
return ActionPlan(
ActionType.COMPLETE,
reason=f"Skill {skill} reached target level {self._max_level}",
)
# Try another skill
skill = self._find_lowest_skill(character, exclude={skill})
if not skill:
return ActionPlan(
ActionType.COMPLETE,
reason="All skills at or above max_level",
)
skill_level = self._get_skill_level(character, skill)
self._chosen_skill = skill
# Find optimal target
if skill in _GATHERING_SKILLS:
self._choose_gathering_target(character, skill, skill_level)
elif skill == "combat" or skill not in _ALL_SKILLS:
# Combat leveling - find appropriate monster
self._choose_combat_target(character)
else:
# Crafting skills need gathering first, fallback to gathering
# the raw material skill
gathering_skill = self._crafting_to_gathering(skill)
if gathering_skill:
self._choose_gathering_target(character, gathering_skill, skill_level)
else:
return ActionPlan(
ActionType.IDLE,
reason=f"No leveling strategy available for {skill}",
)
if self._target_pos is None:
return ActionPlan(
ActionType.IDLE,
reason=f"No target found for leveling {skill} at level {skill_level}",
)
self._state = _LevelingState.MOVE_TO_TARGET
self._evaluated = True
logger.info(
"Leveling strategy: skill=%s, level=%d, resource=%s, monster=%s",
skill,
skill_level,
self._chosen_resource_code,
self._chosen_monster_code,
)
return self._handle_move_to_target(character)
def _handle_move_to_target(self, character: CharacterSchema) -> ActionPlan:
if self._target_pos is None:
self._state = _LevelingState.EVALUATE
return ActionPlan(ActionType.IDLE, reason="Target position lost, re-evaluating")
tx, ty = self._target_pos
if self._is_at(character, tx, ty):
if self._chosen_resource_code:
self._state = _LevelingState.GATHER
return self._handle_gather(character)
elif self._chosen_monster_code:
self._state = _LevelingState.FIGHT
return self._handle_fight(character)
return ActionPlan(ActionType.IDLE, reason="At target but no action determined")
if self._chosen_resource_code:
self._state = _LevelingState.GATHER
else:
self._state = _LevelingState.FIGHT
return ActionPlan(
ActionType.MOVE,
params={"x": tx, "y": ty},
reason=f"Moving to leveling target at ({tx}, {ty}) for {self._chosen_skill}",
)
def _handle_gather(self, character: CharacterSchema) -> ActionPlan:
if self._inventory_free_slots(character) == 0:
self._state = _LevelingState.CHECK_INVENTORY
return self._handle_check_inventory(character)
# Re-evaluate periodically to check if level changed
skill_level = self._get_skill_level(character, self._chosen_skill)
if self._max_level > 0 and skill_level >= self._max_level:
self._state = _LevelingState.EVALUATE
self._target_pos = None
return self._handle_evaluate(character)
self._state = _LevelingState.CHECK_INVENTORY
return ActionPlan(
ActionType.GATHER,
reason=f"Gathering for {self._chosen_skill} XP (level {skill_level})",
)
def _handle_fight(self, character: CharacterSchema) -> ActionPlan:
if self._hp_percent(character) < 50:
self._state = _LevelingState.CHECK_HEALTH
return self._handle_check_health(character)
self._state = _LevelingState.CHECK_HEALTH
return ActionPlan(
ActionType.FIGHT,
reason=f"Fighting for combat XP",
)
def _handle_check_health(self, character: CharacterSchema) -> ActionPlan:
if self._hp_percent(character) < 50:
self._state = _LevelingState.HEAL
return self._handle_heal(character)
self._state = _LevelingState.CHECK_INVENTORY
return self._handle_check_inventory(character)
def _handle_heal(self, character: CharacterSchema) -> ActionPlan:
if self._hp_percent(character) >= 100.0:
self._state = _LevelingState.CHECK_INVENTORY
return self._handle_check_inventory(character)
self._state = _LevelingState.CHECK_HEALTH
return ActionPlan(
ActionType.REST,
reason=f"Resting to heal (HP {character.hp}/{character.max_hp})",
)
def _handle_check_inventory(self, character: CharacterSchema) -> ActionPlan:
if self._inventory_free_slots(character) == 0:
self._state = _LevelingState.MOVE_TO_BANK
return self._handle_move_to_bank(character)
# Continue the current activity
self._state = _LevelingState.MOVE_TO_TARGET
return self._handle_move_to_target(character)
def _handle_move_to_bank(self, character: CharacterSchema) -> ActionPlan:
if self._bank_pos is None:
return ActionPlan(ActionType.IDLE, reason="No bank tile found")
bx, by = self._bank_pos
if self._is_at(character, bx, by):
self._state = _LevelingState.DEPOSIT
return self._handle_deposit(character)
self._state = _LevelingState.DEPOSIT
return ActionPlan(
ActionType.MOVE,
params={"x": bx, "y": by},
reason=f"Moving to bank at ({bx}, {by}) to deposit",
)
def _handle_deposit(self, character: CharacterSchema) -> ActionPlan:
for slot in character.inventory:
if slot.quantity > 0:
return ActionPlan(
ActionType.DEPOSIT_ITEM,
params={"code": slot.code, "quantity": slot.quantity},
reason=f"Depositing {slot.quantity}x {slot.code}",
)
# Re-evaluate after depositing (skill might have leveled)
self._state = _LevelingState.EVALUATE
self._target_pos = None
return self._handle_evaluate(character)
# ------------------------------------------------------------------
# Skill analysis helpers
# ------------------------------------------------------------------
def _find_lowest_skill(
self,
character: CharacterSchema,
exclude: set[str] | None = None,
) -> str:
"""Find the gathering/crafting skill with the lowest level."""
exclude = exclude or set()
lowest_skill = ""
lowest_level = float("inf")
for skill in _GATHERING_SKILLS:
if skill in exclude:
continue
level = self._get_skill_level(character, skill)
if level < lowest_level:
lowest_level = level
lowest_skill = skill
return lowest_skill
@staticmethod
def _get_skill_level(character: CharacterSchema, skill: str) -> int:
"""Extract skill level from character, defaulting to 0."""
attr = f"{skill}_level"
return getattr(character, attr, 0)
def _choose_gathering_target(
self,
character: CharacterSchema,
skill: str,
skill_level: int,
) -> None:
"""Choose the best resource to gather for a given skill and level."""
# Filter resources matching the skill
matching = [r for r in self._resources_data if r.skill == skill]
if not matching:
# Fallback: use pathfinder to find any resource of this skill
self._target_pos = self.pathfinder.find_nearest_by_type(
character.x, character.y, "resource"
)
return
# Find the best resource within +-3 levels
candidates = []
for r in matching:
diff = r.level - skill_level
if diff <= 3: # Can gather up to 3 levels above
candidates.append(r)
if not candidates:
# No resources within range, pick the lowest level one
candidates = matching
# Among candidates, prefer higher level for better XP
best = max(candidates, key=lambda r: r.level if r.level <= skill_level + 3 else -r.level)
self._chosen_resource_code = best.code
self._target_pos = self.pathfinder.find_nearest(
character.x, character.y, "resource", best.code
)
def _choose_combat_target(self, character: CharacterSchema) -> None:
"""Choose a monster appropriate for the character's combat level."""
# Find a monster near the character's level
self._chosen_monster_code = ""
self._target_pos = self.pathfinder.find_nearest_by_type(
character.x, character.y, "monster"
)
@staticmethod
def _crafting_to_gathering(crafting_skill: str) -> str:
"""Map a crafting skill to its primary gathering skill."""
mapping = {
"weaponcrafting": "mining",
"gearcrafting": "mining",
"jewelrycrafting": "mining",
"cooking": "fishing",
"alchemy": "mining",
}
return mapping.get(crafting_skill, "")