Add multi-user automation features and per-user error tracking. - Database migrations: add workflow_configs/workflow_runs (004), app_errors (005), pipeline_configs/pipeline_runs (006), and add user_token_hash to app_errors (007). - Backend: introduce per-request token handling (X-API-Token) via app.api.deps and update many API routes (auth, automations, bank, characters, dashboard, events, exchange, logs) to use user-scoped Artifacts client and character scoping. Auth endpoints no longer store tokens server-side (validate-only); clear is a no-op on server. - New Errors API and services: endpoint to list, filter, resolve, and report errors scoped to the requesting user; add error models, schemas, middleware/error handler and error_service for recording/hashing tokens. - Pipelines & Workflows: add API routers, models, schemas and engine modules (pipeline/worker/coordinator, workflow runner/conditions) and action_executor updates to support workflow/pipeline execution. - Logs: logs endpoint now prefers fetching recent action logs from the game API (with fallback to local DB), supports paging and filtering, and scopes results to the user. - Frontend: add pipeline/workflow builders, lists, progress components and hooks (use-errors, use-pipelines, use-workflows), sentry client config, and updates to API client/constants/types. - Misc: add middleware error handler, various engine strategy tweaks, tests adjusted. Overall this change enables per-user API tokens, scopes DB queries to each user, introduces pipelines/workflows runtime support, and centralizes application error tracking.
268 lines
10 KiB
Python
268 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from enum import Enum
|
|
from typing import TYPE_CHECKING
|
|
|
|
from app.engine.pathfinder import Pathfinder
|
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
|
from app.schemas.game import CharacterSchema
|
|
|
|
if TYPE_CHECKING:
|
|
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
|
from app.engine.decision.monster_selector import MonsterSelector
|
|
from app.schemas.game import ItemSchema, MonsterSchema
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _CombatState(str, Enum):
|
|
"""Internal state machine states for the combat loop."""
|
|
|
|
MOVE_TO_MONSTER = "move_to_monster"
|
|
FIGHT = "fight"
|
|
CHECK_HEALTH = "check_health"
|
|
HEAL = "heal"
|
|
CHECK_INVENTORY = "check_inventory"
|
|
MOVE_TO_BANK = "move_to_bank"
|
|
DEPOSIT = "deposit"
|
|
|
|
|
|
class CombatStrategy(BaseStrategy):
|
|
"""Automated combat strategy.
|
|
|
|
State machine flow::
|
|
|
|
MOVE_TO_MONSTER -> FIGHT -> CHECK_HEALTH
|
|
|
|
|
(HP low?) -> HEAL -> CHECK_HEALTH
|
|
|
|
|
(HP OK?) -> CHECK_INVENTORY
|
|
|
|
|
(full?) -> MOVE_TO_BANK -> DEPOSIT -> MOVE_TO_MONSTER
|
|
(ok?) -> MOVE_TO_MONSTER (loop)
|
|
|
|
Configuration keys (see :class:`~app.schemas.automation.CombatConfig`):
|
|
- monster_code: str
|
|
- auto_heal_threshold: int (default 50) -- percentage
|
|
- heal_method: str (default "rest") -- "rest" or "consumable"
|
|
- consumable_code: str | None
|
|
- min_inventory_slots: int (default 3)
|
|
- deposit_loot: bool (default True)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: dict,
|
|
pathfinder: Pathfinder,
|
|
monster_selector: MonsterSelector | None = None,
|
|
monsters_data: list[MonsterSchema] | None = None,
|
|
equipment_optimizer: EquipmentOptimizer | None = None,
|
|
available_items: list[ItemSchema] | None = None,
|
|
) -> None:
|
|
super().__init__(
|
|
config, pathfinder,
|
|
equipment_optimizer=equipment_optimizer,
|
|
available_items=available_items,
|
|
)
|
|
self._state = _CombatState.MOVE_TO_MONSTER
|
|
|
|
# Parsed config with defaults
|
|
self._monster_code: str = config.get("monster_code", "")
|
|
self._heal_threshold: int = config.get("auto_heal_threshold", 50)
|
|
self._heal_method: str = config.get("heal_method", "rest")
|
|
self._consumable_code: str | None = config.get("consumable_code")
|
|
self._min_inv_slots: int = config.get("min_inventory_slots", 3)
|
|
self._deposit_loot: bool = config.get("deposit_loot", True)
|
|
|
|
# Decision modules
|
|
self._monster_selector = monster_selector
|
|
self._monsters_data = monsters_data or []
|
|
|
|
# Cached locations (resolved lazily)
|
|
self._monster_pos: tuple[int, int] | None = None
|
|
self._bank_pos: tuple[int, int] | None = None
|
|
|
|
def get_state(self) -> str:
|
|
return self._state.value
|
|
|
|
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
|
# Auto-select monster if code is empty or "auto"
|
|
if (not self._monster_code or self._monster_code == "auto") and self._monster_selector and self._monsters_data:
|
|
selected = self._monster_selector.select_optimal(character, self._monsters_data)
|
|
if selected:
|
|
self._monster_code = selected.code
|
|
logger.info("Auto-selected monster %s for character %s", selected.code, character.name)
|
|
|
|
# Check auto-equip on first tick
|
|
equip_action = self._check_auto_equip(character)
|
|
if equip_action is not None:
|
|
return equip_action
|
|
|
|
# Lazily resolve monster and bank positions
|
|
self._resolve_locations(character)
|
|
|
|
match self._state:
|
|
case _CombatState.MOVE_TO_MONSTER:
|
|
return self._handle_move_to_monster(character)
|
|
case _CombatState.FIGHT:
|
|
return self._handle_fight(character)
|
|
case _CombatState.CHECK_HEALTH:
|
|
return self._handle_check_health(character)
|
|
case _CombatState.HEAL:
|
|
return self._handle_heal(character)
|
|
case _CombatState.CHECK_INVENTORY:
|
|
return self._handle_check_inventory(character)
|
|
case _CombatState.MOVE_TO_BANK:
|
|
return self._handle_move_to_bank(character)
|
|
case _CombatState.DEPOSIT:
|
|
return self._handle_deposit(character)
|
|
case _:
|
|
return ActionPlan(ActionType.IDLE, reason="Unknown state")
|
|
|
|
# ------------------------------------------------------------------
|
|
# State handlers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_move_to_monster(self, character: CharacterSchema) -> ActionPlan:
|
|
if self._monster_pos is None:
|
|
return ActionPlan(
|
|
ActionType.IDLE,
|
|
reason=f"No map tile found for monster {self._monster_code}",
|
|
)
|
|
|
|
mx, my = self._monster_pos
|
|
|
|
# Already at the monster tile
|
|
if self._is_at(character, mx, my):
|
|
self._state = _CombatState.FIGHT
|
|
return self._handle_fight(character)
|
|
|
|
self._state = _CombatState.FIGHT # transition after move
|
|
return ActionPlan(
|
|
ActionType.MOVE,
|
|
params={"x": mx, "y": my},
|
|
reason=f"Moving to monster {self._monster_code} at ({mx}, {my})",
|
|
)
|
|
|
|
def _handle_fight(self, character: CharacterSchema) -> ActionPlan:
|
|
# Before fighting, check health first
|
|
if self._hp_percent(character) < self._heal_threshold:
|
|
self._state = _CombatState.HEAL
|
|
return self._handle_heal(character)
|
|
|
|
self._state = _CombatState.CHECK_HEALTH # after fight we check health
|
|
return ActionPlan(
|
|
ActionType.FIGHT,
|
|
reason=f"Fighting {self._monster_code}",
|
|
)
|
|
|
|
def _handle_check_health(self, character: CharacterSchema) -> ActionPlan:
|
|
if self._hp_percent(character) < self._heal_threshold:
|
|
self._state = _CombatState.HEAL
|
|
return self._handle_heal(character)
|
|
|
|
# Health is fine, check inventory
|
|
self._state = _CombatState.CHECK_INVENTORY
|
|
return self._handle_check_inventory(character)
|
|
|
|
def _handle_heal(self, character: CharacterSchema) -> ActionPlan:
|
|
# If already at full health, go back to the inventory check
|
|
if self._hp_percent(character) >= 100.0:
|
|
self._state = _CombatState.CHECK_INVENTORY
|
|
return self._handle_check_inventory(character)
|
|
|
|
if self._heal_method == "consumable" and self._consumable_code:
|
|
# Check if the character has the consumable in inventory
|
|
has_consumable = any(
|
|
slot.code == self._consumable_code for slot in character.inventory
|
|
)
|
|
if has_consumable:
|
|
# Stay in HEAL state to re-check HP after using the item
|
|
self._state = _CombatState.CHECK_HEALTH
|
|
return ActionPlan(
|
|
ActionType.USE_ITEM,
|
|
params={"code": self._consumable_code, "quantity": 1},
|
|
reason=f"Using consumable {self._consumable_code} to heal",
|
|
)
|
|
else:
|
|
# Fallback to rest if no consumable available
|
|
logger.info(
|
|
"No %s in inventory, falling back to rest",
|
|
self._consumable_code,
|
|
)
|
|
|
|
# Default: rest to restore HP
|
|
self._state = _CombatState.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:
|
|
free_slots = self._inventory_free_slots(character)
|
|
|
|
if self._deposit_loot and free_slots <= self._min_inv_slots:
|
|
self._state = _CombatState.MOVE_TO_BANK
|
|
return self._handle_move_to_bank(character)
|
|
|
|
# Inventory is fine, go fight
|
|
self._state = _CombatState.MOVE_TO_MONSTER
|
|
return self._handle_move_to_monster(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 on map",
|
|
)
|
|
|
|
bx, by = self._bank_pos
|
|
|
|
if self._is_at(character, bx, by):
|
|
self._state = _CombatState.DEPOSIT
|
|
return self._handle_deposit(character)
|
|
|
|
self._state = _CombatState.DEPOSIT # transition after move
|
|
return ActionPlan(
|
|
ActionType.MOVE,
|
|
params={"x": bx, "y": by},
|
|
reason=f"Moving to bank at ({bx}, {by}) to deposit loot",
|
|
)
|
|
|
|
def _handle_deposit(self, character: CharacterSchema) -> ActionPlan:
|
|
# Deposit the first non-empty inventory slot
|
|
for slot in character.inventory:
|
|
if slot.quantity > 0:
|
|
# Stay in DEPOSIT state to deposit the next item on the next tick
|
|
return ActionPlan(
|
|
ActionType.DEPOSIT_ITEM,
|
|
params={"code": slot.code, "quantity": slot.quantity},
|
|
reason=f"Depositing {slot.quantity}x {slot.code}",
|
|
)
|
|
|
|
# All items deposited -- go back to monster
|
|
self._state = _CombatState.MOVE_TO_MONSTER
|
|
return self._handle_move_to_monster(character)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _resolve_locations(self, character: CharacterSchema) -> None:
|
|
"""Lazily resolve and cache monster / bank tile positions."""
|
|
if self._monster_pos is None:
|
|
self._monster_pos = self.pathfinder.find_nearest(
|
|
character.x, character.y, "monster", self._monster_code
|
|
)
|
|
if self._monster_pos:
|
|
logger.info(
|
|
"Resolved monster %s at %s", self._monster_code, self._monster_pos
|
|
)
|
|
|
|
if self._bank_pos is None and self._deposit_loot:
|
|
self._bank_pos = self.pathfinder.find_nearest_by_type(
|
|
character.x, character.y, "bank"
|
|
)
|
|
if self._bank_pos:
|
|
logger.info("Resolved bank at %s", self._bank_pos)
|