Initial release: Artifacts MMO Dashboard & Automation Platform
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)
This commit is contained in:
Paweł Orzech 2026-03-01 19:46:45 +01:00
commit f845647934
No known key found for this signature in database
157 changed files with 26332 additions and 0 deletions

18
.env.example Normal file
View file

@ -0,0 +1,18 @@
# Artifacts MMO API
ARTIFACTS_TOKEN=your_api_token_here
# Database
DATABASE_URL=postgresql+asyncpg://artifacts:artifacts@db:5432/artifacts
# Backend
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
CORS_ORIGINS=["http://localhost:3000"]
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:8000
# PostgreSQL (used by docker-compose)
POSTGRES_USER=artifacts
POSTGRES_PASSWORD=artifacts
POSTGRES_DB=artifacts

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,32 @@
---
name: Bug Report
about: Report a bug to help us improve
title: "[BUG] "
labels: bug
assignees: ""
---
## Description
A clear description of the bug.
## Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. See error
## Expected Behavior
What you expected to happen.
## Actual Behavior
What actually happened.
## Environment
- OS: [e.g. macOS 15]
- Browser: [e.g. Chrome 130]
- Docker version: [e.g. 27.0]
## Screenshots
If applicable, add screenshots.
## Additional Context
Any other context about the problem.

View file

@ -0,0 +1,19 @@
---
name: Feature Request
about: Suggest a new feature
title: "[FEATURE] "
labels: enhancement
assignees: ""
---
## Problem
What problem does this feature solve?
## Proposed Solution
Describe your proposed solution.
## Alternatives Considered
Any alternative approaches you've considered.
## Additional Context
Any other context, mockups, or screenshots.

18
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,18 @@
## Summary
Brief description of changes.
## Changes
- Change 1
- Change 2
## Testing
- [ ] Tests pass locally
- [ ] Docker compose starts without errors
- [ ] Manually tested in browser
## Screenshots
If applicable, add screenshots.

84
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,84 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: artifacts
POSTGRES_PASSWORD: artifacts
POSTGRES_DB: artifacts_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
working-directory: backend
run: uv sync
- name: Lint
working-directory: backend
run: uv run ruff check .
- name: Type check
working-directory: backend
run: uv run mypy app --ignore-missing-imports
- name: Test
working-directory: backend
env:
DATABASE_URL: postgresql+asyncpg://artifacts:artifacts@localhost:5432/artifacts_test
ARTIFACTS_TOKEN: test_token
run: uv run pytest tests/ -v
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Lint
working-directory: frontend
run: pnpm lint
- name: Type check
working-directory: frontend
run: pnpm type-check
- name: Build
working-directory: frontend
run: pnpm build

45
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-version:refname | head -2 | tail -1)
if [ -z "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" HEAD)
else
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${PREV_TAG}..HEAD)
fi
echo "changelog<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body: |
## Changes
${{ steps.changelog.outputs.changelog }}
## Docker
```bash
docker compose up
```
draft: false
prerelease: false

55
.gitignore vendored Normal file
View file

@ -0,0 +1,55 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
*.egg
dist/
build/
.eggs/
*.whl
.venv/
venv/
# Node.js
node_modules/
.next/
out/
.turbo/
# Environment
.env
.env.local
.env.production
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
docker-compose.override.yml
# Database
*.db
*.sqlite3
# Logs
*.log
# Coverage
htmlcov/
.coverage
coverage/
.nyc_output/
# Misc
*.bak
*.tmp

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Pawel Orzech
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# Artifacts MMO Dashboard
> Dashboard & automation platform for [Artifacts MMO](https://artifactsmmo.com) — control, automate, and analyze your characters through a beautiful web interface.
![Python](https://img.shields.io/badge/Python-3.12-blue?logo=python&logoColor=white)
![Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js&logoColor=white)
![FastAPI](https://img.shields.io/badge/FastAPI-0.115-009688?logo=fastapi&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-17-336791?logo=postgresql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white)
![License](https://img.shields.io/badge/License-MIT-green)
## Features
- **Live Character Dashboard** — real-time view of all 5 characters with HP, stats, equipment, inventory, skills, and cooldowns
- **Automation Engine** — combat, gathering, crafting, trading, and task automation with configurable strategies
- **Interactive Map** — world map with character positions, monsters, resources, and event overlays
- **Bank Management** — searchable bank inventory with item details and estimated values
- **Grand Exchange** — market browsing, order management, and price history charts
- **Event Tracking** — live game events with notifications
- **Analytics** — XP gain, gold tracking, actions/hour, and level progression charts
- **Multi-Character Coordination** — resource pipelines, boss fights, and task distribution
- **WebSocket Updates** — real-time dashboard updates via game WebSocket relay
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | Next.js 15, React 19, TypeScript, Tailwind CSS 4, shadcn/ui, TanStack Query, Recharts |
| Backend | Python 3.12, FastAPI, SQLAlchemy (async), httpx, Pydantic v2 |
| Database | PostgreSQL 17 |
| Deployment | Docker Compose, Coolify |
## Quickstart
### Prerequisites
- Docker & Docker Compose
- An [Artifacts MMO](https://artifactsmmo.com) account and API token
### Setup
1. Clone the repository:
```bash
git clone https://github.com/yourusername/artifacts-dashboard.git
cd artifacts-dashboard
```
2. Copy the environment file and add your API token:
```bash
cp .env.example .env
# Edit .env and set ARTIFACTS_TOKEN
```
3. Start the stack:
```bash
docker compose up
```
4. Open your browser:
- Dashboard: http://localhost:3000
- API docs: http://localhost:8000/docs
## Project Structure
```
artifacts-dashboard/
├── backend/ # FastAPI application
│ ├── app/
│ │ ├── api/ # REST endpoints
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # Business logic
│ │ ├── engine/ # Automation engine
│ │ └── websocket/# WebSocket client & relay
│ └── tests/
├── frontend/ # Next.js application
│ └── src/
│ ├── app/ # Pages (App Router)
│ ├── components/
│ ├── hooks/
│ └── lib/
├── docs/ # Documentation
└── docker-compose.yml
```
## Documentation
- [Architecture](docs/ARCHITECTURE.md) — system design and patterns
- [Automation](docs/AUTOMATION.md) — strategy configuration guide
- [Deployment](docs/DEPLOYMENT.md) — production deployment with Coolify
- [API Reference](docs/API.md) — backend REST API
## Contributing
Contributions are welcome! Please read the following before submitting:
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Commit your changes: `git commit -m "Add my feature"`
4. Push to the branch: `git push origin feature/my-feature`
5. Open a Pull Request
Please use the provided issue and PR templates.
## License
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.

14
backend/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY pyproject.toml uv.lock* ./
RUN uv sync --frozen --no-dev 2>/dev/null || uv sync --no-dev
COPY . .
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

43
backend/alembic.ini Normal file
View file

@ -0,0 +1,43 @@
[alembic]
script_location = alembic
prepend_sys_path = .
# The target database URL is set programmatically in env.py from app.config.settings.
# sqlalchemy.url is intentionally left blank here.
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

78
backend/alembic/env.py Normal file
View file

@ -0,0 +1,78 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import settings
from app.database import Base
# Import all models so their tables are registered on Base.metadata
from app.models import game_cache as _game_cache # noqa: F401
from app.models import character_snapshot as _snapshot # noqa: F401
from app.models import automation as _automation # noqa: F401
# Alembic Config object
config = context.config
# Override sqlalchemy.url with value from application settings
config.set_main_option("sqlalchemy.url", settings.database_url)
# Set up Python logging from the .ini file
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# MetaData for autogenerate support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
Generates SQL scripts without connecting to the database.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode using an async engine."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Entry point for online migrations -- delegates to async runner."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,73 @@
"""Create game_data_cache and character_snapshots tables
Revision ID: 001_initial
Revises:
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# game_data_cache
op.create_table(
"game_data_cache",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"data_type",
sa.String(length=50),
nullable=False,
comment=(
"Type of cached data: items, monsters, resources, maps, "
"events, achievements, npcs, tasks, effects, badges"
),
),
sa.Column("data", sa.JSON(), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("data_type", name="uq_game_data_cache_data_type"),
)
# character_snapshots
op.create_table(
"character_snapshots",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("data", sa.JSON(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_character_snapshots_name"),
"character_snapshots",
["name"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_character_snapshots_name"),
table_name="character_snapshots",
)
op.drop_table("character_snapshots")
op.drop_table("game_data_cache")

View file

@ -0,0 +1,126 @@
"""Add automation_configs, automation_runs, automation_logs tables
Revision ID: 002_automation
Revises: 001_initial
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "002_automation"
down_revision: Union[str, None] = "001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# automation_configs
op.create_table(
"automation_configs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("character_name", sa.String(length=100), nullable=False),
sa.Column(
"strategy_type",
sa.String(length=50),
nullable=False,
comment="Strategy type: combat, gathering, crafting, trading, task, leveling",
),
sa.Column("config", sa.JSON(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_automation_configs_character_name"),
"automation_configs",
["character_name"],
unique=False,
)
# automation_runs
op.create_table(
"automation_runs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"config_id",
sa.Integer(),
sa.ForeignKey("automation_configs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
sa.String(length=20),
nullable=False,
server_default=sa.text("'running'"),
comment="Status: running, paused, stopped, completed, error",
),
sa.Column(
"started_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("error_message", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_automation_runs_config_id"),
"automation_runs",
["config_id"],
unique=False,
)
# automation_logs
op.create_table(
"automation_logs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"run_id",
sa.Integer(),
sa.ForeignKey("automation_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("action_type", sa.String(length=50), nullable=False),
sa.Column("details", sa.JSON(), nullable=False),
sa.Column("success", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_automation_logs_run_id"),
"automation_logs",
["run_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_automation_logs_run_id"), table_name="automation_logs")
op.drop_table("automation_logs")
op.drop_index(op.f("ix_automation_runs_config_id"), table_name="automation_runs")
op.drop_table("automation_runs")
op.drop_index(op.f("ix_automation_configs_character_name"), table_name="automation_configs")
op.drop_table("automation_configs")

View file

@ -0,0 +1,141 @@
"""Add price_history and event_log tables
Revision ID: 003_price_event
Revises: 002_automation
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "003_price_event"
down_revision: Union[str, None] = "002_automation"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# price_history
op.create_table(
"price_history",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"item_code",
sa.String(length=100),
nullable=False,
comment="Item code from the Artifacts API",
),
sa.Column(
"buy_price",
sa.Float(),
nullable=True,
comment="Best buy price at capture time",
),
sa.Column(
"sell_price",
sa.Float(),
nullable=True,
comment="Best sell price at capture time",
),
sa.Column(
"volume",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
comment="Trade volume at capture time",
),
sa.Column(
"captured_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
comment="Timestamp when the price was captured",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_price_history_item_code"),
"price_history",
["item_code"],
unique=False,
)
op.create_index(
op.f("ix_price_history_captured_at"),
"price_history",
["captured_at"],
unique=False,
)
# event_log
op.create_table(
"event_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"event_type",
sa.String(length=100),
nullable=False,
comment="Type of event (e.g. 'combat', 'gathering', 'trade', 'level_up')",
),
sa.Column(
"event_data",
sa.JSON(),
nullable=False,
comment="Arbitrary JSON payload with event details",
),
sa.Column(
"character_name",
sa.String(length=100),
nullable=True,
comment="Character associated with the event (if applicable)",
),
sa.Column(
"map_x",
sa.Integer(),
nullable=True,
comment="X coordinate where the event occurred",
),
sa.Column(
"map_y",
sa.Integer(),
nullable=True,
comment="Y coordinate where the event occurred",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_event_log_event_type"),
"event_log",
["event_type"],
unique=False,
)
op.create_index(
op.f("ix_event_log_character_name"),
"event_log",
["character_name"],
unique=False,
)
op.create_index(
op.f("ix_event_log_created_at"),
"event_log",
["created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_event_log_created_at"), table_name="event_log")
op.drop_index(op.f("ix_event_log_character_name"), table_name="event_log")
op.drop_index(op.f("ix_event_log_event_type"), table_name="event_log")
op.drop_table("event_log")
op.drop_index(op.f("ix_price_history_captured_at"), table_name="price_history")
op.drop_index(op.f("ix_price_history_item_code"), table_name="price_history")
op.drop_table("price_history")

0
backend/app/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,257 @@
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import async_session_factory
from app.engine.manager import AutomationManager
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
from app.schemas.automation import (
AutomationConfigCreate,
AutomationConfigDetailResponse,
AutomationConfigResponse,
AutomationConfigUpdate,
AutomationLogResponse,
AutomationRunResponse,
AutomationStatusResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/automations", tags=["automations"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_manager(request: Request) -> AutomationManager:
manager: AutomationManager | None = getattr(request.app.state, "automation_manager", None)
if manager is None:
raise HTTPException(
status_code=503,
detail="Automation engine is not available",
)
return manager
# ---------------------------------------------------------------------------
# CRUD -- Automation Configs
# ---------------------------------------------------------------------------
@router.get("/", response_model=list[AutomationConfigResponse])
async def list_configs(request: Request) -> list[AutomationConfigResponse]:
"""List all automation configurations with their current status."""
async with async_session_factory() as db:
stmt = select(AutomationConfig).order_by(AutomationConfig.id)
result = await db.execute(stmt)
configs = result.scalars().all()
return [AutomationConfigResponse.model_validate(c) for c in configs]
@router.post("/", response_model=AutomationConfigResponse, status_code=201)
async def create_config(
payload: AutomationConfigCreate,
request: Request,
) -> AutomationConfigResponse:
"""Create a new automation configuration."""
async with async_session_factory() as db:
config = AutomationConfig(
name=payload.name,
character_name=payload.character_name,
strategy_type=payload.strategy_type,
config=payload.config,
)
db.add(config)
await db.commit()
await db.refresh(config)
return AutomationConfigResponse.model_validate(config)
@router.get("/{config_id}", response_model=AutomationConfigDetailResponse)
async def get_config(config_id: int, request: Request) -> AutomationConfigDetailResponse:
"""Get an automation configuration with its run history."""
async with async_session_factory() as db:
stmt = (
select(AutomationConfig)
.options(selectinload(AutomationConfig.runs))
.where(AutomationConfig.id == config_id)
)
result = await db.execute(stmt)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
return AutomationConfigDetailResponse(
config=AutomationConfigResponse.model_validate(config),
runs=[AutomationRunResponse.model_validate(r) for r in config.runs],
)
@router.put("/{config_id}", response_model=AutomationConfigResponse)
async def update_config(
config_id: int,
payload: AutomationConfigUpdate,
request: Request,
) -> AutomationConfigResponse:
"""Update an automation configuration.
Cannot update a configuration that has an active runner.
"""
manager = _get_manager(request)
if manager.is_running(config_id):
raise HTTPException(
status_code=409,
detail="Cannot update a config while its automation is running. Stop it first.",
)
async with async_session_factory() as db:
config = await db.get(AutomationConfig, config_id)
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
if payload.name is not None:
config.name = payload.name
if payload.config is not None:
config.config = payload.config
if payload.enabled is not None:
config.enabled = payload.enabled
await db.commit()
await db.refresh(config)
return AutomationConfigResponse.model_validate(config)
@router.delete("/{config_id}", status_code=204)
async def delete_config(config_id: int, request: Request) -> None:
"""Delete an automation configuration and all its runs/logs.
Cannot delete a configuration that has an active runner.
"""
manager = _get_manager(request)
if manager.is_running(config_id):
raise HTTPException(
status_code=409,
detail="Cannot delete a config while its automation is running. Stop it first.",
)
async with async_session_factory() as db:
config = await db.get(AutomationConfig, config_id)
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
await db.delete(config)
await db.commit()
# ---------------------------------------------------------------------------
# Control -- Start / Stop / Pause / Resume
# ---------------------------------------------------------------------------
@router.post("/{config_id}/start", response_model=AutomationRunResponse)
async def start_automation(config_id: int, request: Request) -> AutomationRunResponse:
"""Start an automation from its configuration."""
manager = _get_manager(request)
try:
return await manager.start(config_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{config_id}/stop", status_code=204)
async def stop_automation(config_id: int, request: Request) -> None:
"""Stop a running automation."""
manager = _get_manager(request)
try:
await manager.stop(config_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{config_id}/pause", status_code=204)
async def pause_automation(config_id: int, request: Request) -> None:
"""Pause a running automation."""
manager = _get_manager(request)
try:
await manager.pause(config_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{config_id}/resume", status_code=204)
async def resume_automation(config_id: int, request: Request) -> None:
"""Resume a paused automation."""
manager = _get_manager(request)
try:
await manager.resume(config_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# Status & Logs
# ---------------------------------------------------------------------------
@router.get("/status/all", response_model=list[AutomationStatusResponse])
async def get_all_statuses(request: Request) -> list[AutomationStatusResponse]:
"""Get live status for all active automations."""
manager = _get_manager(request)
return manager.get_all_statuses()
@router.get("/{config_id}/status", response_model=AutomationStatusResponse)
async def get_automation_status(
config_id: int,
request: Request,
) -> AutomationStatusResponse:
"""Get live status for a specific automation."""
manager = _get_manager(request)
status = manager.get_status(config_id)
if status is None:
# Check if the config exists at all
async with async_session_factory() as db:
config = await db.get(AutomationConfig, config_id)
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
# Config exists but no active runner
return AutomationStatusResponse(
config_id=config_id,
character_name=config.character_name,
strategy_type=config.strategy_type,
status="stopped",
)
return status
@router.get("/{config_id}/logs", response_model=list[AutomationLogResponse])
async def get_logs(
config_id: int,
request: Request,
limit: int = 100,
) -> list[AutomationLogResponse]:
"""Get recent logs for an automation config (across all its runs)."""
async with async_session_factory() as db:
# Verify config exists
config = await db.get(AutomationConfig, config_id)
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
# Fetch logs for all runs belonging to this config
stmt = (
select(AutomationLog)
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
.where(AutomationRun.config_id == config_id)
.order_by(AutomationLog.created_at.desc())
.limit(min(limit, 500))
)
result = await db.execute(stmt)
logs = result.scalars().all()
return [AutomationLogResponse.model_validate(log) for log in logs]

134
backend/app/api/bank.py Normal file
View file

@ -0,0 +1,134 @@
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from httpx import HTTPStatusError
from pydantic import BaseModel, Field
from app.database import async_session_factory
from app.services.artifacts_client import ArtifactsClient
from app.services.bank_service import BankService
from app.services.game_data_cache import GameDataCacheService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["bank"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
def _get_cache_service(request: Request) -> GameDataCacheService:
return request.app.state.cache_service
# ---------------------------------------------------------------------------
# Request schemas for manual actions
# ---------------------------------------------------------------------------
class ManualActionRequest(BaseModel):
"""Request body for manual character actions."""
action: str = Field(
...,
description="Action to perform: 'move', 'fight', 'gather', 'rest'",
)
params: dict = Field(
default_factory=dict,
description="Action parameters (e.g. {x, y} for move)",
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/bank")
async def get_bank(request: Request) -> dict[str, Any]:
"""Return bank details with enriched item data from game cache."""
client = _get_client(request)
cache_service = _get_cache_service(request)
bank_service = BankService()
try:
# Try to get items cache for enrichment
items_cache = None
try:
async with async_session_factory() as db:
items_cache = await cache_service.get_items(db)
except Exception:
logger.warning("Failed to load items cache for bank enrichment")
result = await bank_service.get_contents(client, items_cache)
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
return result
@router.get("/bank/summary")
async def get_bank_summary(request: Request) -> dict[str, Any]:
"""Return a summary of bank contents: gold, item count, slots."""
client = _get_client(request)
bank_service = BankService()
try:
return await bank_service.get_summary(client)
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
@router.post("/characters/{name}/action")
async def manual_action(
name: str,
body: ManualActionRequest,
request: Request,
) -> dict[str, Any]:
"""Execute a manual action on a character.
Supported actions:
- **move**: Move to coordinates. Params: {"x": int, "y": int}
- **fight**: Fight the monster at the current tile. No params.
- **gather**: Gather the resource at the current tile. No params.
- **rest**: Rest to recover HP. No params.
"""
client = _get_client(request)
try:
match body.action:
case "move":
x = body.params.get("x")
y = body.params.get("y")
if x is None or y is None:
raise HTTPException(
status_code=400,
detail="Move action requires 'x' and 'y' in params",
)
result = await client.move(name, int(x), int(y))
case "fight":
result = await client.fight(name)
case "gather":
result = await client.gather(name)
case "rest":
result = await client.rest(name)
case _:
raise HTTPException(
status_code=400,
detail=f"Unknown action: {body.action!r}. Supported: move, fight, gather, rest",
)
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
return {"action": body.action, "character": name, "result": result}

View file

@ -0,0 +1,46 @@
from fastapi import APIRouter, HTTPException, Request
from httpx import HTTPStatusError
from app.schemas.game import CharacterSchema
from app.services.artifacts_client import ArtifactsClient
from app.services.character_service import CharacterService
router = APIRouter(prefix="/api/characters", tags=["characters"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
def _get_service(request: Request) -> CharacterService:
return request.app.state.character_service
@router.get("/", response_model=list[CharacterSchema])
async def list_characters(request: Request) -> list[CharacterSchema]:
"""Return all characters belonging to the authenticated account."""
client = _get_client(request)
service = _get_service(request)
try:
return await service.get_all(client)
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
@router.get("/{name}", response_model=CharacterSchema)
async def get_character(name: str, request: Request) -> CharacterSchema:
"""Return a single character by name."""
client = _get_client(request)
service = _get_service(request)
try:
return await service.get_one(client, name)
except HTTPStatusError as exc:
if exc.response.status_code == 404:
raise HTTPException(status_code=404, detail="Character not found") from exc
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc

View file

@ -0,0 +1,48 @@
import logging
from fastapi import APIRouter, HTTPException, Request
from httpx import HTTPStatusError
from app.schemas.game import DashboardData
from app.services.artifacts_client import ArtifactsClient
from app.services.character_service import CharacterService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["dashboard"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
def _get_service(request: Request) -> CharacterService:
return request.app.state.character_service
@router.get("/dashboard", response_model=DashboardData)
async def get_dashboard(request: Request) -> DashboardData:
"""Return aggregated dashboard data: all characters + server status."""
client = _get_client(request)
service = _get_service(request)
try:
characters = await service.get_all(client)
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
# Server status could be extended later (e.g., ping, event info)
server_status: dict | None = None
try:
events = await client.get_events()
server_status = {"events": events}
except Exception:
logger.warning("Failed to fetch server events for dashboard", exc_info=True)
return DashboardData(
characters=characters,
server_status=server_status,
)

76
backend/app/api/events.py Normal file
View file

@ -0,0 +1,76 @@
"""Game events API router."""
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from httpx import HTTPStatusError
from sqlalchemy import select
from app.database import async_session_factory
from app.models.event_log import EventLog
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/events", tags=["events"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
@router.get("/")
async def get_active_events(request: Request) -> dict[str, Any]:
"""Get currently active game events from the Artifacts API."""
client = _get_client(request)
try:
events = await client.get_events()
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
return {"events": events}
@router.get("/history")
async def get_event_history(
request: Request,
event_type: str | None = Query(default=None, description="Filter by event type"),
character_name: str | None = Query(default=None, description="Filter by character"),
limit: int = Query(default=100, ge=1, le=500, description="Max entries to return"),
offset: int = Query(default=0, ge=0, description="Offset for pagination"),
) -> dict[str, Any]:
"""Get historical events from the event log database."""
async with async_session_factory() as db:
stmt = select(EventLog).order_by(EventLog.created_at.desc())
if event_type:
stmt = stmt.where(EventLog.event_type == event_type)
if character_name:
stmt = stmt.where(EventLog.character_name == character_name)
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
logs = result.scalars().all()
return {
"events": [
{
"id": log.id,
"event_type": log.event_type,
"event_data": log.event_data,
"character_name": log.character_name,
"map_x": log.map_x,
"map_y": log.map_y,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
"limit": limit,
"offset": offset,
}

View file

@ -0,0 +1,82 @@
"""Grand Exchange API router."""
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from httpx import HTTPStatusError
from app.database import async_session_factory
from app.services.artifacts_client import ArtifactsClient
from app.services.exchange_service import ExchangeService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
def _get_exchange_service(request: Request) -> ExchangeService:
service: ExchangeService | None = getattr(request.app.state, "exchange_service", None)
if service is None:
raise HTTPException(
status_code=503,
detail="Exchange service is not available",
)
return service
@router.get("/orders")
async def get_orders(request: Request) -> dict[str, Any]:
"""Get all active Grand Exchange orders."""
client = _get_client(request)
service = _get_exchange_service(request)
try:
orders = await service.get_orders(client)
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
return {"orders": orders}
@router.get("/history")
async def get_history(request: Request) -> dict[str, Any]:
"""Get Grand Exchange transaction history."""
client = _get_client(request)
service = _get_exchange_service(request)
try:
history = await service.get_history(client)
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
return {"history": history}
@router.get("/prices/{item_code}")
async def get_price_history(
item_code: str,
request: Request,
days: int = Query(default=7, ge=1, le=90, description="Number of days of history"),
) -> dict[str, Any]:
"""Get price history for a specific item."""
service = _get_exchange_service(request)
async with async_session_factory() as db:
entries = await service.get_price_history(db, item_code, days)
return {
"item_code": item_code,
"days": days,
"entries": entries,
}

View file

@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.game import ItemSchema, MapSchema, MonsterSchema, ResourceSchema
from app.services.game_data_cache import GameDataCacheService
router = APIRouter(prefix="/api/game", tags=["game-data"])
def _get_cache_service(request: Request) -> GameDataCacheService:
return request.app.state.cache_service
@router.get("/items", response_model=list[ItemSchema])
async def list_items(
request: Request,
db: AsyncSession = Depends(get_db),
) -> list[ItemSchema]:
"""Return all items from the local cache."""
service = _get_cache_service(request)
return await service.get_items(db)
@router.get("/monsters", response_model=list[MonsterSchema])
async def list_monsters(
request: Request,
db: AsyncSession = Depends(get_db),
) -> list[MonsterSchema]:
"""Return all monsters from the local cache."""
service = _get_cache_service(request)
return await service.get_monsters(db)
@router.get("/resources", response_model=list[ResourceSchema])
async def list_resources(
request: Request,
db: AsyncSession = Depends(get_db),
) -> list[ResourceSchema]:
"""Return all resources from the local cache."""
service = _get_cache_service(request)
return await service.get_resources(db)
@router.get("/maps", response_model=list[MapSchema])
async def list_maps(
request: Request,
db: AsyncSession = Depends(get_db),
) -> list[MapSchema]:
"""Return all maps from the local cache."""
service = _get_cache_service(request)
return await service.get_maps(db)

101
backend/app/api/logs.py Normal file
View file

@ -0,0 +1,101 @@
"""Character logs and analytics API router."""
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from httpx import HTTPStatusError
from app.database import async_session_factory
from app.services.analytics_service import AnalyticsService
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/logs", tags=["logs"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
@router.get("/")
async def get_character_logs(
request: Request,
character: str = Query(default="", description="Character name to filter logs"),
limit: int = Query(default=50, ge=1, le=200, description="Max entries to return"),
) -> dict[str, Any]:
"""Get character action logs from the Artifacts API.
This endpoint retrieves the character's recent action logs directly
from the game server.
"""
client = _get_client(request)
try:
if character:
# Get logs for a specific character
char_data = await client.get_character(character)
return {
"character": character,
"logs": [], # The API doesn't have a dedicated logs endpoint per character;
# action data comes from the automation logs in our DB
"character_data": {
"name": char_data.name,
"level": char_data.level,
"xp": char_data.xp,
"gold": char_data.gold,
"x": char_data.x,
"y": char_data.y,
"task": char_data.task,
"task_progress": char_data.task_progress,
"task_total": char_data.task_total,
},
}
else:
# Get all characters as a summary
characters = await client.get_characters()
return {
"characters": [
{
"name": c.name,
"level": c.level,
"xp": c.xp,
"gold": c.gold,
"x": c.x,
"y": c.y,
}
for c in characters
],
}
except HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Artifacts API error: {exc.response.text}",
) from exc
@router.get("/analytics")
async def get_analytics(
request: Request,
character: str = Query(..., description="Character name"),
hours: int = Query(default=24, ge=1, le=168, description="Hours of history"),
) -> dict[str, Any]:
"""Get analytics aggregations for a character.
Returns XP history, gold history, and estimated actions per hour.
"""
analytics = AnalyticsService()
async with async_session_factory() as db:
xp_history = await analytics.get_xp_history(db, character, hours)
gold_history = await analytics.get_gold_history(db, character, hours)
actions_rate = await analytics.get_actions_per_hour(db, character)
return {
"character": character,
"hours": hours,
"xp_history": xp_history,
"gold_history": gold_history,
"actions_rate": actions_rate,
}

113
backend/app/api/ws.py Normal file
View file

@ -0,0 +1,113 @@
"""WebSocket endpoint for the frontend dashboard.
Provides a ``/ws/live`` WebSocket endpoint that relays events from the
internal :class:`EventBus` to connected browser clients. Multiple
frontend connections are supported simultaneously.
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
router = APIRouter()
class ConnectionManager:
"""Manage active frontend WebSocket connections."""
def __init__(self) -> None:
self._connections: list[WebSocket] = []
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self._connections.append(ws)
logger.info(
"Frontend WebSocket connected (total=%d)", len(self._connections)
)
def disconnect(self, ws: WebSocket) -> None:
if ws in self._connections:
self._connections.remove(ws)
logger.info(
"Frontend WebSocket removed (total=%d)", len(self._connections)
)
async def broadcast(self, message: dict) -> None:
"""Send a message to all connected clients.
Silently removes any clients whose connections have broken.
"""
disconnected: list[WebSocket] = []
for ws in self._connections:
try:
await ws.send_json(message)
except Exception:
disconnected.append(ws)
for ws in disconnected:
self.disconnect(ws)
@property
def connection_count(self) -> int:
return len(self._connections)
# Singleton connection manager -- shared across all WebSocket endpoint
# invocations within the same process.
ws_manager = ConnectionManager()
@router.websocket("/ws/live")
async def websocket_live(ws: WebSocket) -> None:
"""WebSocket endpoint that relays internal events to the frontend.
Once connected the client receives a stream of JSON events from the
:class:`EventBus`. The client may send text frames (reserved for
future command support); they are currently ignored.
"""
await ws_manager.connect(ws)
# Obtain the event bus from application state
event_bus: EventBus = ws.app.state.event_bus
queue = event_bus.subscribe_all()
relay_task: asyncio.Task | None = None
try:
# Background task: relay events from the bus to the client
async def _relay() -> None:
try:
while True:
event = await queue.get()
await ws.send_json(event)
except asyncio.CancelledError:
pass
relay_task = asyncio.create_task(
_relay(), name="ws-relay"
)
# Main loop: keep connection alive by reading client frames
while True:
_data = await ws.receive_text()
# Client messages can be handled here in the future
except WebSocketDisconnect:
logger.info("Frontend WebSocket disconnected")
except Exception:
logger.exception("WebSocket error")
finally:
if relay_task is not None:
relay_task.cancel()
try:
await relay_task
except asyncio.CancelledError:
pass
event_bus.unsubscribe("*", queue)
ws_manager.disconnect(ws)

21
backend/app/config.py Normal file
View file

@ -0,0 +1,21 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
artifacts_token: str = ""
database_url: str = "postgresql+asyncpg://artifacts:artifacts@db:5432/artifacts"
cors_origins: list[str] = ["http://localhost:3000"]
# Artifacts API
artifacts_api_url: str = "https://api.artifactsmmo.com"
# Rate limits
action_rate_limit: int = 7 # actions per window
action_rate_window: float = 2.0 # seconds
data_rate_limit: int = 20 # data requests per window
data_rate_window: float = 1.0 # seconds
model_config = {"env_file": ".env", "extra": "ignore"}
settings = Settings()

34
backend/app/database.py Normal file
View file

@ -0,0 +1,34 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise

View file

@ -0,0 +1,11 @@
from app.engine.cooldown import CooldownTracker
from app.engine.manager import AutomationManager
from app.engine.pathfinder import Pathfinder
from app.engine.runner import AutomationRunner
__all__ = [
"AutomationManager",
"AutomationRunner",
"CooldownTracker",
"Pathfinder",
]

View file

@ -0,0 +1,90 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
logger = logging.getLogger(__name__)
# Safety buffer added after every cooldown to avoid 499 "action already in progress" errors
_BUFFER_SECONDS: float = 0.1
class CooldownTracker:
"""Track per-character cooldowns with a safety buffer.
The Artifacts MMO API returns cooldown information after every action.
This tracker stores the expiry timestamp for each character and provides
an async ``wait`` method that sleeps until the cooldown has elapsed plus
a small buffer (100 ms) to prevent race-condition 499 errors.
"""
def __init__(self) -> None:
self._cooldowns: dict[str, datetime] = {}
def update(
self,
character_name: str,
cooldown_seconds: float,
cooldown_expiration: str | None = None,
) -> None:
"""Record the cooldown from an action response.
Parameters
----------
character_name:
The character whose cooldown is being updated.
cooldown_seconds:
Total cooldown duration in seconds (used as fallback).
cooldown_expiration:
ISO-8601 timestamp of when the cooldown expires (preferred).
"""
if cooldown_expiration:
try:
expiry = datetime.fromisoformat(cooldown_expiration)
# Ensure timezone-aware
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
logger.warning(
"Failed to parse cooldown_expiration %r for %s, using duration fallback",
cooldown_expiration,
character_name,
)
expiry = datetime.now(timezone.utc) + timedelta(seconds=cooldown_seconds)
else:
expiry = datetime.now(timezone.utc) + timedelta(seconds=cooldown_seconds)
self._cooldowns[character_name] = expiry
logger.debug(
"Cooldown for %s set to %s (%.1fs)",
character_name,
expiry.isoformat(),
cooldown_seconds,
)
async def wait(self, character_name: str) -> None:
"""Sleep until the character's cooldown has expired plus a safety buffer."""
expiry = self._cooldowns.get(character_name)
if expiry is None:
return
now = datetime.now(timezone.utc)
remaining = (expiry - now).total_seconds() + _BUFFER_SECONDS
if remaining > 0:
logger.debug("Waiting %.2fs for %s cooldown", remaining, character_name)
await asyncio.sleep(remaining)
def is_ready(self, character_name: str) -> bool:
"""Return True if the character has no active cooldown."""
expiry = self._cooldowns.get(character_name)
if expiry is None:
return True
return datetime.now(timezone.utc) >= expiry
def remaining(self, character_name: str) -> float:
"""Return remaining cooldown seconds (0 if ready)."""
expiry = self._cooldowns.get(character_name)
if expiry is None:
return 0.0
delta = (expiry - datetime.now(timezone.utc)).total_seconds()
return max(delta, 0.0)

View file

@ -0,0 +1,139 @@
"""Coordinator for multi-character operations.
Provides simple sequential setup of automations across characters
for pipelines like: gatherer collects materials -> crafter processes them.
"""
import logging
from typing import Any
from app.engine.manager import AutomationManager
logger = logging.getLogger(__name__)
class Coordinator:
"""Coordinates multi-character operations by sequentially setting up automations.
This is a lightweight orchestrator that configures multiple characters
to work in a pipeline. It does not manage real-time synchronization
between characters; each character runs its automation independently.
"""
def __init__(self, manager: AutomationManager) -> None:
self._manager = manager
async def resource_pipeline(
self,
gatherer_config_id: int,
crafter_config_id: int,
item_code: str,
) -> dict[str, Any]:
"""Set up a gather-then-craft pipeline across two characters.
The gatherer character will gather resources and deposit them.
The crafter character will withdraw materials and craft the item.
This is a simple sequential setup -- both automations run
independently after being started.
Parameters
----------
gatherer_config_id:
Automation config ID for the gathering character.
crafter_config_id:
Automation config ID for the crafting character.
item_code:
The item code that the crafter will produce.
Returns
-------
Dict with the run IDs and status of both automations.
"""
results: dict[str, Any] = {
"item_code": item_code,
"gatherer": None,
"crafter": None,
"errors": [],
}
# Start the gatherer first
try:
gatherer_run = await self._manager.start(gatherer_config_id)
results["gatherer"] = {
"config_id": gatherer_config_id,
"run_id": gatherer_run.id,
"status": gatherer_run.status,
}
logger.info(
"Pipeline: started gatherer config=%d run=%d",
gatherer_config_id,
gatherer_run.id,
)
except ValueError as exc:
error_msg = f"Failed to start gatherer: {exc}"
results["errors"].append(error_msg)
logger.warning("Pipeline: %s", error_msg)
# Start the crafter
try:
crafter_run = await self._manager.start(crafter_config_id)
results["crafter"] = {
"config_id": crafter_config_id,
"run_id": crafter_run.id,
"status": crafter_run.status,
}
logger.info(
"Pipeline: started crafter config=%d run=%d",
crafter_config_id,
crafter_run.id,
)
except ValueError as exc:
error_msg = f"Failed to start crafter: {exc}"
results["errors"].append(error_msg)
logger.warning("Pipeline: %s", error_msg)
return results
async def stop_pipeline(
self,
gatherer_config_id: int,
crafter_config_id: int,
) -> dict[str, Any]:
"""Stop both automations in a resource pipeline.
Parameters
----------
gatherer_config_id:
Automation config ID for the gathering character.
crafter_config_id:
Automation config ID for the crafting character.
Returns
-------
Dict with the stop results for both automations.
"""
results: dict[str, Any] = {
"gatherer_stopped": False,
"crafter_stopped": False,
"errors": [],
}
for label, config_id in [
("gatherer", gatherer_config_id),
("crafter", crafter_config_id),
]:
try:
await self._manager.stop(config_id)
results[f"{label}_stopped"] = True
logger.info("Pipeline: stopped %s config=%d", label, config_id)
except ValueError as exc:
results["errors"].append(f"Failed to stop {label}: {exc}")
logger.warning(
"Pipeline: failed to stop %s config=%d: %s",
label,
config_id,
exc,
)
return results

View file

@ -0,0 +1,11 @@
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
from app.engine.decision.heal_policy import HealPolicy
from app.engine.decision.monster_selector import MonsterSelector
from app.engine.decision.resource_selector import ResourceSelector
__all__ = [
"EquipmentOptimizer",
"HealPolicy",
"MonsterSelector",
"ResourceSelector",
]

View file

@ -0,0 +1,157 @@
"""Equipment optimizer for suggesting gear improvements."""
import logging
from dataclasses import dataclass, field
from app.schemas.game import CharacterSchema, ItemSchema
logger = logging.getLogger(__name__)
# Equipment slot names and the item types that can go in them
_SLOT_TYPE_MAP: dict[str, list[str]] = {
"weapon_slot": ["weapon"],
"shield_slot": ["shield"],
"helmet_slot": ["helmet"],
"body_armor_slot": ["body_armor"],
"leg_armor_slot": ["leg_armor"],
"boots_slot": ["boots"],
"ring1_slot": ["ring"],
"ring2_slot": ["ring"],
"amulet_slot": ["amulet"],
"artifact1_slot": ["artifact"],
"artifact2_slot": ["artifact"],
"artifact3_slot": ["artifact"],
}
# Effect names that contribute to the equipment score
_ATTACK_EFFECTS = {"attack_fire", "attack_earth", "attack_water", "attack_air"}
_DEFENSE_EFFECTS = {"res_fire", "res_earth", "res_water", "res_air"}
_HP_EFFECTS = {"hp"}
_DAMAGE_EFFECTS = {"dmg_fire", "dmg_earth", "dmg_water", "dmg_air"}
@dataclass
class EquipmentSuggestion:
"""A suggestion to equip a different item in a slot."""
slot: str
current_item_code: str
suggested_item_code: str
current_score: float
suggested_score: float
improvement: float
reason: str
@dataclass
class EquipmentAnalysis:
"""Full analysis of a character's equipment vs available items."""
suggestions: list[EquipmentSuggestion] = field(default_factory=list)
total_current_score: float = 0.0
total_best_score: float = 0.0
class EquipmentOptimizer:
"""Analyzes character equipment and suggests improvements.
Uses a simple scoring system: sum of all attack + defense + HP stats
from item effects.
"""
def suggest_equipment(
self,
character: CharacterSchema,
available_items: list[ItemSchema],
) -> EquipmentAnalysis:
"""Analyze the character's current equipment and suggest improvements.
Parameters
----------
character:
The character to analyze.
available_items:
Items available to the character (e.g. from bank).
Returns
-------
EquipmentAnalysis with suggestions for each slot where a better item exists.
"""
# Build a lookup of item code -> ItemSchema
item_lookup: dict[str, ItemSchema] = {
item.code: item for item in available_items
}
analysis = EquipmentAnalysis()
for slot, valid_types in _SLOT_TYPE_MAP.items():
current_code = getattr(character, slot, "")
current_item = item_lookup.get(current_code) if current_code else None
current_score = self._score_item(current_item) if current_item else 0.0
# Find the best available item for this slot
candidates = [
item
for item in available_items
if item.type in valid_types and item.level <= character.level
]
if not candidates:
analysis.total_current_score += current_score
analysis.total_best_score += current_score
continue
best_candidate = max(candidates, key=lambda i: self._score_item(i))
best_score = self._score_item(best_candidate)
analysis.total_current_score += current_score
analysis.total_best_score += max(current_score, best_score)
# Only suggest if there's an actual improvement
improvement = best_score - current_score
if improvement > 0 and best_candidate.code != current_code:
suggestion = EquipmentSuggestion(
slot=slot,
current_item_code=current_code or "(empty)",
suggested_item_code=best_candidate.code,
current_score=current_score,
suggested_score=best_score,
improvement=improvement,
reason=(
f"Replace {current_code or 'empty'} "
f"(score {current_score:.1f}) with {best_candidate.code} "
f"(score {best_score:.1f}, +{improvement:.1f})"
),
)
analysis.suggestions.append(suggestion)
# Sort suggestions by improvement descending
analysis.suggestions.sort(key=lambda s: s.improvement, reverse=True)
return analysis
@staticmethod
def _score_item(item: ItemSchema | None) -> float:
"""Calculate a simple composite score for an item.
Score = sum of all attack effects + defense effects + HP + damage effects.
"""
if item is None:
return 0.0
score = 0.0
for effect in item.effects:
name = effect.name.lower()
if name in _ATTACK_EFFECTS:
score += effect.value
elif name in _DEFENSE_EFFECTS:
score += effect.value
elif name in _HP_EFFECTS:
score += effect.value * 0.5 # HP is weighted less than raw stats
elif name in _DAMAGE_EFFECTS:
score += effect.value * 1.5 # Damage bonuses are weighted higher
# Small bonus for higher-level items (tie-breaker)
score += item.level * 0.1
return score

View file

@ -0,0 +1,77 @@
import logging
from app.engine.strategies.base import ActionPlan, ActionType
from app.schemas.game import CharacterSchema
logger = logging.getLogger(__name__)
class HealPolicy:
"""Encapsulates healing decision logic.
Used by strategies to determine *if* and *how* a character should heal.
"""
@staticmethod
def should_heal(character: CharacterSchema, threshold: int) -> bool:
"""Return ``True`` if the character's HP is below *threshold* percent.
Parameters
----------
character:
The character to evaluate.
threshold:
HP percentage (0-100) below which healing is recommended.
"""
if character.max_hp == 0:
return False
hp_pct = (character.hp / character.max_hp) * 100.0
return hp_pct < threshold
@staticmethod
def is_full_health(character: CharacterSchema) -> bool:
"""Return ``True`` if the character is at maximum HP."""
return character.hp >= character.max_hp
@staticmethod
def choose_heal_method(character: CharacterSchema, config: dict) -> ActionPlan:
"""Decide between resting and using a consumable.
Parameters
----------
character:
Current character state.
config:
Strategy config dict containing ``heal_method``,
``consumable_code``, etc.
Returns
-------
An :class:`ActionPlan` for the chosen healing action.
"""
heal_method = config.get("heal_method", "rest")
consumable_code: str | None = config.get("consumable_code")
if heal_method == "consumable" and consumable_code:
# Verify the character actually has the consumable
has_item = any(
slot.code == consumable_code for slot in character.inventory
)
if has_item:
return ActionPlan(
ActionType.USE_ITEM,
params={"code": consumable_code, "quantity": 1},
reason=f"Using {consumable_code} to heal ({character.hp}/{character.max_hp} HP)",
)
else:
logger.info(
"Consumable %s not in inventory for %s, falling back to rest",
consumable_code,
character.name,
)
# Default / fallback: rest
return ActionPlan(
ActionType.REST,
reason=f"Resting to heal ({character.hp}/{character.max_hp} HP)",
)

View file

@ -0,0 +1,83 @@
import logging
from app.schemas.game import CharacterSchema, MonsterSchema
logger = logging.getLogger(__name__)
# Maximum level difference when selecting an "optimal" monster
_MAX_LEVEL_DELTA: int = 5
class MonsterSelector:
"""Select the best monster for a character to fight.
The selection heuristic prefers monsters within +/- 5 levels of the
character's combat level. Among those candidates, higher-level monsters
are preferred because they yield more XP.
"""
def select_optimal(
self,
character: CharacterSchema,
monsters: list[MonsterSchema],
) -> MonsterSchema | None:
"""Return the best monster for the character, or ``None`` if the
list is empty or no suitable monster exists.
Parameters
----------
character:
The character that will be fighting.
monsters:
All available monsters (typically from the game data cache).
Returns
-------
The selected monster, or None.
"""
if not monsters:
return None
char_level = character.level
# First pass: prefer monsters within the level window
candidates = [
m
for m in monsters
if abs(m.level - char_level) <= _MAX_LEVEL_DELTA
]
if not candidates:
# No monster in the preferred window -- fall back to the
# highest-level monster that is still at or below the character
below = [m for m in monsters if m.level <= char_level]
if below:
candidates = below
else:
# All monsters are higher-level; pick the lowest available
candidates = sorted(monsters, key=lambda m: m.level)
return candidates[0] if candidates else None
# Among candidates, prefer higher level for better XP
candidates.sort(key=lambda m: m.level, reverse=True)
selected = candidates[0]
logger.debug(
"Selected monster %s (level %d) for character %s (level %d)",
selected.code,
selected.level,
character.name,
char_level,
)
return selected
def filter_by_code(
self,
monsters: list[MonsterSchema],
code: str,
) -> MonsterSchema | None:
"""Return the monster with the given code, or ``None``."""
for m in monsters:
if m.code == code:
return m
return None

View file

@ -0,0 +1,149 @@
"""Resource selector for choosing optimal gathering targets based on character skill level."""
import logging
from dataclasses import dataclass
from app.schemas.game import CharacterSchema, ResourceSchema
logger = logging.getLogger(__name__)
@dataclass
class ResourceSelection:
"""Result of a resource selection decision."""
resource: ResourceSchema
score: float
reason: str
class ResourceSelector:
"""Selects the optimal resource for a character to gather based on skill level.
Prefers resources within +/- 3 levels of the character's skill.
Among eligible resources, prefers higher-level ones for better XP.
"""
# How many levels above/below the character's skill level to consider
LEVEL_RANGE: int = 3
def select_optimal(
self,
character: CharacterSchema,
resources: list[ResourceSchema],
skill: str,
) -> ResourceSelection | None:
"""Select the best resource for the character's skill level.
Parameters
----------
character:
The character whose skill level determines the selection.
resources:
Available resources to choose from.
skill:
The gathering skill to optimize for (e.g. "mining", "woodcutting", "fishing").
Returns
-------
ResourceSelection or None if no suitable resource is found.
"""
skill_level = self._get_skill_level(character, skill)
if skill_level is None:
logger.warning("Unknown skill %r for resource selection", skill)
return None
# Filter to resources that match the skill
skill_resources = [r for r in resources if r.skill == skill]
if not skill_resources:
logger.info("No resources found for skill %s", skill)
return None
# Score each resource
scored: list[tuple[ResourceSchema, float, str]] = []
for resource in skill_resources:
score, reason = self._score_resource(resource, skill_level)
if score > 0:
scored.append((resource, score, reason))
if not scored:
# Fallback: pick the highest-level resource we can actually gather
gatherable = [
r for r in skill_resources if r.level <= skill_level
]
if gatherable:
best = max(gatherable, key=lambda r: r.level)
return ResourceSelection(
resource=best,
score=0.1,
reason=f"Fallback: highest gatherable resource (level {best.level}, skill {skill_level})",
)
# Pick the lowest-level resource as absolute fallback
lowest = min(skill_resources, key=lambda r: r.level)
return ResourceSelection(
resource=lowest,
score=0.01,
reason=f"Absolute fallback: lowest resource (level {lowest.level}, skill {skill_level})",
)
# Sort by score descending and pick the best
scored.sort(key=lambda x: x[1], reverse=True)
best_resource, best_score, best_reason = scored[0]
logger.info(
"Selected resource %s (level %d) for %s level %d: %s (score=%.2f)",
best_resource.code,
best_resource.level,
skill,
skill_level,
best_reason,
best_score,
)
return ResourceSelection(
resource=best_resource,
score=best_score,
reason=best_reason,
)
def _score_resource(
self,
resource: ResourceSchema,
skill_level: int,
) -> tuple[float, str]:
"""Score a resource based on how well it matches the character's skill level.
Returns (score, reason). Score of 0 means the resource is not suitable.
"""
level_diff = resource.level - skill_level
# Cannot gather resources more than LEVEL_RANGE levels above skill
if level_diff > self.LEVEL_RANGE:
return 0.0, f"Too high level (resource {resource.level}, skill {skill_level})"
# Ideal range: within +/- LEVEL_RANGE
if abs(level_diff) <= self.LEVEL_RANGE:
# Higher level within range = more XP = better score
# Base score from level closeness (prefer higher)
base_score = 10.0 + level_diff # Range: [7, 13]
# Bonus for being at or slightly above skill level (best XP)
if 0 <= level_diff <= self.LEVEL_RANGE:
base_score += 5.0 # Prefer resources at or above skill level
reason = f"In optimal range (diff={level_diff:+d})"
return base_score, reason
# Resource is far below skill level -- still works but less XP
# level_diff < -LEVEL_RANGE
penalty = abs(level_diff) - self.LEVEL_RANGE
score = max(5.0 - penalty, 0.1)
return score, f"Below optimal range (diff={level_diff:+d})"
@staticmethod
def _get_skill_level(character: CharacterSchema, skill: str) -> int | None:
"""Extract the level for a given skill from the character schema."""
skill_attr = f"{skill}_level"
if hasattr(character, skill_attr):
return getattr(character, skill_attr)
return None

View file

@ -0,0 +1,244 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload
from app.engine.cooldown import CooldownTracker
from app.engine.pathfinder import Pathfinder
from app.engine.runner import AutomationRunner
from app.engine.strategies.base import BaseStrategy
from app.engine.strategies.combat import CombatStrategy
from app.engine.strategies.crafting import CraftingStrategy
from app.engine.strategies.gathering import GatheringStrategy
from app.engine.strategies.leveling import LevelingStrategy
from app.engine.strategies.task import TaskStrategy
from app.engine.strategies.trading import TradingStrategy
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
from app.schemas.automation import (
AutomationLogResponse,
AutomationRunResponse,
AutomationStatusResponse,
)
from app.services.artifacts_client import ArtifactsClient
if TYPE_CHECKING:
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
class AutomationManager:
"""Central manager that orchestrates all automation runners.
One manager exists per application instance and is stored on
``app.state.automation_manager``. It holds references to all active
runners (keyed by ``config_id``) and provides high-level start / stop /
pause / resume operations.
"""
def __init__(
self,
client: ArtifactsClient,
db_factory: async_sessionmaker[AsyncSession],
pathfinder: Pathfinder,
event_bus: EventBus | None = None,
) -> None:
self._client = client
self._db_factory = db_factory
self._pathfinder = pathfinder
self._event_bus = event_bus
self._runners: dict[int, AutomationRunner] = {}
self._cooldown_tracker = CooldownTracker()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self, config_id: int) -> AutomationRunResponse:
"""Start an automation from its persisted configuration.
Creates a new :class:`AutomationRun` record and spawns an
:class:`AutomationRunner` task.
Raises
------
ValueError
If the config does not exist, is disabled, or is already running.
"""
# Prevent duplicate runners
if config_id in self._runners:
runner = self._runners[config_id]
if runner.is_running or runner.is_paused:
raise ValueError(
f"Automation config {config_id} is already running "
f"(run_id={runner.run_id}, status={runner.status})"
)
async with self._db_factory() as db:
# Load the config
config = await db.get(AutomationConfig, config_id)
if config is None:
raise ValueError(f"Automation config {config_id} not found")
if not config.enabled:
raise ValueError(f"Automation config {config_id} is disabled")
# Create strategy
strategy = self._create_strategy(config.strategy_type, config.config)
# Create run record
run = AutomationRun(
config_id=config_id,
status="running",
)
db.add(run)
await db.commit()
await db.refresh(run)
run_response = AutomationRunResponse.model_validate(run)
# Build and start the runner
runner = AutomationRunner(
config_id=config_id,
character_name=config.character_name,
strategy=strategy,
client=self._client,
cooldown_tracker=self._cooldown_tracker,
db_factory=self._db_factory,
run_id=run.id,
event_bus=self._event_bus,
)
self._runners[config_id] = runner
await runner.start()
logger.info(
"Started automation config=%d character=%s strategy=%s run=%d",
config_id,
config.character_name,
config.strategy_type,
run.id,
)
return run_response
async def stop(self, config_id: int) -> None:
"""Stop a running automation.
Raises
------
ValueError
If no runner exists for the given config.
"""
runner = self._runners.get(config_id)
if runner is None:
raise ValueError(f"No active runner for config {config_id}")
await runner.stop()
del self._runners[config_id]
logger.info("Stopped automation config=%d", config_id)
async def pause(self, config_id: int) -> None:
"""Pause a running automation.
Raises
------
ValueError
If no runner exists for the given config or it is not running.
"""
runner = self._runners.get(config_id)
if runner is None:
raise ValueError(f"No active runner for config {config_id}")
if not runner.is_running:
raise ValueError(f"Runner for config {config_id} is not running (status={runner.status})")
await runner.pause()
async def resume(self, config_id: int) -> None:
"""Resume a paused automation.
Raises
------
ValueError
If no runner exists for the given config or it is not paused.
"""
runner = self._runners.get(config_id)
if runner is None:
raise ValueError(f"No active runner for config {config_id}")
if not runner.is_paused:
raise ValueError(f"Runner for config {config_id} is not paused (status={runner.status})")
await runner.resume()
async def stop_all(self) -> None:
"""Stop all running automations (used during shutdown)."""
config_ids = list(self._runners.keys())
for config_id in config_ids:
try:
await self.stop(config_id)
except Exception:
logger.exception("Error stopping automation config=%d", config_id)
# ------------------------------------------------------------------
# Status queries
# ------------------------------------------------------------------
def get_status(self, config_id: int) -> AutomationStatusResponse | None:
"""Return the live status of a single automation, or ``None``."""
runner = self._runners.get(config_id)
if runner is None:
return None
return AutomationStatusResponse(
config_id=runner.config_id,
character_name=runner.character_name,
strategy_type=runner.strategy_state,
status=runner.status,
run_id=runner.run_id,
actions_count=runner.actions_count,
)
def get_all_statuses(self) -> list[AutomationStatusResponse]:
"""Return live status for all active automations."""
return [
AutomationStatusResponse(
config_id=r.config_id,
character_name=r.character_name,
strategy_type=r.strategy_state,
status=r.status,
run_id=r.run_id,
actions_count=r.actions_count,
)
for r in self._runners.values()
]
def is_running(self, config_id: int) -> bool:
"""Return True if there is an active runner for the config."""
runner = self._runners.get(config_id)
return runner is not None and (runner.is_running or runner.is_paused)
# ------------------------------------------------------------------
# Strategy factory
# ------------------------------------------------------------------
def _create_strategy(self, strategy_type: str, config: dict) -> BaseStrategy:
"""Instantiate a strategy by type name."""
match strategy_type:
case "combat":
return CombatStrategy(config, self._pathfinder)
case "gathering":
return GatheringStrategy(config, self._pathfinder)
case "crafting":
return CraftingStrategy(config, self._pathfinder)
case "trading":
return TradingStrategy(config, self._pathfinder)
case "task":
return TaskStrategy(config, self._pathfinder)
case "leveling":
return LevelingStrategy(config, self._pathfinder)
case _:
raise ValueError(
f"Unknown strategy type: {strategy_type!r}. "
f"Supported: combat, gathering, crafting, trading, task, leveling"
)

View file

@ -0,0 +1,130 @@
import logging
from app.schemas.game import MapSchema
logger = logging.getLogger(__name__)
class Pathfinder:
"""Spatial index over the game map for finding tiles by content.
Uses Manhattan distance (since the Artifacts MMO API ``move`` action
performs a direct teleport with a cooldown proportional to Manhattan
distance). A* path-finding over walkable tiles is therefore unnecessary;
the optimal strategy is always to move directly to the target.
"""
def __init__(self) -> None:
self._maps: list[MapSchema] = []
self._map_index: dict[tuple[int, int], MapSchema] = {}
# ------------------------------------------------------------------
# Initialization
# ------------------------------------------------------------------
def load_maps(self, maps: list[MapSchema]) -> None:
"""Load map data (typically from the game data cache)."""
self._maps = list(maps)
self._map_index = {(m.x, m.y): m for m in self._maps}
logger.info("Pathfinder loaded %d map tiles", len(self._maps))
@property
def is_loaded(self) -> bool:
return len(self._maps) > 0
# ------------------------------------------------------------------
# Tile lookup
# ------------------------------------------------------------------
def get_tile(self, x: int, y: int) -> MapSchema | None:
"""Return the map tile at the given coordinates, or None."""
return self._map_index.get((x, y))
def tile_has_content(self, x: int, y: int, content_type: str, content_code: str) -> bool:
"""Check whether the tile at (x, y) has the specified content."""
tile = self._map_index.get((x, y))
if tile is None or tile.content is None:
return False
return tile.content.type == content_type and tile.content.code == content_code
def tile_has_content_type(self, x: int, y: int, content_type: str) -> bool:
"""Check whether the tile at (x, y) has any content of the given type."""
tile = self._map_index.get((x, y))
if tile is None or tile.content is None:
return False
return tile.content.type == content_type
# ------------------------------------------------------------------
# Nearest-tile search
# ------------------------------------------------------------------
def find_nearest(
self,
from_x: int,
from_y: int,
content_type: str,
content_code: str,
) -> tuple[int, int] | None:
"""Find the nearest tile whose content matches type *and* code.
Returns ``(x, y)`` of the closest match, or ``None`` if not found.
"""
best: tuple[int, int] | None = None
best_dist = float("inf")
for m in self._maps:
if (
m.content is not None
and m.content.type == content_type
and m.content.code == content_code
):
dist = abs(m.x - from_x) + abs(m.y - from_y)
if dist < best_dist:
best_dist = dist
best = (m.x, m.y)
return best
def find_nearest_by_type(
self,
from_x: int,
from_y: int,
content_type: str,
) -> tuple[int, int] | None:
"""Find the nearest tile that has any content of *content_type*.
Returns ``(x, y)`` of the closest match, or ``None`` if not found.
"""
best: tuple[int, int] | None = None
best_dist = float("inf")
for m in self._maps:
if m.content is not None and m.content.type == content_type:
dist = abs(m.x - from_x) + abs(m.y - from_y)
if dist < best_dist:
best_dist = dist
best = (m.x, m.y)
return best
def find_all(
self,
content_type: str,
content_code: str | None = None,
) -> list[tuple[int, int]]:
"""Return coordinates of all tiles matching the given content filter."""
results: list[tuple[int, int]] = []
for m in self._maps:
if m.content is None:
continue
if m.content.type != content_type:
continue
if content_code is not None and m.content.code != content_code:
continue
results.append((m.x, m.y))
return results
@staticmethod
def manhattan_distance(x1: int, y1: int, x2: int, y2: int) -> int:
"""Compute the Manhattan distance between two points."""
return abs(x1 - x2) + abs(y1 - y2)

View file

@ -0,0 +1,504 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.engine.cooldown import CooldownTracker
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.models.automation import AutomationLog, AutomationRun
from app.services.artifacts_client import ArtifactsClient
if TYPE_CHECKING:
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
# Delay before retrying after an unhandled error in the run loop
_ERROR_RETRY_DELAY: float = 2.0
# Maximum consecutive errors before the runner stops itself
_MAX_CONSECUTIVE_ERRORS: int = 10
class AutomationRunner:
"""Drives the automation loop for a single character.
Each runner owns an ``asyncio.Task`` that repeatedly:
1. Waits for the character's cooldown to expire.
2. Fetches the current character state from the API.
3. Asks the strategy for the next action.
4. Executes that action via the artifacts client.
5. Records the cooldown and logs the result to the database.
The runner can be paused, resumed, or stopped at any time.
"""
def __init__(
self,
config_id: int,
character_name: str,
strategy: BaseStrategy,
client: ArtifactsClient,
cooldown_tracker: CooldownTracker,
db_factory: async_sessionmaker[AsyncSession],
run_id: int,
event_bus: EventBus | None = None,
) -> None:
self._config_id = config_id
self._character_name = character_name
self._strategy = strategy
self._client = client
self._cooldown = cooldown_tracker
self._db_factory = db_factory
self._run_id = run_id
self._event_bus = event_bus
self._running = False
self._paused = False
self._task: asyncio.Task[None] | None = None
self._actions_count: int = 0
self._consecutive_errors: int = 0
# ------------------------------------------------------------------
# Public properties
# ------------------------------------------------------------------
@property
def config_id(self) -> int:
return self._config_id
@property
def character_name(self) -> str:
return self._character_name
@property
def run_id(self) -> int:
return self._run_id
@property
def actions_count(self) -> int:
return self._actions_count
@property
def is_running(self) -> bool:
return self._running and not self._paused
@property
def is_paused(self) -> bool:
return self._running and self._paused
@property
def status(self) -> str:
if not self._running:
return "stopped"
if self._paused:
return "paused"
return "running"
@property
def strategy_state(self) -> str:
return self._strategy.get_state()
# ------------------------------------------------------------------
# Event bus helpers
# ------------------------------------------------------------------
async def _publish(self, event_type: str, data: dict) -> None:
"""Publish an event to the event bus if one is configured."""
if self._event_bus is not None:
try:
await self._event_bus.publish(event_type, data)
except Exception:
logger.exception("Failed to publish event %s", event_type)
async def _publish_status(self, status: str) -> None:
"""Publish an automation_status_changed event."""
await self._publish(
"automation_status_changed",
{
"config_id": self._config_id,
"character_name": self._character_name,
"status": status,
"run_id": self._run_id,
},
)
async def _publish_action(
self,
action_type: str,
success: bool,
details: dict | None = None,
) -> None:
"""Publish an automation_action event."""
await self._publish(
"automation_action",
{
"config_id": self._config_id,
"character_name": self._character_name,
"action_type": action_type,
"success": success,
"details": details or {},
"actions_count": self._actions_count,
},
)
async def _publish_character_update(self) -> None:
"""Publish a character_update event to trigger frontend re-fetch."""
await self._publish(
"character_update",
{
"character_name": self._character_name,
},
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
"""Start the automation loop in a background task."""
if self._running:
logger.warning("Runner for config %d is already running", self._config_id)
return
self._running = True
self._paused = False
self._task = asyncio.create_task(
self._run_loop(),
name=f"automation-{self._config_id}-{self._character_name}",
)
logger.info(
"Started automation runner for config %d (character=%s, run=%d)",
self._config_id,
self._character_name,
self._run_id,
)
await self._publish_status("running")
async def stop(self, error_message: str | None = None) -> None:
"""Stop the automation loop and finalize the run record."""
self._running = False
if self._task is not None and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# Update the run record in the database
final_status = "error" if error_message else "stopped"
await self._finalize_run(
status=final_status,
error_message=error_message,
)
logger.info(
"Stopped automation runner for config %d (actions=%d)",
self._config_id,
self._actions_count,
)
await self._publish_status(final_status)
async def pause(self) -> None:
"""Pause the automation loop (the task keeps running but idles)."""
self._paused = True
await self._update_run_status("paused")
logger.info("Paused automation runner for config %d", self._config_id)
await self._publish_status("paused")
async def resume(self) -> None:
"""Resume a paused automation loop."""
self._paused = False
await self._update_run_status("running")
logger.info("Resumed automation runner for config %d", self._config_id)
await self._publish_status("running")
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
async def _run_loop(self) -> None:
"""Core automation loop -- runs until stopped or the strategy completes."""
try:
while self._running:
if self._paused:
await asyncio.sleep(1)
continue
try:
await self._tick()
self._consecutive_errors = 0
except asyncio.CancelledError:
raise
except Exception as exc:
self._consecutive_errors += 1
logger.exception(
"Error in automation loop for config %d (error %d/%d): %s",
self._config_id,
self._consecutive_errors,
_MAX_CONSECUTIVE_ERRORS,
exc,
)
await self._log_action(
ActionPlan(ActionType.IDLE, reason=str(exc)),
success=False,
)
await self._publish_action(
"error",
success=False,
details={"error": str(exc)},
)
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
logger.error(
"Too many consecutive errors for config %d, stopping",
self._config_id,
)
await self._finalize_run(
status="error",
error_message=f"Stopped after {_MAX_CONSECUTIVE_ERRORS} consecutive errors. Last: {exc}",
)
self._running = False
await self._publish_status("error")
return
await asyncio.sleep(_ERROR_RETRY_DELAY)
except asyncio.CancelledError:
logger.info("Automation loop for config %d was cancelled", self._config_id)
async def _tick(self) -> None:
"""Execute a single iteration of the automation loop."""
# 1. Wait for cooldown
await self._cooldown.wait(self._character_name)
# 2. Fetch current character state
character = await self._client.get_character(self._character_name)
# 3. Ask strategy for the next action
plan = await self._strategy.next_action(character)
# 4. Handle terminal actions
if plan.action_type == ActionType.COMPLETE:
logger.info(
"Strategy completed for config %d: %s",
self._config_id,
plan.reason,
)
await self._log_action(plan, success=True)
await self._finalize_run(status="completed")
self._running = False
await self._publish_status("completed")
await self._publish_action(
plan.action_type.value,
success=True,
details={"reason": plan.reason},
)
return
if plan.action_type == ActionType.IDLE:
logger.debug(
"Strategy idle for config %d: %s",
self._config_id,
plan.reason,
)
await asyncio.sleep(1)
return
# 5. Execute the action
result = await self._execute_action(plan)
# 6. Update cooldown from response
self._update_cooldown_from_result(result)
# 7. Record success
self._actions_count += 1
await self._log_action(plan, success=True)
# 8. Publish events for the frontend
await self._publish_action(
plan.action_type.value,
success=True,
details={
"params": plan.params,
"reason": plan.reason,
"strategy_state": self._strategy.get_state(),
},
)
await self._publish_character_update()
# ------------------------------------------------------------------
# Action execution
# ------------------------------------------------------------------
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
"""Dispatch an action plan to the appropriate client method."""
match plan.action_type:
case ActionType.MOVE:
return await self._client.move(
self._character_name,
plan.params["x"],
plan.params["y"],
)
case ActionType.FIGHT:
return await self._client.fight(self._character_name)
case ActionType.GATHER:
return await self._client.gather(self._character_name)
case ActionType.REST:
return await self._client.rest(self._character_name)
case ActionType.EQUIP:
return await self._client.equip(
self._character_name,
plan.params["code"],
plan.params["slot"],
plan.params.get("quantity", 1),
)
case ActionType.UNEQUIP:
return await self._client.unequip(
self._character_name,
plan.params["slot"],
plan.params.get("quantity", 1),
)
case ActionType.USE_ITEM:
return await self._client.use_item(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.DEPOSIT_ITEM:
return await self._client.deposit_item(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.WITHDRAW_ITEM:
return await self._client.withdraw_item(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.CRAFT:
return await self._client.craft(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.RECYCLE:
return await self._client.recycle(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.GE_BUY:
return await self._client.ge_buy(
self._character_name,
plan.params["code"],
plan.params["quantity"],
plan.params["price"],
)
case ActionType.GE_SELL:
return await self._client.ge_sell_order(
self._character_name,
plan.params["code"],
plan.params["quantity"],
plan.params["price"],
)
case ActionType.GE_CANCEL:
return await self._client.ge_cancel(
self._character_name,
plan.params["order_id"],
)
case ActionType.TASK_NEW:
return await self._client.task_new(self._character_name)
case ActionType.TASK_TRADE:
return await self._client.task_trade(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.TASK_COMPLETE:
return await self._client.task_complete(self._character_name)
case ActionType.TASK_EXCHANGE:
return await self._client.task_exchange(self._character_name)
case _:
logger.warning("Unhandled action type: %s", plan.action_type)
return {}
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
"""Extract cooldown information from an action response and update the tracker."""
cooldown = result.get("cooldown")
if cooldown is None:
return
self._cooldown.update(
self._character_name,
cooldown.get("total_seconds", 0),
cooldown.get("expiration"),
)
# ------------------------------------------------------------------
# Database helpers
# ------------------------------------------------------------------
async def _log_action(self, plan: ActionPlan, success: bool) -> None:
"""Write an action log entry and update the run's action count."""
try:
async with self._db_factory() as db:
log = AutomationLog(
run_id=self._run_id,
action_type=plan.action_type.value,
details={
"params": plan.params,
"reason": plan.reason,
"strategy_state": self._strategy.get_state(),
},
success=success,
)
db.add(log)
# Update the run's action counter
stmt = select(AutomationRun).where(AutomationRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.actions_count = self._actions_count
await db.commit()
except Exception:
logger.exception("Failed to log action for run %d", self._run_id)
async def _update_run_status(self, status: str) -> None:
"""Update the status field of the current run."""
try:
async with self._db_factory() as db:
stmt = select(AutomationRun).where(AutomationRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.status = status
await db.commit()
except Exception:
logger.exception("Failed to update run %d status to %s", self._run_id, status)
async def _finalize_run(
self,
status: str,
error_message: str | None = None,
) -> None:
"""Mark the run as finished with a final status and timestamp."""
try:
async with self._db_factory() as db:
stmt = select(AutomationRun).where(AutomationRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.status = status
run.stopped_at = datetime.now(timezone.utc)
run.actions_count = self._actions_count
if error_message:
run.error_message = error_message
await db.commit()
except Exception:
logger.exception("Failed to finalize run %d", self._run_id)

View file

@ -0,0 +1,19 @@
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.engine.strategies.combat import CombatStrategy
from app.engine.strategies.crafting import CraftingStrategy
from app.engine.strategies.gathering import GatheringStrategy
from app.engine.strategies.leveling import LevelingStrategy
from app.engine.strategies.task import TaskStrategy
from app.engine.strategies.trading import TradingStrategy
__all__ = [
"ActionPlan",
"ActionType",
"BaseStrategy",
"CombatStrategy",
"CraftingStrategy",
"GatheringStrategy",
"LevelingStrategy",
"TaskStrategy",
"TradingStrategy",
]

View file

@ -0,0 +1,99 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from app.engine.pathfinder import Pathfinder
from app.schemas.game import CharacterSchema
class ActionType(str, Enum):
"""All possible actions the automation runner can execute."""
MOVE = "move"
FIGHT = "fight"
GATHER = "gather"
REST = "rest"
EQUIP = "equip"
UNEQUIP = "unequip"
USE_ITEM = "use_item"
DEPOSIT_ITEM = "deposit_item"
WITHDRAW_ITEM = "withdraw_item"
CRAFT = "craft"
RECYCLE = "recycle"
GE_BUY = "ge_buy"
GE_SELL = "ge_sell"
GE_CANCEL = "ge_cancel"
TASK_NEW = "task_new"
TASK_TRADE = "task_trade"
TASK_COMPLETE = "task_complete"
TASK_EXCHANGE = "task_exchange"
IDLE = "idle"
COMPLETE = "complete"
@dataclass
class ActionPlan:
"""A single action to be executed by the runner."""
action_type: ActionType
params: dict = field(default_factory=dict)
reason: str = ""
class BaseStrategy(ABC):
"""Abstract base class for all automation strategies.
A strategy inspects the current character state and returns an
:class:`ActionPlan` describing the next action the runner should execute.
Subclasses must implement :meth:`next_action` and :meth:`get_state`.
"""
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
self.config = config
self.pathfinder = pathfinder
@abstractmethod
async def next_action(self, character: CharacterSchema) -> ActionPlan:
"""Determine the next action based on the current character state.
Returns an :class:`ActionPlan` for the runner to execute. Returning
``ActionType.COMPLETE`` signals the runner to stop the automation
loop gracefully. ``ActionType.IDLE`` causes the runner to skip
execution and re-evaluate after a short delay.
"""
...
@abstractmethod
def get_state(self) -> str:
"""Return a human-readable label describing the current strategy state.
Used for logging and status reporting.
"""
...
# ------------------------------------------------------------------
# Shared helpers available to all strategies
# ------------------------------------------------------------------
@staticmethod
def _inventory_used_slots(character: CharacterSchema) -> int:
"""Count how many inventory slots are currently occupied."""
return len(character.inventory)
@staticmethod
def _inventory_free_slots(character: CharacterSchema) -> int:
"""Count how many inventory slots are free."""
return character.inventory_max_items - len(character.inventory)
@staticmethod
def _hp_percent(character: CharacterSchema) -> float:
"""Return the character's HP as a percentage of max HP."""
if character.max_hp == 0:
return 100.0
return (character.hp / character.max_hp) * 100.0
@staticmethod
def _is_at(character: CharacterSchema, x: int, y: int) -> bool:
"""Check whether the character is standing at the given tile."""
return character.x == x and character.y == y

View file

@ -0,0 +1,232 @@
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
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) -> None:
super().__init__(config, pathfinder)
self._state = _CombatState.MOVE_TO_MONSTER
# Parsed config with defaults
self._monster_code: str = config["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)
# 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:
# 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)

View file

@ -0,0 +1,420 @@
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, ItemSchema
logger = logging.getLogger(__name__)
class _CraftState(str, Enum):
"""Internal state machine states for the crafting loop."""
CHECK_MATERIALS = "check_materials"
GATHER_MATERIALS = "gather_materials"
MOVE_TO_BANK_WITHDRAW = "move_to_bank_withdraw"
WITHDRAW_MATERIALS = "withdraw_materials"
MOVE_TO_WORKSHOP = "move_to_workshop"
CRAFT = "craft"
CHECK_RESULT = "check_result"
MOVE_TO_BANK_DEPOSIT = "move_to_bank_deposit"
DEPOSIT = "deposit"
# Mapping from craft skill names to their workshop content codes
_SKILL_TO_WORKSHOP: dict[str, str] = {
"weaponcrafting": "weaponcrafting",
"gearcrafting": "gearcrafting",
"jewelrycrafting": "jewelrycrafting",
"cooking": "cooking",
"woodcutting": "woodcutting",
"mining": "mining",
"alchemy": "alchemy",
}
class CraftingStrategy(BaseStrategy):
"""Automated crafting strategy.
State machine flow::
CHECK_MATERIALS -> (missing?) -> MOVE_TO_BANK_WITHDRAW -> WITHDRAW_MATERIALS
|
-> GATHER_MATERIALS (if gather_materials=True) |
v
-> MOVE_TO_WORKSHOP -> CRAFT -> CHECK_RESULT
|
(recycle?) -> CRAFT (loop for XP)
(done?) -> MOVE_TO_BANK_DEPOSIT -> DEPOSIT
|
(more qty?) -> CHECK_MATERIALS (loop)
Configuration keys (see :class:`~app.schemas.automation.CraftingConfig`):
- item_code: str -- the item to craft
- quantity: int (default 1) -- how many to craft total
- gather_materials: bool (default False) -- auto-gather missing materials
- recycle_excess: bool (default False) -- recycle crafted items for XP
"""
def __init__(
self,
config: dict,
pathfinder: Pathfinder,
items_data: list[ItemSchema] | None = None,
) -> None:
super().__init__(config, pathfinder)
self._state = _CraftState.CHECK_MATERIALS
# Parsed config with defaults
self._item_code: str = config["item_code"]
self._quantity: int = config.get("quantity", 1)
self._gather_materials: bool = config.get("gather_materials", False)
self._recycle_excess: bool = config.get("recycle_excess", False)
# Runtime counters
self._crafted_count: int = 0
# Recipe data (resolved from game data)
self._recipe: list[dict[str, str | int]] = [] # [{"code": ..., "quantity": ...}]
self._craft_skill: str = ""
self._craft_level: int = 0
self._recipe_resolved: bool = False
# If items data is provided, resolve the recipe immediately
if items_data:
self._resolve_recipe(items_data)
# Cached locations
self._workshop_pos: tuple[int, int] | None = None
self._bank_pos: tuple[int, int] | None = None
# Sub-state for gathering
self._gather_resource_code: str | None = None
self._gather_pos: tuple[int, int] | None = None
def get_state(self) -> str:
return self._state.value
def set_items_data(self, items_data: list[ItemSchema]) -> None:
"""Set item data for recipe resolution (called by manager after creation)."""
if not self._recipe_resolved:
self._resolve_recipe(items_data)
async def next_action(self, character: CharacterSchema) -> ActionPlan:
# Check if we've completed the target quantity
if self._crafted_count >= self._quantity and not self._recycle_excess:
return ActionPlan(
ActionType.COMPLETE,
reason=f"Crafted {self._crafted_count}/{self._quantity} {self._item_code}",
)
# If recipe is not resolved, idle until it is
if not self._recipe_resolved:
return ActionPlan(
ActionType.IDLE,
reason=f"Recipe for {self._item_code} not yet resolved from game data",
)
# Resolve locations lazily
self._resolve_locations(character)
match self._state:
case _CraftState.CHECK_MATERIALS:
return self._handle_check_materials(character)
case _CraftState.GATHER_MATERIALS:
return self._handle_gather_materials(character)
case _CraftState.MOVE_TO_BANK_WITHDRAW:
return self._handle_move_to_bank_withdraw(character)
case _CraftState.WITHDRAW_MATERIALS:
return self._handle_withdraw_materials(character)
case _CraftState.MOVE_TO_WORKSHOP:
return self._handle_move_to_workshop(character)
case _CraftState.CRAFT:
return self._handle_craft(character)
case _CraftState.CHECK_RESULT:
return self._handle_check_result(character)
case _CraftState.MOVE_TO_BANK_DEPOSIT:
return self._handle_move_to_bank_deposit(character)
case _CraftState.DEPOSIT:
return self._handle_deposit(character)
case _:
return ActionPlan(ActionType.IDLE, reason="Unknown state")
# ------------------------------------------------------------------
# State handlers
# ------------------------------------------------------------------
def _handle_check_materials(self, character: CharacterSchema) -> ActionPlan:
"""Check if the character has all required materials in inventory."""
missing = self._get_missing_materials(character)
if not missing:
# All materials in inventory, go craft
self._state = _CraftState.MOVE_TO_WORKSHOP
return self._handle_move_to_workshop(character)
# Materials are missing -- try to withdraw from bank first
self._state = _CraftState.MOVE_TO_BANK_WITHDRAW
return self._handle_move_to_bank_withdraw(character)
def _handle_move_to_bank_withdraw(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 = _CraftState.WITHDRAW_MATERIALS
return self._handle_withdraw_materials(character)
self._state = _CraftState.WITHDRAW_MATERIALS
return ActionPlan(
ActionType.MOVE,
params={"x": bx, "y": by},
reason=f"Moving to bank at ({bx}, {by}) to withdraw materials",
)
def _handle_withdraw_materials(self, character: CharacterSchema) -> ActionPlan:
"""Withdraw missing materials from the bank one at a time."""
missing = self._get_missing_materials(character)
if not missing:
# All materials acquired, go to workshop
self._state = _CraftState.MOVE_TO_WORKSHOP
return self._handle_move_to_workshop(character)
# Withdraw the first missing material
code, needed_qty = next(iter(missing.items()))
# If we should gather and we can't withdraw, switch to gather mode
if self._gather_materials:
# We'll try to withdraw; if it fails the runner will handle the error
# and we can switch to gathering mode. For now, attempt the withdraw.
pass
return ActionPlan(
ActionType.WITHDRAW_ITEM,
params={"code": code, "quantity": needed_qty},
reason=f"Withdrawing {needed_qty}x {code} for crafting {self._item_code}",
)
def _handle_gather_materials(self, character: CharacterSchema) -> ActionPlan:
"""Gather missing materials (if gather_materials is enabled)."""
if self._gather_resource_code is None or self._gather_pos is None:
# Cannot determine what to gather, fall back to check
self._state = _CraftState.CHECK_MATERIALS
return self._handle_check_materials(character)
gx, gy = self._gather_pos
if not self._is_at(character, gx, gy):
return ActionPlan(
ActionType.MOVE,
params={"x": gx, "y": gy},
reason=f"Moving to resource {self._gather_resource_code} at ({gx}, {gy})",
)
# Check if inventory is full
if self._inventory_free_slots(character) == 0:
# Need to deposit and try again
self._state = _CraftState.MOVE_TO_BANK_DEPOSIT
return self._handle_move_to_bank_deposit(character)
# Check if we still need materials
missing = self._get_missing_materials(character)
if not missing:
self._state = _CraftState.MOVE_TO_WORKSHOP
return self._handle_move_to_workshop(character)
return ActionPlan(
ActionType.GATHER,
reason=f"Gathering {self._gather_resource_code} for crafting materials",
)
def _handle_move_to_workshop(self, character: CharacterSchema) -> ActionPlan:
if self._workshop_pos is None:
return ActionPlan(
ActionType.IDLE,
reason=f"No workshop found for skill {self._craft_skill}",
)
wx, wy = self._workshop_pos
if self._is_at(character, wx, wy):
self._state = _CraftState.CRAFT
return self._handle_craft(character)
self._state = _CraftState.CRAFT
return ActionPlan(
ActionType.MOVE,
params={"x": wx, "y": wy},
reason=f"Moving to {self._craft_skill} workshop at ({wx}, {wy})",
)
def _handle_craft(self, character: CharacterSchema) -> ActionPlan:
# Verify we have materials before crafting
missing = self._get_missing_materials(character)
if missing:
# Somehow lost materials, go back to check
self._state = _CraftState.CHECK_MATERIALS
return self._handle_check_materials(character)
self._state = _CraftState.CHECK_RESULT
return ActionPlan(
ActionType.CRAFT,
params={"code": self._item_code, "quantity": 1},
reason=f"Crafting {self._item_code} ({self._crafted_count + 1}/{self._quantity})",
)
def _handle_check_result(self, character: CharacterSchema) -> ActionPlan:
self._crafted_count += 1
if self._recycle_excess:
# Check if we have the item to recycle
has_item = any(
slot.code == self._item_code for slot in character.inventory
)
if has_item:
# Recycle and go back to check materials for next craft
self._state = _CraftState.CHECK_MATERIALS
return ActionPlan(
ActionType.RECYCLE,
params={"code": self._item_code, "quantity": 1},
reason=f"Recycling {self._item_code} for XP (crafted {self._crafted_count})",
)
# Check if we need to craft more
if self._crafted_count >= self._quantity:
# Done crafting, deposit results
self._state = _CraftState.MOVE_TO_BANK_DEPOSIT
return self._handle_move_to_bank_deposit(character)
# Check if inventory is getting full
if self._inventory_free_slots(character) <= 2:
self._state = _CraftState.MOVE_TO_BANK_DEPOSIT
return self._handle_move_to_bank_deposit(character)
# Craft more
self._state = _CraftState.CHECK_MATERIALS
return self._handle_check_materials(character)
def _handle_move_to_bank_deposit(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 = _CraftState.DEPOSIT
return self._handle_deposit(character)
self._state = _CraftState.DEPOSIT
return ActionPlan(
ActionType.MOVE,
params={"x": bx, "y": by},
reason=f"Moving to bank at ({bx}, {by}) to deposit crafted items",
)
def _handle_deposit(self, character: CharacterSchema) -> ActionPlan:
# Deposit the first non-empty inventory slot
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}",
)
# All deposited
if self._crafted_count >= self._quantity and not self._recycle_excess:
return ActionPlan(
ActionType.COMPLETE,
reason=f"Crafted and deposited {self._crafted_count}/{self._quantity} {self._item_code}",
)
# More to craft
self._state = _CraftState.CHECK_MATERIALS
return self._handle_check_materials(character)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _resolve_recipe(self, items_data: list[ItemSchema]) -> None:
"""Look up the item's crafting recipe from game data."""
for item in items_data:
if item.code == self._item_code:
if item.craft is None:
logger.warning(
"Item %s has no crafting recipe", self._item_code
)
return
self._craft_skill = item.craft.skill or ""
self._craft_level = item.craft.level or 0
self._recipe = [
{"code": ci.code, "quantity": ci.quantity}
for ci in item.craft.items
]
self._recipe_resolved = True
logger.info(
"Resolved recipe for %s: skill=%s, level=%d, materials=%s",
self._item_code,
self._craft_skill,
self._craft_level,
self._recipe,
)
return
logger.warning("Item %s not found in game data", self._item_code)
def _get_missing_materials(self, character: CharacterSchema) -> dict[str, int]:
"""Return a dict of {material_code: needed_quantity} for materials
not currently in the character's inventory."""
inventory_counts: dict[str, int] = {}
for slot in character.inventory:
inventory_counts[slot.code] = inventory_counts.get(slot.code, 0) + slot.quantity
missing: dict[str, int] = {}
for mat in self._recipe:
code = str(mat["code"])
needed = int(mat["quantity"])
have = inventory_counts.get(code, 0)
if have < needed:
missing[code] = needed - have
return missing
def _resolve_locations(self, character: CharacterSchema) -> None:
"""Lazily resolve and cache workshop and bank tile positions."""
if self._workshop_pos is None and self._craft_skill:
workshop_code = _SKILL_TO_WORKSHOP.get(self._craft_skill, self._craft_skill)
self._workshop_pos = self.pathfinder.find_nearest(
character.x, character.y, "workshop", workshop_code
)
if self._workshop_pos:
logger.info(
"Resolved workshop for %s at %s",
self._craft_skill,
self._workshop_pos,
)
if self._bank_pos is None:
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)
if (
self._gather_materials
and self._gather_resource_code is not None
and self._gather_pos is None
):
self._gather_pos = self.pathfinder.find_nearest(
character.x, character.y, "resource", self._gather_resource_code
)
if self._gather_pos:
logger.info(
"Resolved gather resource %s at %s",
self._gather_resource_code,
self._gather_pos,
)

View file

@ -0,0 +1,202 @@
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
logger = logging.getLogger(__name__)
class _GatherState(str, Enum):
"""Internal state machine states for the gathering loop."""
MOVE_TO_RESOURCE = "move_to_resource"
GATHER = "gather"
CHECK_INVENTORY = "check_inventory"
MOVE_TO_BANK = "move_to_bank"
DEPOSIT = "deposit"
class GatheringStrategy(BaseStrategy):
"""Automated gathering strategy.
State machine flow::
MOVE_TO_RESOURCE -> GATHER -> CHECK_INVENTORY
|
(full?) -> MOVE_TO_BANK -> DEPOSIT -> MOVE_TO_RESOURCE
(ok?) -> MOVE_TO_RESOURCE (loop)
|
(max_loops reached?) -> COMPLETE
Configuration keys (see :class:`~app.schemas.automation.GatheringConfig`):
- resource_code: str
- deposit_on_full: bool (default True)
- max_loops: int (default 0 = infinite)
"""
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
super().__init__(config, pathfinder)
self._state = _GatherState.MOVE_TO_RESOURCE
# Parsed config with defaults
self._resource_code: str = config["resource_code"]
self._deposit_on_full: bool = config.get("deposit_on_full", True)
self._max_loops: int = config.get("max_loops", 0)
# Runtime counters
self._loop_count: int = 0
# Cached locations (resolved lazily)
self._resource_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:
# Check loop limit
if self._max_loops > 0 and self._loop_count >= self._max_loops:
return ActionPlan(
ActionType.COMPLETE,
reason=f"Completed {self._loop_count}/{self._max_loops} gather-deposit cycles",
)
# Lazily resolve tile positions
self._resolve_locations(character)
match self._state:
case _GatherState.MOVE_TO_RESOURCE:
return self._handle_move_to_resource(character)
case _GatherState.GATHER:
return self._handle_gather(character)
case _GatherState.CHECK_INVENTORY:
return self._handle_check_inventory(character)
case _GatherState.MOVE_TO_BANK:
return self._handle_move_to_bank(character)
case _GatherState.DEPOSIT:
return self._handle_deposit(character)
case _:
return ActionPlan(ActionType.IDLE, reason="Unknown state")
# ------------------------------------------------------------------
# State handlers
# ------------------------------------------------------------------
def _handle_move_to_resource(self, character: CharacterSchema) -> ActionPlan:
if self._resource_pos is None:
return ActionPlan(
ActionType.IDLE,
reason=f"No map tile found for resource {self._resource_code}",
)
rx, ry = self._resource_pos
# Already at the resource tile
if self._is_at(character, rx, ry):
self._state = _GatherState.GATHER
return self._handle_gather(character)
self._state = _GatherState.GATHER # transition after move
return ActionPlan(
ActionType.MOVE,
params={"x": rx, "y": ry},
reason=f"Moving to resource {self._resource_code} at ({rx}, {ry})",
)
def _handle_gather(self, character: CharacterSchema) -> ActionPlan:
# Before gathering, check if inventory is full
if self._inventory_free_slots(character) == 0:
self._state = _GatherState.CHECK_INVENTORY
return self._handle_check_inventory(character)
self._state = _GatherState.CHECK_INVENTORY # after gather we check inventory
return ActionPlan(
ActionType.GATHER,
reason=f"Gathering {self._resource_code}",
)
def _handle_check_inventory(self, character: CharacterSchema) -> ActionPlan:
free_slots = self._inventory_free_slots(character)
if free_slots == 0 and self._deposit_on_full:
self._state = _GatherState.MOVE_TO_BANK
return self._handle_move_to_bank(character)
if free_slots == 0 and not self._deposit_on_full:
# Inventory full and not depositing -- complete the automation
return ActionPlan(
ActionType.COMPLETE,
reason="Inventory full and deposit_on_full is disabled",
)
# Inventory has space, go gather more
self._state = _GatherState.MOVE_TO_RESOURCE
return self._handle_move_to_resource(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 = _GatherState.DEPOSIT
return self._handle_deposit(character)
self._state = _GatherState.DEPOSIT # transition after move
return ActionPlan(
ActionType.MOVE,
params={"x": bx, "y": by},
reason=f"Moving to bank at ({bx}, {by}) to deposit items",
)
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 -- count the loop and go back to resource
self._loop_count += 1
logger.info(
"Gather-deposit cycle %d completed for %s",
self._loop_count,
self._resource_code,
)
self._state = _GatherState.MOVE_TO_RESOURCE
return self._handle_move_to_resource(character)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _resolve_locations(self, character: CharacterSchema) -> None:
"""Lazily resolve and cache resource / bank tile positions."""
if self._resource_pos is None:
self._resource_pos = self.pathfinder.find_nearest(
character.x, character.y, "resource", self._resource_code
)
if self._resource_pos:
logger.info(
"Resolved resource %s at %s",
self._resource_code,
self._resource_pos,
)
if self._bank_pos is None and self._deposit_on_full:
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)

View file

@ -0,0 +1,371 @@
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, "")

View file

@ -0,0 +1,425 @@
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
logger = logging.getLogger(__name__)
class _TaskState(str, Enum):
"""Internal state machine states for the task loop."""
MOVE_TO_TASKMASTER = "move_to_taskmaster"
ACCEPT_TASK = "accept_task"
EVALUATE_TASK = "evaluate_task"
DO_REQUIREMENTS = "do_requirements"
MOVE_TO_TASK_TARGET = "move_to_task_target"
EXECUTE_TASK_ACTION = "execute_task_action"
CHECK_TASK_PROGRESS = "check_task_progress"
CHECK_HEALTH = "check_health"
HEAL = "heal"
MOVE_TO_BANK = "move_to_bank"
DEPOSIT = "deposit"
MOVE_TO_TASKMASTER_TRADE = "move_to_taskmaster_trade"
TRADE_ITEMS = "trade_items"
COMPLETE_TASK = "complete_task"
EXCHANGE_COINS = "exchange_coins"
class TaskStrategy(BaseStrategy):
"""Automated task completion strategy.
State machine flow::
MOVE_TO_TASKMASTER -> ACCEPT_TASK -> EVALUATE_TASK
|
-> DO_REQUIREMENTS -> MOVE_TO_TASK_TARGET
-> EXECUTE_TASK_ACTION
-> CHECK_TASK_PROGRESS
|
(done?) -> MOVE_TO_TASKMASTER_TRADE
-> TRADE_ITEMS
-> COMPLETE_TASK
-> EXCHANGE_COINS
-> (loop to ACCEPT_TASK)
(not done?) -> EXECUTE_TASK_ACTION (loop)
Configuration keys (see :class:`~app.schemas.automation.TaskConfig`):
- max_tasks: int (default 0 = infinite) -- max tasks to complete
- auto_exchange: bool (default True) -- exchange task coins automatically
- task_type: str (default "") -- preferred task type filter (empty = any)
"""
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
super().__init__(config, pathfinder)
self._state = _TaskState.MOVE_TO_TASKMASTER
# Config
self._max_tasks: int = config.get("max_tasks", 0)
self._auto_exchange: bool = config.get("auto_exchange", True)
self._task_type_filter: str = config.get("task_type", "")
# Runtime state
self._tasks_completed: int = 0
self._current_task_code: str = ""
self._current_task_type: str = ""
self._current_task_total: int = 0
# Cached positions
self._taskmaster_pos: tuple[int, int] | None = None
self._task_target_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:
# Check if we've completed enough tasks
if self._max_tasks > 0 and self._tasks_completed >= self._max_tasks:
return ActionPlan(
ActionType.COMPLETE,
reason=f"Completed {self._tasks_completed}/{self._max_tasks} tasks",
)
self._resolve_locations(character)
match self._state:
case _TaskState.MOVE_TO_TASKMASTER:
return self._handle_move_to_taskmaster(character)
case _TaskState.ACCEPT_TASK:
return self._handle_accept_task(character)
case _TaskState.EVALUATE_TASK:
return self._handle_evaluate_task(character)
case _TaskState.DO_REQUIREMENTS:
return self._handle_do_requirements(character)
case _TaskState.MOVE_TO_TASK_TARGET:
return self._handle_move_to_task_target(character)
case _TaskState.EXECUTE_TASK_ACTION:
return self._handle_execute_task_action(character)
case _TaskState.CHECK_TASK_PROGRESS:
return self._handle_check_task_progress(character)
case _TaskState.CHECK_HEALTH:
return self._handle_check_health(character)
case _TaskState.HEAL:
return self._handle_heal(character)
case _TaskState.MOVE_TO_BANK:
return self._handle_move_to_bank(character)
case _TaskState.DEPOSIT:
return self._handle_deposit(character)
case _TaskState.MOVE_TO_TASKMASTER_TRADE:
return self._handle_move_to_taskmaster_trade(character)
case _TaskState.TRADE_ITEMS:
return self._handle_trade_items(character)
case _TaskState.COMPLETE_TASK:
return self._handle_complete_task(character)
case _TaskState.EXCHANGE_COINS:
return self._handle_exchange_coins(character)
case _:
return ActionPlan(ActionType.IDLE, reason="Unknown task state")
# ------------------------------------------------------------------
# State handlers
# ------------------------------------------------------------------
def _handle_move_to_taskmaster(self, character: CharacterSchema) -> ActionPlan:
# If character already has a task, skip to evaluating it
if character.task and character.task_type:
self._current_task_code = character.task
self._current_task_type = character.task_type
self._current_task_total = character.task_total
self._state = _TaskState.EVALUATE_TASK
return self._handle_evaluate_task(character)
if self._taskmaster_pos is None:
return ActionPlan(
ActionType.IDLE,
reason="No task master NPC found on map",
)
tx, ty = self._taskmaster_pos
if self._is_at(character, tx, ty):
self._state = _TaskState.ACCEPT_TASK
return self._handle_accept_task(character)
self._state = _TaskState.ACCEPT_TASK
return ActionPlan(
ActionType.MOVE,
params={"x": tx, "y": ty},
reason=f"Moving to task master at ({tx}, {ty})",
)
def _handle_accept_task(self, character: CharacterSchema) -> ActionPlan:
# If already has a task, evaluate it
if character.task and character.task_type:
self._current_task_code = character.task
self._current_task_type = character.task_type
self._current_task_total = character.task_total
self._state = _TaskState.EVALUATE_TASK
return self._handle_evaluate_task(character)
# Accept a new task (the API call is task_new)
self._state = _TaskState.EVALUATE_TASK
return ActionPlan(
ActionType.TASK_NEW,
reason="Accepting new task from task master",
)
def _handle_evaluate_task(self, character: CharacterSchema) -> ActionPlan:
"""Evaluate the current task and determine where to go."""
self._current_task_code = character.task
self._current_task_type = character.task_type
self._current_task_total = character.task_total
if not self._current_task_code:
# No task assigned, go accept one
self._state = _TaskState.MOVE_TO_TASKMASTER
return self._handle_move_to_taskmaster(character)
# Check if task is already complete
if character.task_progress >= character.task_total:
self._state = _TaskState.MOVE_TO_TASKMASTER_TRADE
return self._handle_move_to_taskmaster_trade(character)
# Determine target location based on task type
self._resolve_task_target(character)
self._state = _TaskState.MOVE_TO_TASK_TARGET
return self._handle_move_to_task_target(character)
def _handle_do_requirements(self, character: CharacterSchema) -> ActionPlan:
# Redirect to move to task target
self._state = _TaskState.MOVE_TO_TASK_TARGET
return self._handle_move_to_task_target(character)
def _handle_move_to_task_target(self, character: CharacterSchema) -> ActionPlan:
if self._task_target_pos is None:
return ActionPlan(
ActionType.IDLE,
reason=f"No target found for task {self._current_task_code} (type={self._current_task_type})",
)
tx, ty = self._task_target_pos
if self._is_at(character, tx, ty):
self._state = _TaskState.EXECUTE_TASK_ACTION
return self._handle_execute_task_action(character)
self._state = _TaskState.EXECUTE_TASK_ACTION
return ActionPlan(
ActionType.MOVE,
params={"x": tx, "y": ty},
reason=f"Moving to task target at ({tx}, {ty}) for {self._current_task_code}",
)
def _handle_execute_task_action(self, character: CharacterSchema) -> ActionPlan:
"""Execute the appropriate action for the current task type."""
task_type = self._current_task_type.lower()
if task_type == "monsters":
# Check health before fighting
if self._hp_percent(character) < 50:
self._state = _TaskState.CHECK_HEALTH
return self._handle_check_health(character)
self._state = _TaskState.CHECK_TASK_PROGRESS
return ActionPlan(
ActionType.FIGHT,
reason=f"Fighting for task: {self._current_task_code}",
)
if task_type in ("resources", "items"):
# Check inventory
if self._inventory_free_slots(character) == 0:
self._state = _TaskState.MOVE_TO_BANK
return self._handle_move_to_bank(character)
self._state = _TaskState.CHECK_TASK_PROGRESS
return ActionPlan(
ActionType.GATHER,
reason=f"Gathering for task: {self._current_task_code}",
)
# Unknown task type, try to fight as default
self._state = _TaskState.CHECK_TASK_PROGRESS
return ActionPlan(
ActionType.FIGHT,
reason=f"Executing task action for {self._current_task_code} (type={task_type})",
)
def _handle_check_task_progress(self, character: CharacterSchema) -> ActionPlan:
"""Check if the task requirements are met."""
if character.task_progress >= character.task_total:
# Task requirements met, go trade
self._state = _TaskState.MOVE_TO_TASKMASTER_TRADE
return self._handle_move_to_taskmaster_trade(character)
# Check inventory for deposit needs
if self._inventory_free_slots(character) <= 1:
self._state = _TaskState.MOVE_TO_BANK
return self._handle_move_to_bank(character)
# Continue the task action
self._state = _TaskState.EXECUTE_TASK_ACTION
return self._handle_execute_task_action(character)
def _handle_check_health(self, character: CharacterSchema) -> ActionPlan:
if self._hp_percent(character) >= 50:
self._state = _TaskState.EXECUTE_TASK_ACTION
return self._handle_execute_task_action(character)
self._state = _TaskState.HEAL
return self._handle_heal(character)
def _handle_heal(self, character: CharacterSchema) -> ActionPlan:
if self._hp_percent(character) >= 100.0:
self._state = _TaskState.EXECUTE_TASK_ACTION
return self._handle_execute_task_action(character)
self._state = _TaskState.CHECK_HEALTH
return ActionPlan(
ActionType.REST,
reason=f"Resting to heal during task (HP {character.hp}/{character.max_hp})",
)
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 = _TaskState.DEPOSIT
return self._handle_deposit(character)
self._state = _TaskState.DEPOSIT
return ActionPlan(
ActionType.MOVE,
params={"x": bx, "y": by},
reason=f"Moving to bank at ({bx}, {by}) to deposit during task",
)
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}",
)
# All deposited, go back to task
self._state = _TaskState.MOVE_TO_TASK_TARGET
return self._handle_move_to_task_target(character)
def _handle_move_to_taskmaster_trade(self, character: CharacterSchema) -> ActionPlan:
if self._taskmaster_pos is None:
return ActionPlan(ActionType.IDLE, reason="No task master found")
tx, ty = self._taskmaster_pos
if self._is_at(character, tx, ty):
self._state = _TaskState.TRADE_ITEMS
return self._handle_trade_items(character)
self._state = _TaskState.TRADE_ITEMS
return ActionPlan(
ActionType.MOVE,
params={"x": tx, "y": ty},
reason=f"Moving to task master at ({tx}, {ty}) to trade items",
)
def _handle_trade_items(self, character: CharacterSchema) -> ActionPlan:
"""Trade the required items to the task master."""
# The task_trade action requires the task item code and quantity
if not self._current_task_code:
self._state = _TaskState.COMPLETE_TASK
return self._handle_complete_task(character)
self._state = _TaskState.COMPLETE_TASK
return ActionPlan(
ActionType.TASK_TRADE,
params={
"code": self._current_task_code,
"quantity": self._current_task_total,
},
reason=f"Trading {self._current_task_total}x {self._current_task_code} to task master",
)
def _handle_complete_task(self, character: CharacterSchema) -> ActionPlan:
"""Complete the task at the task master."""
self._tasks_completed += 1
if self._auto_exchange:
self._state = _TaskState.EXCHANGE_COINS
else:
self._state = _TaskState.MOVE_TO_TASKMASTER # loop for next task
return ActionPlan(
ActionType.TASK_COMPLETE,
reason=f"Completing task #{self._tasks_completed}: {self._current_task_code}",
)
def _handle_exchange_coins(self, character: CharacterSchema) -> ActionPlan:
"""Exchange task coins for rewards."""
# Reset for next task
self._current_task_code = ""
self._current_task_type = ""
self._current_task_total = 0
self._task_target_pos = None
self._state = _TaskState.MOVE_TO_TASKMASTER
return ActionPlan(
ActionType.TASK_EXCHANGE,
reason="Exchanging task coins for rewards",
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _resolve_locations(self, character: CharacterSchema) -> None:
"""Lazily resolve and cache tile positions."""
if self._taskmaster_pos is None:
# Task masters are NPCs of type "tasks_master"
self._taskmaster_pos = self.pathfinder.find_nearest_by_type(
character.x, character.y, "tasks_master"
)
if self._taskmaster_pos:
logger.info("Resolved task master at %s", self._taskmaster_pos)
if self._bank_pos is None:
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)
def _resolve_task_target(self, character: CharacterSchema) -> None:
"""Resolve the target location for the current task."""
task_type = self._current_task_type.lower()
task_code = self._current_task_code
if task_type == "monsters":
self._task_target_pos = self.pathfinder.find_nearest(
character.x, character.y, "monster", task_code
)
elif task_type in ("resources", "items"):
self._task_target_pos = self.pathfinder.find_nearest(
character.x, character.y, "resource", task_code
)
else:
# Try monster first, then resource
self._task_target_pos = self.pathfinder.find_nearest(
character.x, character.y, "monster", task_code
)
if self._task_target_pos is None:
self._task_target_pos = self.pathfinder.find_nearest(
character.x, character.y, "resource", task_code
)
if self._task_target_pos:
logger.info(
"Resolved task target %s (%s) at %s",
task_code,
task_type,
self._task_target_pos,
)

View file

@ -0,0 +1,307 @@
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
logger = logging.getLogger(__name__)
class _TradingState(str, Enum):
"""Internal state machine states for the trading loop."""
MOVE_TO_BANK = "move_to_bank"
WITHDRAW_ITEMS = "withdraw_items"
MOVE_TO_GE = "move_to_ge"
CREATE_SELL_ORDER = "create_sell_order"
CREATE_BUY_ORDER = "create_buy_order"
WAIT_FOR_ORDER = "wait_for_order"
CHECK_ORDERS = "check_orders"
COLLECT_ITEMS = "collect_items"
DEPOSIT_ITEMS = "deposit_items"
# ActionType extensions for GE operations (handled via params in the runner)
# We reuse CRAFT action type slot to send GE-specific actions; the runner
# dispatches based on action_type enum. We add new action types to base.
class _TradingMode(str, Enum):
SELL_LOOT = "sell_loot"
BUY_MATERIALS = "buy_materials"
FLIP = "flip"
class TradingStrategy(BaseStrategy):
"""Automated Grand Exchange trading strategy.
Supports three modes:
**sell_loot** -- Move to bank, withdraw items, move to GE, create sell orders.
**buy_materials** -- Move to GE, create buy orders, wait, collect.
**flip** -- Buy low, sell high based on price history margins.
Configuration keys (see :class:`~app.schemas.automation.TradingConfig`):
- mode: str ("sell_loot"|"buy_materials"|"flip")
- item_code: str
- quantity: int (default 1)
- min_price: int (default 0) -- minimum acceptable price
- max_price: int (default 0) -- maximum acceptable price (0 = no limit)
"""
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
super().__init__(config, pathfinder)
# Parse config
mode_str = config.get("mode", "sell_loot")
try:
self._mode = _TradingMode(mode_str)
except ValueError:
logger.warning("Unknown trading mode %r, defaulting to sell_loot", mode_str)
self._mode = _TradingMode.SELL_LOOT
self._item_code: str = config["item_code"]
self._quantity: int = config.get("quantity", 1)
self._min_price: int = config.get("min_price", 0)
self._max_price: int = config.get("max_price", 0)
# Determine initial state based on mode
if self._mode == _TradingMode.SELL_LOOT:
self._state = _TradingState.MOVE_TO_BANK
elif self._mode == _TradingMode.BUY_MATERIALS:
self._state = _TradingState.MOVE_TO_GE
elif self._mode == _TradingMode.FLIP:
self._state = _TradingState.MOVE_TO_GE
else:
self._state = _TradingState.MOVE_TO_GE
# Runtime state
self._items_withdrawn: int = 0
self._orders_created: bool = False
self._wait_cycles: int = 0
# Cached positions
self._bank_pos: tuple[int, int] | None = None
self._ge_pos: tuple[int, int] | None = None
def get_state(self) -> str:
return f"{self._mode.value}:{self._state.value}"
async def next_action(self, character: CharacterSchema) -> ActionPlan:
self._resolve_locations(character)
match self._state:
case _TradingState.MOVE_TO_BANK:
return self._handle_move_to_bank(character)
case _TradingState.WITHDRAW_ITEMS:
return self._handle_withdraw_items(character)
case _TradingState.MOVE_TO_GE:
return self._handle_move_to_ge(character)
case _TradingState.CREATE_SELL_ORDER:
return self._handle_create_sell_order(character)
case _TradingState.CREATE_BUY_ORDER:
return self._handle_create_buy_order(character)
case _TradingState.WAIT_FOR_ORDER:
return self._handle_wait_for_order(character)
case _TradingState.CHECK_ORDERS:
return self._handle_check_orders(character)
case _TradingState.COLLECT_ITEMS:
return self._handle_collect_items(character)
case _TradingState.DEPOSIT_ITEMS:
return self._handle_deposit_items(character)
case _:
return ActionPlan(ActionType.IDLE, reason="Unknown trading state")
# ------------------------------------------------------------------
# State handlers
# ------------------------------------------------------------------
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 = _TradingState.WITHDRAW_ITEMS
return self._handle_withdraw_items(character)
self._state = _TradingState.WITHDRAW_ITEMS
return ActionPlan(
ActionType.MOVE,
params={"x": bx, "y": by},
reason=f"Moving to bank at ({bx}, {by}) to withdraw items for sale",
)
def _handle_withdraw_items(self, character: CharacterSchema) -> ActionPlan:
# Calculate how many we still need to withdraw
remaining = self._quantity - self._items_withdrawn
if remaining <= 0:
self._state = _TradingState.MOVE_TO_GE
return self._handle_move_to_ge(character)
# Check inventory space
free = self._inventory_free_slots(character)
if free <= 0:
self._state = _TradingState.MOVE_TO_GE
return self._handle_move_to_ge(character)
withdraw_qty = min(remaining, free)
self._items_withdrawn += withdraw_qty
return ActionPlan(
ActionType.WITHDRAW_ITEM,
params={"code": self._item_code, "quantity": withdraw_qty},
reason=f"Withdrawing {withdraw_qty}x {self._item_code} for GE sale",
)
def _handle_move_to_ge(self, character: CharacterSchema) -> ActionPlan:
if self._ge_pos is None:
return ActionPlan(ActionType.IDLE, reason="No Grand Exchange tile found")
gx, gy = self._ge_pos
if self._is_at(character, gx, gy):
if self._mode == _TradingMode.SELL_LOOT:
self._state = _TradingState.CREATE_SELL_ORDER
return self._handle_create_sell_order(character)
elif self._mode == _TradingMode.BUY_MATERIALS:
self._state = _TradingState.CREATE_BUY_ORDER
return self._handle_create_buy_order(character)
elif self._mode == _TradingMode.FLIP:
if not self._orders_created:
self._state = _TradingState.CREATE_BUY_ORDER
return self._handle_create_buy_order(character)
else:
self._state = _TradingState.CREATE_SELL_ORDER
return self._handle_create_sell_order(character)
return ActionPlan(ActionType.IDLE, reason="At GE but unknown mode")
# Determine next state based on mode
if self._mode == _TradingMode.SELL_LOOT:
self._state = _TradingState.CREATE_SELL_ORDER
elif self._mode == _TradingMode.BUY_MATERIALS:
self._state = _TradingState.CREATE_BUY_ORDER
elif self._mode == _TradingMode.FLIP:
self._state = _TradingState.CREATE_BUY_ORDER
return ActionPlan(
ActionType.MOVE,
params={"x": gx, "y": gy},
reason=f"Moving to Grand Exchange at ({gx}, {gy})",
)
def _handle_create_sell_order(self, character: CharacterSchema) -> ActionPlan:
# Check if we have items to sell in inventory
item_in_inv = None
for slot in character.inventory:
if slot.code == self._item_code and slot.quantity > 0:
item_in_inv = slot
break
if item_in_inv is None:
# Nothing to sell, we're done
return ActionPlan(
ActionType.COMPLETE,
reason=f"No {self._item_code} in inventory to sell",
)
sell_price = self._min_price if self._min_price > 0 else 1
sell_qty = min(item_in_inv.quantity, self._quantity)
self._orders_created = True
self._state = _TradingState.WAIT_FOR_ORDER
return ActionPlan(
ActionType.GE_SELL,
params={
"code": self._item_code,
"quantity": sell_qty,
"price": sell_price,
},
reason=f"Creating sell order: {sell_qty}x {self._item_code} at {sell_price} gold each",
)
def _handle_create_buy_order(self, character: CharacterSchema) -> ActionPlan:
buy_price = self._max_price if self._max_price > 0 else 1
self._orders_created = True
self._state = _TradingState.WAIT_FOR_ORDER
return ActionPlan(
ActionType.GE_BUY,
params={
"code": self._item_code,
"quantity": self._quantity,
"price": buy_price,
},
reason=f"Creating buy order: {self._quantity}x {self._item_code} at {buy_price} gold each",
)
def _handle_wait_for_order(self, character: CharacterSchema) -> ActionPlan:
self._wait_cycles += 1
# Wait for a reasonable time, then check
if self._wait_cycles < 3:
return ActionPlan(
ActionType.IDLE,
reason=f"Waiting for GE order to fill (cycle {self._wait_cycles})",
)
# After waiting, check orders
self._state = _TradingState.CHECK_ORDERS
return self._handle_check_orders(character)
def _handle_check_orders(self, character: CharacterSchema) -> ActionPlan:
# For now, just complete after creating orders
# In a full implementation, we'd check the GE order status
if self._mode == _TradingMode.FLIP and self._orders_created:
# For flip mode, once buy order is done, create sell
self._state = _TradingState.CREATE_SELL_ORDER
return ActionPlan(
ActionType.IDLE,
reason="Checking order status for flip trade",
)
return ActionPlan(
ActionType.COMPLETE,
reason=f"Trading operation complete for {self._item_code} (mode={self._mode.value})",
)
def _handle_collect_items(self, character: CharacterSchema) -> ActionPlan:
# In the actual game, items from filled orders go to inventory automatically
self._state = _TradingState.DEPOSIT_ITEMS
return self._handle_deposit_items(character)
def _handle_deposit_items(self, character: CharacterSchema) -> ActionPlan:
# Deposit any items in inventory
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} from trading",
)
return ActionPlan(
ActionType.COMPLETE,
reason=f"Trading complete for {self._item_code}",
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _resolve_locations(self, character: CharacterSchema) -> None:
"""Lazily resolve and cache bank and GE tile positions."""
if self._bank_pos is None:
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)
if self._ge_pos is None:
self._ge_pos = self.pathfinder.find_nearest_by_type(
character.x, character.y, "grand_exchange"
)
if self._ge_pos:
logger.info("Resolved Grand Exchange at %s", self._ge_pos)

242
backend/app/main.py Normal file
View file

@ -0,0 +1,242 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import async_session_factory, engine, Base
from app.services.artifacts_client import ArtifactsClient
from app.services.character_service import CharacterService
from app.services.game_data_cache import GameDataCacheService
# Import models so they are registered on Base.metadata
from app.models import game_cache as _game_cache_model # noqa: F401
from app.models import character_snapshot as _snapshot_model # noqa: F401
from app.models import automation as _automation_model # noqa: F401
from app.models import price_history as _price_history_model # noqa: F401
from app.models import event_log as _event_log_model # noqa: F401
# Import routers
from app.api.characters import router as characters_router
from app.api.game_data import router as game_data_router
from app.api.dashboard import router as dashboard_router
from app.api.bank import router as bank_router
from app.api.automations import router as automations_router
from app.api.ws import router as ws_router
from app.api.exchange import router as exchange_router
from app.api.events import router as events_router
from app.api.logs import router as logs_router
# Automation engine
from app.engine.pathfinder import Pathfinder
from app.engine.manager import AutomationManager
# Exchange service
from app.services.exchange_service import ExchangeService
# WebSocket system
from app.websocket.event_bus import EventBus
from app.websocket.client import GameWebSocketClient
from app.websocket.handlers import GameEventHandler
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
async def _snapshot_loop(
db_factory: async_session_factory.__class__,
client: ArtifactsClient,
character_service: CharacterService,
interval: float = 60.0,
) -> None:
"""Periodically save character snapshots."""
while True:
try:
async with db_factory() as db:
await character_service.take_snapshot(db, client)
except asyncio.CancelledError:
logger.info("Character snapshot loop cancelled")
return
except Exception:
logger.exception("Error taking character snapshot")
await asyncio.sleep(interval)
async def _load_pathfinder_maps(
pathfinder: Pathfinder,
cache_service: GameDataCacheService,
) -> None:
"""Load map data from the game data cache into the pathfinder.
Retries with a short delay if the cache has not been populated yet
(e.g. the background refresh has not completed its first pass).
"""
max_attempts = 10
for attempt in range(1, max_attempts + 1):
try:
async with async_session_factory() as db:
maps = await cache_service.get_maps(db)
if maps:
pathfinder.load_maps(maps)
logger.info("Pathfinder loaded %d map tiles", len(maps))
return
logger.info(
"Map cache empty, retrying (%d/%d)",
attempt,
max_attempts,
)
except Exception:
logger.exception(
"Error loading maps into pathfinder (attempt %d/%d)",
attempt,
max_attempts,
)
await asyncio.sleep(5)
logger.warning(
"Pathfinder could not load maps after %d attempts; "
"automations that depend on pathfinding will not work until maps are cached",
max_attempts,
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# --- Startup ---
# Create tables if they do not exist (useful for dev; in prod rely on Alembic)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Instantiate shared services
client = ArtifactsClient()
cache_service = GameDataCacheService()
character_service = CharacterService()
# Event bus for internal pub/sub
event_bus = EventBus()
exchange_service = ExchangeService()
app.state.artifacts_client = client
app.state.cache_service = cache_service
app.state.character_service = character_service
app.state.event_bus = event_bus
app.state.exchange_service = exchange_service
# Start background cache refresh (runs immediately, then every 30 min)
cache_task = cache_service.start_background_refresh(
db_factory=async_session_factory,
client=client,
)
# Start periodic character snapshot (every 60 seconds)
snapshot_task = asyncio.create_task(
_snapshot_loop(async_session_factory, client, character_service)
)
# --- Automation engine ---
# Initialize pathfinder and load maps (runs in a background task so it
# does not block startup if the cache has not been populated yet)
pathfinder = Pathfinder()
pathfinder_task = asyncio.create_task(
_load_pathfinder_maps(pathfinder, cache_service)
)
# Create the automation manager and expose it on app.state
automation_manager = AutomationManager(
client=client,
db_factory=async_session_factory,
pathfinder=pathfinder,
event_bus=event_bus,
)
app.state.automation_manager = automation_manager
# --- Price capture background task ---
price_capture_task = exchange_service.start_price_capture(
db_factory=async_session_factory,
client=client,
)
# --- WebSocket system ---
# Game WebSocket client (connects to the Artifacts game server)
game_ws_client = GameWebSocketClient(
token=settings.artifacts_token,
event_bus=event_bus,
)
game_ws_task = await game_ws_client.start()
app.state.game_ws_client = game_ws_client
# Event handler (processes game events from the bus)
game_event_handler = GameEventHandler(event_bus=event_bus)
event_handler_task = await game_event_handler.start()
logger.info("Artifacts Dashboard API started")
yield
# --- Shutdown ---
logger.info("Shutting down background tasks")
# Stop all running automations gracefully
await automation_manager.stop_all()
# Stop WebSocket system
await game_event_handler.stop()
await game_ws_client.stop()
cache_service.stop_background_refresh()
exchange_service.stop_price_capture()
snapshot_task.cancel()
pathfinder_task.cancel()
# Wait for tasks to finish cleanly
for task in (cache_task, snapshot_task, pathfinder_task, price_capture_task, game_ws_task, event_handler_task):
try:
await task
except asyncio.CancelledError:
pass
await client.close()
await engine.dispose()
logger.info("Artifacts Dashboard API stopped")
app = FastAPI(
title="Artifacts Dashboard API",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
app.include_router(characters_router)
app.include_router(game_data_router)
app.include_router(dashboard_router)
app.include_router(bank_router)
app.include_router(automations_router)
app.include_router(ws_router)
app.include_router(exchange_router)
app.include_router(events_router)
app.include_router(logs_router)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

View file

@ -0,0 +1,15 @@
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
from app.models.character_snapshot import CharacterSnapshot
from app.models.event_log import EventLog
from app.models.game_cache import GameDataCache
from app.models.price_history import PriceHistory
__all__ = [
"AutomationConfig",
"AutomationLog",
"AutomationRun",
"CharacterSnapshot",
"EventLog",
"GameDataCache",
"PriceHistory",
]

View file

@ -0,0 +1,108 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class AutomationConfig(Base):
__tablename__ = "automation_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
character_name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
strategy_type: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="Strategy type: combat, gathering, crafting, trading, task, leveling",
)
config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
enabled: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
runs: Mapped[list["AutomationRun"]] = relationship(
back_populates="config",
cascade="all, delete-orphan",
order_by="AutomationRun.started_at.desc()",
)
def __repr__(self) -> str:
return (
f"<AutomationConfig(id={self.id}, name={self.name!r}, "
f"character={self.character_name!r}, strategy={self.strategy_type!r})>"
)
class AutomationRun(Base):
__tablename__ = "automation_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
config_id: Mapped[int] = mapped_column(
Integer, ForeignKey("automation_configs.id", ondelete="CASCADE"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="running",
comment="Status: running, paused, stopped, completed, error",
)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
actions_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
config: Mapped["AutomationConfig"] = relationship(back_populates="runs")
logs: Mapped[list["AutomationLog"]] = relationship(
back_populates="run",
cascade="all, delete-orphan",
order_by="AutomationLog.created_at.desc()",
)
def __repr__(self) -> str:
return (
f"<AutomationRun(id={self.id}, config_id={self.config_id}, "
f"status={self.status!r})>"
)
class AutomationLog(Base):
__tablename__ = "automation_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
run_id: Mapped[int] = mapped_column(
Integer, ForeignKey("automation_runs.id", ondelete="CASCADE"), nullable=False, index=True
)
action_type: Mapped[str] = mapped_column(String(50), nullable=False)
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
success: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
run: Mapped["AutomationRun"] = relationship(back_populates="logs")
def __repr__(self) -> str:
return (
f"<AutomationLog(id={self.id}, run_id={self.run_id}, "
f"action={self.action_type!r}, success={self.success})>"
)

View file

@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class CharacterSnapshot(Base):
__tablename__ = "character_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
data: Mapped[dict] = mapped_column(JSON, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
def __repr__(self) -> str:
return f"<CharacterSnapshot(id={self.id}, name={self.name!r})>"

View file

@ -0,0 +1,54 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class EventLog(Base):
"""Logged game events for historical tracking and analytics."""
__tablename__ = "event_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
event_type: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
comment="Type of event (e.g. 'combat', 'gathering', 'trade', 'level_up')",
)
event_data: Mapped[dict] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="Arbitrary JSON payload with event details",
)
character_name: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
index=True,
comment="Character associated with the event (if applicable)",
)
map_x: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
comment="X coordinate where the event occurred",
)
map_y: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
comment="Y coordinate where the event occurred",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
index=True,
)
def __repr__(self) -> str:
return (
f"<EventLog(id={self.id}, type={self.event_type!r}, "
f"character={self.character_name!r})>"
)

View file

@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class GameDataCache(Base):
__tablename__ = "game_data_cache"
__table_args__ = (
UniqueConstraint("data_type", name="uq_game_data_cache_data_type"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
data_type: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="Type of cached data: items, monsters, resources, maps, events, "
"achievements, npcs, tasks, effects, badges",
)
data: Mapped[dict] = mapped_column(JSON, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str:
return f"<GameDataCache(id={self.id}, data_type={self.data_type!r})>"

View file

@ -0,0 +1,49 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class PriceHistory(Base):
"""Captured Grand Exchange price snapshots over time."""
__tablename__ = "price_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
item_code: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
comment="Item code from the Artifacts API",
)
buy_price: Mapped[float | None] = mapped_column(
Float,
nullable=True,
comment="Best buy price at capture time",
)
sell_price: Mapped[float | None] = mapped_column(
Float,
nullable=True,
comment="Best sell price at capture time",
)
volume: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="Trade volume at capture time",
)
captured_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
index=True,
comment="Timestamp when the price was captured",
)
def __repr__(self) -> str:
return (
f"<PriceHistory(id={self.id}, item={self.item_code!r}, "
f"buy={self.buy_price}, sell={self.sell_price})>"
)

View file

View file

@ -0,0 +1,214 @@
from datetime import datetime
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Strategy-specific config schemas
# ---------------------------------------------------------------------------
class CombatConfig(BaseModel):
"""Configuration for the combat automation strategy."""
monster_code: str = Field(..., description="Code of the monster to fight")
auto_heal_threshold: int = Field(
default=50,
ge=0,
le=100,
description="Heal when HP drops below this percentage",
)
heal_method: str = Field(
default="rest",
description="Healing method: 'rest' or 'consumable'",
)
consumable_code: str | None = Field(
default=None,
description="Item code of the consumable to use for healing (required when heal_method='consumable')",
)
min_inventory_slots: int = Field(
default=3,
ge=0,
description="Deposit loot when free inventory slots drops to this number",
)
deposit_loot: bool = Field(
default=True,
description="Whether to automatically deposit loot at the bank",
)
class GatheringConfig(BaseModel):
"""Configuration for the gathering automation strategy."""
resource_code: str = Field(..., description="Code of the resource to gather")
deposit_on_full: bool = Field(
default=True,
description="Whether to deposit items at bank when inventory is full",
)
max_loops: int = Field(
default=0,
ge=0,
description="Maximum gather-deposit cycles (0 = infinite)",
)
class CraftingConfig(BaseModel):
"""Configuration for the crafting automation strategy."""
item_code: str = Field(..., description="Code of the item to craft")
quantity: int = Field(
default=1,
ge=1,
description="How many items to craft in total",
)
gather_materials: bool = Field(
default=False,
description="If True, automatically gather missing materials",
)
recycle_excess: bool = Field(
default=False,
description="If True, recycle crafted items for XP grinding",
)
class TradingConfig(BaseModel):
"""Configuration for the trading (Grand Exchange) automation strategy."""
mode: str = Field(
default="sell_loot",
description="Trading mode: 'sell_loot', 'buy_materials', or 'flip'",
)
item_code: str = Field(..., description="Code of the item to trade")
quantity: int = Field(
default=1,
ge=1,
description="Quantity to trade",
)
min_price: int = Field(
default=0,
ge=0,
description="Minimum acceptable price (for selling)",
)
max_price: int = Field(
default=0,
ge=0,
description="Maximum acceptable price (for buying, 0 = no limit)",
)
class TaskConfig(BaseModel):
"""Configuration for the task automation strategy."""
max_tasks: int = Field(
default=0,
ge=0,
description="Maximum tasks to complete (0 = infinite)",
)
auto_exchange: bool = Field(
default=True,
description="Automatically exchange task coins for rewards",
)
task_type: str = Field(
default="",
description="Preferred task type filter (empty = accept any)",
)
class LevelingConfig(BaseModel):
"""Configuration for the leveling automation strategy."""
target_skill: str = Field(
default="",
description="Specific skill to level (empty = auto-pick lowest skill)",
)
min_level: int = Field(
default=0,
ge=0,
description="Minimum level threshold",
)
max_level: int = Field(
default=0,
ge=0,
description="Stop when skill reaches this level (0 = no limit)",
)
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class AutomationConfigCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
character_name: str = Field(..., min_length=1, max_length=100)
strategy_type: str = Field(
...,
description="Strategy type: combat, gathering, crafting, trading, task, leveling",
)
config: dict = Field(default_factory=dict)
class AutomationConfigUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
config: dict | None = None
enabled: bool | None = None
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class AutomationConfigResponse(BaseModel):
id: int
name: str
character_name: str
strategy_type: str
config: dict
enabled: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class AutomationRunResponse(BaseModel):
id: int
config_id: int
status: str
started_at: datetime
stopped_at: datetime | None = None
actions_count: int
error_message: str | None = None
model_config = {"from_attributes": True}
class AutomationLogResponse(BaseModel):
id: int
run_id: int
action_type: str
details: dict
success: bool
created_at: datetime
model_config = {"from_attributes": True}
class AutomationStatusResponse(BaseModel):
config_id: int
character_name: str
strategy_type: str
status: str
run_id: int | None = None
actions_count: int = 0
latest_logs: list[AutomationLogResponse] = Field(default_factory=list)
model_config = {"from_attributes": True}
class AutomationConfigDetailResponse(BaseModel):
"""Config with its run history."""
config: AutomationConfigResponse
runs: list[AutomationRunResponse] = Field(default_factory=list)

View file

@ -0,0 +1,51 @@
"""Pydantic schemas for Grand Exchange responses."""
from datetime import datetime
from pydantic import BaseModel, Field
class OrderResponse(BaseModel):
"""A single GE order."""
id: str = ""
code: str = ""
quantity: int = 0
price: int = 0
order: str = Field(default="", description="Order type: 'buy' or 'sell'")
created_at: datetime | None = None
model_config = {"from_attributes": True}
class PriceHistoryResponse(BaseModel):
"""A single price history entry."""
id: int
item_code: str
buy_price: float | None = None
sell_price: float | None = None
volume: int = 0
captured_at: datetime | None = None
model_config = {"from_attributes": True}
class PriceHistoryListResponse(BaseModel):
"""Response for price history queries."""
item_code: str
days: int
entries: list[PriceHistoryResponse] = Field(default_factory=list)
class ExchangeOrdersResponse(BaseModel):
"""Response wrapping a list of GE orders."""
orders: list[dict] = Field(default_factory=list)
class ExchangeHistoryResponse(BaseModel):
"""Response wrapping GE transaction history."""
history: list[dict] = Field(default_factory=list)

205
backend/app/schemas/game.py Normal file
View file

@ -0,0 +1,205 @@
from datetime import datetime
from pydantic import BaseModel, Field
# --- Inventory ---
class InventorySlot(BaseModel):
slot: int
code: str
quantity: int
# --- Crafting ---
class CraftItem(BaseModel):
code: str
quantity: int
class CraftSchema(BaseModel):
skill: str | None = None
level: int | None = None
items: list[CraftItem] = Field(default_factory=list)
quantity: int | None = None
# --- Effects ---
class EffectSchema(BaseModel):
name: str = ""
value: int = 0
# --- Items ---
class ItemSchema(BaseModel):
name: str
code: str
level: int = 0
type: str = ""
subtype: str = ""
description: str = ""
effects: list[EffectSchema] = Field(default_factory=list)
craft: CraftSchema | None = None
# --- Drops ---
class DropSchema(BaseModel):
code: str
rate: int = 0
min_quantity: int = 0
max_quantity: int = 0
# --- Monsters ---
class MonsterSchema(BaseModel):
name: str
code: str
level: int = 0
hp: int = 0
attack_fire: int = 0
attack_earth: int = 0
attack_water: int = 0
attack_air: int = 0
res_fire: int = 0
res_earth: int = 0
res_water: int = 0
res_air: int = 0
min_gold: int = 0
max_gold: int = 0
drops: list[DropSchema] = Field(default_factory=list)
# --- Resources ---
class ResourceSchema(BaseModel):
name: str
code: str
skill: str = ""
level: int = 0
drops: list[DropSchema] = Field(default_factory=list)
# --- Maps ---
class ContentSchema(BaseModel):
type: str
code: str
class MapSchema(BaseModel):
name: str = ""
x: int
y: int
content: ContentSchema | None = None
# --- Characters ---
class CharacterSchema(BaseModel):
name: str
account: str = ""
skin: str = ""
level: int = 0
xp: int = 0
max_xp: int = 0
gold: int = 0
speed: int = 0
hp: int = 0
max_hp: int = 0
haste: int = 0
critical_strike: int = 0
stamina: int = 0
# Attack stats
attack_fire: int = 0
attack_earth: int = 0
attack_water: int = 0
attack_air: int = 0
# Damage stats
dmg_fire: int = 0
dmg_earth: int = 0
dmg_water: int = 0
dmg_air: int = 0
# Resistance stats
res_fire: int = 0
res_earth: int = 0
res_water: int = 0
res_air: int = 0
# Position
x: int = 0
y: int = 0
# Cooldown
cooldown: int = 0
cooldown_expiration: datetime | None = None
# Equipment slots
weapon_slot: str = ""
shield_slot: str = ""
helmet_slot: str = ""
body_armor_slot: str = ""
leg_armor_slot: str = ""
boots_slot: str = ""
ring1_slot: str = ""
ring2_slot: str = ""
amulet_slot: str = ""
artifact1_slot: str = ""
artifact2_slot: str = ""
artifact3_slot: str = ""
utility1_slot: str = ""
utility1_slot_quantity: int = 0
utility2_slot: str = ""
utility2_slot_quantity: int = 0
# Inventory
inventory_max_items: int = 0
inventory: list[InventorySlot] = Field(default_factory=list)
# Task
task: str = ""
task_type: str = ""
task_progress: int = 0
task_total: int = 0
# Skill levels and XP
mining_level: int = 0
mining_xp: int = 0
woodcutting_level: int = 0
woodcutting_xp: int = 0
fishing_level: int = 0
fishing_xp: int = 0
weaponcrafting_level: int = 0
weaponcrafting_xp: int = 0
gearcrafting_level: int = 0
gearcrafting_xp: int = 0
jewelrycrafting_level: int = 0
jewelrycrafting_xp: int = 0
cooking_level: int = 0
cooking_xp: int = 0
alchemy_level: int = 0
alchemy_xp: int = 0
# --- Dashboard ---
class DashboardData(BaseModel):
characters: list[CharacterSchema] = Field(default_factory=list)
server_status: dict | None = None

View file

View file

@ -0,0 +1,234 @@
"""Analytics service for XP history, gold tracking, and action rate calculations."""
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character_snapshot import CharacterSnapshot
logger = logging.getLogger(__name__)
class AnalyticsService:
"""Provides analytics derived from character snapshot time-series data."""
async def get_xp_history(
self,
db: AsyncSession,
character_name: str,
hours: int = 24,
) -> list[dict[str, Any]]:
"""Get XP snapshots over time for a character.
Parameters
----------
db:
Database session.
character_name:
Name of the character.
hours:
How many hours of history to return.
Returns
-------
List of dicts with timestamp and XP values for each skill.
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
stmt = (
select(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= since,
)
.order_by(CharacterSnapshot.created_at.asc())
)
result = await db.execute(stmt)
snapshots = result.scalars().all()
history: list[dict[str, Any]] = []
for snap in snapshots:
data = snap.data or {}
entry: dict[str, Any] = {
"timestamp": snap.created_at.isoformat() if snap.created_at else None,
"level": data.get("level", 0),
"xp": data.get("xp", 0),
"max_xp": data.get("max_xp", 0),
"skills": {},
}
# Extract all skill XP values
for skill in (
"mining",
"woodcutting",
"fishing",
"weaponcrafting",
"gearcrafting",
"jewelrycrafting",
"cooking",
"alchemy",
):
entry["skills"][skill] = {
"level": data.get(f"{skill}_level", 0),
"xp": data.get(f"{skill}_xp", 0),
}
history.append(entry)
return history
async def get_gold_history(
self,
db: AsyncSession,
character_name: str,
hours: int = 24,
) -> list[dict[str, Any]]:
"""Get gold snapshots over time for a character.
Parameters
----------
db:
Database session.
character_name:
Name of the character.
hours:
How many hours of history to return.
Returns
-------
List of dicts with timestamp and gold amount.
"""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
stmt = (
select(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= since,
)
.order_by(CharacterSnapshot.created_at.asc())
)
result = await db.execute(stmt)
snapshots = result.scalars().all()
return [
{
"timestamp": snap.created_at.isoformat() if snap.created_at else None,
"gold": (snap.data or {}).get("gold", 0),
}
for snap in snapshots
]
async def get_actions_per_hour(
self,
db: AsyncSession,
character_name: str,
) -> dict[str, Any]:
"""Calculate the action rate for a character based on recent snapshots.
Uses the difference between the latest and earliest snapshot in the
last hour to estimate actions per hour (approximated by XP changes).
Returns
-------
Dict with "character_name", "period_hours", "xp_gained", "estimated_actions_per_hour".
"""
now = datetime.now(timezone.utc)
one_hour_ago = now - timedelta(hours=1)
# Get earliest snapshot in the window
stmt_earliest = (
select(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= one_hour_ago,
)
.order_by(CharacterSnapshot.created_at.asc())
.limit(1)
)
result = await db.execute(stmt_earliest)
earliest = result.scalar_one_or_none()
# Get latest snapshot
stmt_latest = (
select(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= one_hour_ago,
)
.order_by(CharacterSnapshot.created_at.desc())
.limit(1)
)
result = await db.execute(stmt_latest)
latest = result.scalar_one_or_none()
if earliest is None or latest is None or earliest.id == latest.id:
return {
"character_name": character_name,
"period_hours": 1,
"xp_gained": 0,
"gold_gained": 0,
"estimated_actions_per_hour": 0,
}
earliest_data = earliest.data or {}
latest_data = latest.data or {}
# Calculate total XP gained across all skills
total_xp_gained = 0
for skill in (
"mining",
"woodcutting",
"fishing",
"weaponcrafting",
"gearcrafting",
"jewelrycrafting",
"cooking",
"alchemy",
):
xp_key = f"{skill}_xp"
early_xp = earliest_data.get(xp_key, 0)
late_xp = latest_data.get(xp_key, 0)
total_xp_gained += max(0, late_xp - early_xp)
# Also add combat XP
total_xp_gained += max(
0,
latest_data.get("xp", 0) - earliest_data.get("xp", 0),
)
gold_gained = max(
0,
latest_data.get("gold", 0) - earliest_data.get("gold", 0),
)
# Estimate time span
if earliest.created_at and latest.created_at:
time_span = (latest.created_at - earliest.created_at).total_seconds()
hours = max(time_span / 3600, 0.01) # Avoid division by zero
else:
hours = 1.0
# Count snapshots as a proxy for activity periods
count_stmt = (
select(func.count())
.select_from(CharacterSnapshot)
.where(
CharacterSnapshot.name == character_name,
CharacterSnapshot.created_at >= one_hour_ago,
)
)
count_result = await db.execute(count_stmt)
snapshot_count = count_result.scalar() or 0
return {
"character_name": character_name,
"period_hours": round(hours, 2),
"xp_gained": total_xp_gained,
"gold_gained": gold_gained,
"snapshot_count": snapshot_count,
"estimated_actions_per_hour": round(total_xp_gained / hours, 1) if hours > 0 else 0,
}

View file

@ -0,0 +1,465 @@
import asyncio
import logging
import time
from collections import deque
from typing import Any
import httpx
from app.config import settings
from app.schemas.game import (
CharacterSchema,
ItemSchema,
MapSchema,
MonsterSchema,
ResourceSchema,
)
logger = logging.getLogger(__name__)
class RateLimiter:
"""Token-bucket style rate limiter using a sliding window of timestamps."""
def __init__(self, max_requests: int, window_seconds: float) -> None:
self._max_requests = max_requests
self._window = window_seconds
self._timestamps: deque[float] = deque()
self._lock = asyncio.Lock()
async def acquire(self) -> None:
async with self._lock:
now = time.monotonic()
# Evict timestamps outside the current window
while self._timestamps and self._timestamps[0] <= now - self._window:
self._timestamps.popleft()
if len(self._timestamps) >= self._max_requests:
# Wait until the oldest timestamp exits the window
sleep_duration = self._window - (now - self._timestamps[0])
if sleep_duration > 0:
await asyncio.sleep(sleep_duration)
# Re-evict after sleeping
now = time.monotonic()
while self._timestamps and self._timestamps[0] <= now - self._window:
self._timestamps.popleft()
self._timestamps.append(time.monotonic())
class ArtifactsClient:
"""Async HTTP client for the Artifacts MMO API.
Handles authentication, rate limiting, pagination, and retry logic.
"""
MAX_RETRIES: int = 3
RETRY_BASE_DELAY: float = 1.0
def __init__(self) -> None:
self._client = httpx.AsyncClient(
base_url=settings.artifacts_api_url,
headers={
"Authorization": f"Bearer {settings.artifacts_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
timeout=httpx.Timeout(30.0, connect=10.0),
)
self._action_limiter = RateLimiter(
max_requests=settings.action_rate_limit,
window_seconds=settings.action_rate_window,
)
self._data_limiter = RateLimiter(
max_requests=settings.data_rate_limit,
window_seconds=settings.data_rate_window,
)
# ------------------------------------------------------------------
# Low-level request helpers
# ------------------------------------------------------------------
async def _request(
self,
method: str,
path: str,
*,
limiter: RateLimiter,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
last_exc: Exception | None = None
for attempt in range(1, self.MAX_RETRIES + 1):
await limiter.acquire()
try:
response = await self._client.request(
method,
path,
json=json_body,
params=params,
)
if response.status_code == 429:
retry_after = float(response.headers.get("Retry-After", "2"))
logger.warning(
"Rate limited on %s %s, retrying after %.1fs",
method,
path,
retry_after,
)
await asyncio.sleep(retry_after)
continue
if response.status_code >= 500:
logger.warning(
"Server error %d on %s %s (attempt %d/%d)",
response.status_code,
method,
path,
attempt,
self.MAX_RETRIES,
)
if attempt < self.MAX_RETRIES:
delay = self.RETRY_BASE_DELAY * (2 ** (attempt - 1))
await asyncio.sleep(delay)
continue
response.raise_for_status()
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError:
raise
except (httpx.TransportError, httpx.TimeoutException) as exc:
last_exc = exc
logger.warning(
"Network error on %s %s (attempt %d/%d): %s",
method,
path,
attempt,
self.MAX_RETRIES,
exc,
)
if attempt < self.MAX_RETRIES:
delay = self.RETRY_BASE_DELAY * (2 ** (attempt - 1))
await asyncio.sleep(delay)
continue
raise last_exc or RuntimeError(f"Request failed after {self.MAX_RETRIES} retries")
async def _get(
self,
path: str,
*,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
return await self._request(
"GET",
path,
limiter=self._data_limiter,
params=params,
)
async def _post_action(
self,
path: str,
*,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
return await self._request(
"POST",
path,
limiter=self._action_limiter,
json_body=json_body,
)
async def _get_paginated(
self,
path: str,
page_size: int = 100,
) -> list[dict[str, Any]]:
"""Fetch all pages from a paginated endpoint."""
all_items: list[dict[str, Any]] = []
page = 1
while True:
result = await self._get(path, params={"page": page, "size": page_size})
data = result.get("data", [])
all_items.extend(data)
total_pages = result.get("pages", 1)
if page >= total_pages:
break
page += 1
return all_items
# ------------------------------------------------------------------
# Data endpoints - Characters
# ------------------------------------------------------------------
async def get_characters(self) -> list[CharacterSchema]:
result = await self._get("/my/characters")
data = result.get("data", [])
return [CharacterSchema.model_validate(c) for c in data]
async def get_character(self, name: str) -> CharacterSchema:
result = await self._get(f"/characters/{name}")
return CharacterSchema.model_validate(result["data"])
# ------------------------------------------------------------------
# Data endpoints - Items
# ------------------------------------------------------------------
async def get_items(self, page: int = 1, size: int = 100) -> list[ItemSchema]:
result = await self._get("/items", params={"page": page, "size": size})
return [ItemSchema.model_validate(i) for i in result.get("data", [])]
async def get_all_items(self) -> list[ItemSchema]:
raw = await self._get_paginated("/items")
return [ItemSchema.model_validate(i) for i in raw]
# ------------------------------------------------------------------
# Data endpoints - Monsters
# ------------------------------------------------------------------
async def get_monsters(self, page: int = 1, size: int = 100) -> list[MonsterSchema]:
result = await self._get("/monsters", params={"page": page, "size": size})
return [MonsterSchema.model_validate(m) for m in result.get("data", [])]
async def get_all_monsters(self) -> list[MonsterSchema]:
raw = await self._get_paginated("/monsters")
return [MonsterSchema.model_validate(m) for m in raw]
# ------------------------------------------------------------------
# Data endpoints - Resources
# ------------------------------------------------------------------
async def get_resources(self, page: int = 1, size: int = 100) -> list[ResourceSchema]:
result = await self._get("/resources", params={"page": page, "size": size})
return [ResourceSchema.model_validate(r) for r in result.get("data", [])]
async def get_all_resources(self) -> list[ResourceSchema]:
raw = await self._get_paginated("/resources")
return [ResourceSchema.model_validate(r) for r in raw]
# ------------------------------------------------------------------
# Data endpoints - Maps
# ------------------------------------------------------------------
async def get_maps(
self,
page: int = 1,
size: int = 100,
content_type: str | None = None,
content_code: str | None = None,
) -> list[MapSchema]:
params: dict[str, Any] = {"page": page, "size": size}
if content_type is not None:
params["content_type"] = content_type
if content_code is not None:
params["content_code"] = content_code
result = await self._get("/maps", params=params)
return [MapSchema.model_validate(m) for m in result.get("data", [])]
async def get_all_maps(self) -> list[MapSchema]:
raw = await self._get_paginated("/maps")
return [MapSchema.model_validate(m) for m in raw]
# ------------------------------------------------------------------
# Data endpoints - Events, Bank, GE
# ------------------------------------------------------------------
async def get_events(self) -> list[dict[str, Any]]:
result = await self._get("/events")
return result.get("data", [])
async def get_bank_items(self, page: int = 1, size: int = 100) -> list[dict[str, Any]]:
result = await self._get("/my/bank/items", params={"page": page, "size": size})
return result.get("data", [])
async def get_all_bank_items(self) -> list[dict[str, Any]]:
return await self._get_paginated("/my/bank/items")
async def get_bank_details(self) -> dict[str, Any]:
result = await self._get("/my/bank")
return result.get("data", {})
async def get_ge_orders(self) -> list[dict[str, Any]]:
result = await self._get("/my/grandexchange/orders")
return result.get("data", [])
async def get_ge_history(self) -> list[dict[str, Any]]:
result = await self._get("/my/grandexchange/history")
return result.get("data", [])
# ------------------------------------------------------------------
# Action endpoints
# ------------------------------------------------------------------
async def move(self, name: str, x: int, y: int) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/move",
json_body={"x": x, "y": y},
)
return result.get("data", {})
async def fight(self, name: str) -> dict[str, Any]:
result = await self._post_action(f"/my/{name}/action/fight")
return result.get("data", {})
async def gather(self, name: str) -> dict[str, Any]:
result = await self._post_action(f"/my/{name}/action/gathering")
return result.get("data", {})
async def rest(self, name: str) -> dict[str, Any]:
result = await self._post_action(f"/my/{name}/action/rest")
return result.get("data", {})
async def equip(
self, name: str, code: str, slot: str, quantity: int = 1
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/equip",
json_body={"code": code, "slot": slot, "quantity": quantity},
)
return result.get("data", {})
async def unequip(self, name: str, slot: str, quantity: int = 1) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/unequip",
json_body={"slot": slot, "quantity": quantity},
)
return result.get("data", {})
async def use_item(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/use",
json_body={"code": code, "quantity": quantity},
)
return result.get("data", {})
async def deposit_item(self, name: str, code: str, quantity: int) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/bank/deposit",
json_body={"code": code, "quantity": quantity},
)
return result.get("data", {})
async def withdraw_item(self, name: str, code: str, quantity: int) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/bank/withdraw",
json_body={"code": code, "quantity": quantity},
)
return result.get("data", {})
async def deposit_gold(self, name: str, quantity: int) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/bank/deposit/gold",
json_body={"quantity": quantity},
)
return result.get("data", {})
async def withdraw_gold(self, name: str, quantity: int) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/bank/withdraw/gold",
json_body={"quantity": quantity},
)
return result.get("data", {})
async def craft(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/crafting",
json_body={"code": code, "quantity": quantity},
)
return result.get("data", {})
async def recycle(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/recycling",
json_body={"code": code, "quantity": quantity},
)
return result.get("data", {})
async def ge_buy(
self, name: str, code: str, quantity: int, price: int
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/buy",
json_body={"code": code, "quantity": quantity, "price": price},
)
return result.get("data", {})
async def ge_sell_order(
self, name: str, code: str, quantity: int, price: int
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/sell",
json_body={"code": code, "quantity": quantity, "price": price},
)
return result.get("data", {})
async def ge_buy_order(
self, name: str, code: str, quantity: int, price: int
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/buy",
json_body={"code": code, "quantity": quantity, "price": price},
)
return result.get("data", {})
async def ge_cancel(self, name: str, order_id: str) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/cancel",
json_body={"id": order_id},
)
return result.get("data", {})
async def task_new(self, name: str) -> dict[str, Any]:
result = await self._post_action(f"/my/{name}/action/task/new")
return result.get("data", {})
async def task_trade(
self, name: str, code: str, quantity: int
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/task/trade",
json_body={"code": code, "quantity": quantity},
)
return result.get("data", {})
async def task_complete(self, name: str) -> dict[str, Any]:
result = await self._post_action(f"/my/{name}/action/task/complete")
return result.get("data", {})
async def task_exchange(self, name: str) -> dict[str, Any]:
result = await self._post_action(f"/my/{name}/action/task/exchange")
return result.get("data", {})
async def task_cancel(self, name: str) -> dict[str, Any]:
result = await self._post_action(f"/my/{name}/action/task/cancel")
return result.get("data", {})
async def npc_buy(self, name: str, code: str, quantity: int) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/npc/buy",
json_body={"code": code, "quantity": quantity},
)
return result.get("data", {})
async def npc_sell(self, name: str, code: str, quantity: int) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/npc/sell",
json_body={"code": code, "quantity": quantity},
)
return result.get("data", {})
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def close(self) -> None:
await self._client.aclose()

View file

@ -0,0 +1,102 @@
"""Bank service providing enriched bank data with item details."""
import logging
from typing import Any
from app.schemas.game import ItemSchema
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
class BankService:
"""High-level service for bank operations with enriched data."""
async def get_contents(
self,
client: ArtifactsClient,
items_cache: list[ItemSchema] | None = None,
) -> dict[str, Any]:
"""Return bank contents enriched with item details from the cache.
Parameters
----------
client:
The artifacts API client.
items_cache:
Optional list of all items from the game data cache.
If provided, bank items are enriched with name, type, level, etc.
Returns
-------
Dict with "details" (bank metadata) and "items" (enriched item list).
"""
details = await client.get_bank_details()
raw_items = await client.get_all_bank_items()
# Build item lookup if cache is provided
item_lookup: dict[str, ItemSchema] = {}
if items_cache:
item_lookup = {item.code: item for item in items_cache}
enriched_items: list[dict[str, Any]] = []
for bank_item in raw_items:
code = bank_item.get("code", "")
quantity = bank_item.get("quantity", 0)
enriched: dict[str, Any] = {
"code": code,
"quantity": quantity,
}
# Enrich with item details if available
item_data = item_lookup.get(code)
if item_data is not None:
enriched["name"] = item_data.name
enriched["type"] = item_data.type
enriched["subtype"] = item_data.subtype
enriched["level"] = item_data.level
enriched["description"] = item_data.description
enriched["effects"] = [
{"name": e.name, "value": e.value}
for e in item_data.effects
]
else:
enriched["name"] = code
enriched["type"] = ""
enriched["subtype"] = ""
enriched["level"] = 0
enriched["description"] = ""
enriched["effects"] = []
enriched_items.append(enriched)
return {
"details": details,
"items": enriched_items,
}
async def get_summary(
self,
client: ArtifactsClient,
) -> dict[str, Any]:
"""Return a summary of bank contents: gold, item count, total slots.
Returns
-------
Dict with "gold", "item_count", "used_slots", and "total_slots".
"""
details = await client.get_bank_details()
raw_items = await client.get_all_bank_items()
gold = details.get("gold", 0)
total_slots = details.get("slots", 0)
used_slots = len(raw_items)
item_count = sum(item.get("quantity", 0) for item in raw_items)
return {
"gold": gold,
"item_count": item_count,
"used_slots": used_slots,
"total_slots": total_slots,
}

View file

@ -0,0 +1,45 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character_snapshot import CharacterSnapshot
from app.schemas.game import CharacterSchema
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
class CharacterService:
"""High-level service for character data and snapshot management."""
async def get_all(self, client: ArtifactsClient) -> list[CharacterSchema]:
"""Return all characters belonging to the authenticated account."""
return await client.get_characters()
async def get_one(self, client: ArtifactsClient, name: str) -> CharacterSchema:
"""Return a single character by name."""
return await client.get_character(name)
async def take_snapshot(
self,
db: AsyncSession,
client: ArtifactsClient,
) -> list[CharacterSnapshot]:
"""Fetch current character states and persist snapshots.
Returns the list of newly created snapshot rows.
"""
characters = await client.get_characters()
snapshots: list[CharacterSnapshot] = []
for char in characters:
snapshot = CharacterSnapshot(
name=char.name,
data=char.model_dump(mode="json"),
)
db.add(snapshot)
snapshots.append(snapshot)
await db.commit()
logger.info("Saved %d character snapshots", len(snapshots))
return snapshots

View file

@ -0,0 +1,210 @@
"""Grand Exchange service for orders, history, and price tracking."""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.price_history import PriceHistory
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
# Default interval for price capture background task (5 minutes)
_PRICE_CAPTURE_INTERVAL: float = 5 * 60
class ExchangeService:
"""High-level service for Grand Exchange operations and price tracking."""
def __init__(self) -> None:
self._capture_task: asyncio.Task[None] | None = None
# ------------------------------------------------------------------
# Order and history queries (pass-through to API with enrichment)
# ------------------------------------------------------------------
async def get_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]:
"""Get all active GE orders for the account.
Returns
-------
List of order dicts from the Artifacts API.
"""
return await client.get_ge_orders()
async def get_history(self, client: ArtifactsClient) -> list[dict[str, Any]]:
"""Get GE transaction history for the account.
Returns
-------
List of history entry dicts from the Artifacts API.
"""
return await client.get_ge_history()
# ------------------------------------------------------------------
# Price capture
# ------------------------------------------------------------------
async def capture_prices(
self,
db: AsyncSession,
client: ArtifactsClient,
) -> int:
"""Snapshot current GE prices to the price_history table.
Captures both buy and sell orders to derive best prices.
Returns
-------
Number of price entries captured.
"""
try:
orders = await client.get_ge_orders()
except Exception:
logger.exception("Failed to fetch GE orders for price capture")
return 0
if not orders:
logger.debug("No GE orders to capture prices from")
return 0
# Aggregate prices by item_code
item_prices: dict[str, dict[str, Any]] = {}
for order in orders:
code = order.get("code", "")
if not code:
continue
if code not in item_prices:
item_prices[code] = {
"buy_price": None,
"sell_price": None,
"volume": 0,
}
price = order.get("price", 0)
quantity = order.get("quantity", 0)
order_type = order.get("order", "") # "buy" or "sell"
item_prices[code]["volume"] += quantity
if order_type == "buy":
current_buy = item_prices[code]["buy_price"]
if current_buy is None or price > current_buy:
item_prices[code]["buy_price"] = price
elif order_type == "sell":
current_sell = item_prices[code]["sell_price"]
if current_sell is None or price < current_sell:
item_prices[code]["sell_price"] = price
# Insert price history records
count = 0
for code, prices in item_prices.items():
entry = PriceHistory(
item_code=code,
buy_price=prices["buy_price"],
sell_price=prices["sell_price"],
volume=prices["volume"],
)
db.add(entry)
count += 1
await db.commit()
logger.info("Captured %d price entries from GE", count)
return count
async def get_price_history(
self,
db: AsyncSession,
item_code: str,
days: int = 7,
) -> list[dict[str, Any]]:
"""Get price history for an item over the specified number of days.
Parameters
----------
db:
Database session.
item_code:
The item code to query.
days:
How many days of history to return (default 7).
Returns
-------
List of price history dicts ordered by captured_at ascending.
"""
since = datetime.now(timezone.utc) - timedelta(days=days)
stmt = (
select(PriceHistory)
.where(
PriceHistory.item_code == item_code,
PriceHistory.captured_at >= since,
)
.order_by(PriceHistory.captured_at.asc())
)
result = await db.execute(stmt)
rows = result.scalars().all()
return [
{
"id": row.id,
"item_code": row.item_code,
"buy_price": row.buy_price,
"sell_price": row.sell_price,
"volume": row.volume,
"captured_at": row.captured_at.isoformat() if row.captured_at else None,
}
for row in rows
]
# ------------------------------------------------------------------
# Background price capture task
# ------------------------------------------------------------------
def start_price_capture(
self,
db_factory: async_sessionmaker[AsyncSession],
client: ArtifactsClient,
interval_seconds: float = _PRICE_CAPTURE_INTERVAL,
) -> asyncio.Task[None]:
"""Spawn a background task that captures GE prices periodically.
Parameters
----------
db_factory:
Async session factory for database access.
client:
Artifacts API client.
interval_seconds:
How often to capture prices (default 5 minutes).
Returns
-------
The created asyncio Task.
"""
async def _loop() -> None:
while True:
try:
async with db_factory() as db:
await self.capture_prices(db, client)
except asyncio.CancelledError:
logger.info("Price capture background task cancelled")
return
except Exception:
logger.exception("Unhandled error during price capture")
await asyncio.sleep(interval_seconds)
self._capture_task = asyncio.create_task(_loop())
return self._capture_task
def stop_price_capture(self) -> None:
"""Cancel the background price capture task."""
if self._capture_task is not None and not self._capture_task.done():
self._capture_task.cancel()

View file

@ -0,0 +1,178 @@
import asyncio
import logging
import time
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game_cache import GameDataCache
from app.schemas.game import ItemSchema, MapSchema, MonsterSchema, ResourceSchema
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
# In-memory cache TTL in seconds (30 minutes)
CACHE_TTL: float = 30 * 60
class _MemoryCacheEntry:
__slots__ = ("data", "fetched_at")
def __init__(self, data: Any, fetched_at: float) -> None:
self.data = data
self.fetched_at = fetched_at
def is_expired(self) -> bool:
return (time.monotonic() - self.fetched_at) > CACHE_TTL
class GameDataCacheService:
"""Manages a two-layer cache (in-memory + database) for static game data.
The database layer acts as a persistent warm cache so that a fresh restart
does not require a full re-fetch from the Artifacts API. The in-memory
layer avoids repeated database round-trips for hot reads.
"""
def __init__(self) -> None:
self._memory: dict[str, _MemoryCacheEntry] = {}
self._refresh_task: asyncio.Task[None] | None = None
# ------------------------------------------------------------------
# Public helpers
# ------------------------------------------------------------------
async def get_items(self, db: AsyncSession) -> list[ItemSchema]:
raw = await self._get_from_cache(db, "items")
if raw is None:
return []
return [ItemSchema.model_validate(i) for i in raw]
async def get_monsters(self, db: AsyncSession) -> list[MonsterSchema]:
raw = await self._get_from_cache(db, "monsters")
if raw is None:
return []
return [MonsterSchema.model_validate(m) for m in raw]
async def get_resources(self, db: AsyncSession) -> list[ResourceSchema]:
raw = await self._get_from_cache(db, "resources")
if raw is None:
return []
return [ResourceSchema.model_validate(r) for r in raw]
async def get_maps(self, db: AsyncSession) -> list[MapSchema]:
raw = await self._get_from_cache(db, "maps")
if raw is None:
return []
return [MapSchema.model_validate(m) for m in raw]
# ------------------------------------------------------------------
# Full refresh
# ------------------------------------------------------------------
async def refresh_all(self, db: AsyncSession, client: ArtifactsClient) -> None:
"""Fetch all game data from the API and persist into the cache table."""
logger.info("Starting full game-data cache refresh")
fetchers: dict[str, Any] = {
"items": client.get_all_items,
"monsters": client.get_all_monsters,
"resources": client.get_all_resources,
"maps": client.get_all_maps,
}
for data_type, fetcher in fetchers.items():
try:
results = await fetcher()
serialized = [r.model_dump(mode="json") for r in results]
await self._upsert_cache(db, data_type, serialized)
self._memory[data_type] = _MemoryCacheEntry(
data=serialized,
fetched_at=time.monotonic(),
)
logger.info(
"Cached %d entries for %s",
len(serialized),
data_type,
)
except Exception:
logger.exception("Failed to refresh cache for %s", data_type)
await db.commit()
logger.info("Game-data cache refresh complete")
# ------------------------------------------------------------------
# Background periodic refresh
# ------------------------------------------------------------------
def start_background_refresh(
self,
db_factory: Any,
client: ArtifactsClient,
interval_seconds: float = CACHE_TTL,
) -> asyncio.Task[None]:
"""Spawn a background task that refreshes the cache periodically."""
async def _loop() -> None:
while True:
try:
async with db_factory() as db:
await self.refresh_all(db, client)
except asyncio.CancelledError:
logger.info("Cache refresh background task cancelled")
return
except Exception:
logger.exception("Unhandled error during background cache refresh")
await asyncio.sleep(interval_seconds)
self._refresh_task = asyncio.create_task(_loop())
return self._refresh_task
def stop_background_refresh(self) -> None:
if self._refresh_task is not None and not self._refresh_task.done():
self._refresh_task.cancel()
# ------------------------------------------------------------------
# Internal cache access
# ------------------------------------------------------------------
async def _get_from_cache(
self,
db: AsyncSession,
data_type: str,
) -> list[dict[str, Any]] | None:
# 1. Try in-memory cache
entry = self._memory.get(data_type)
if entry is not None and not entry.is_expired():
return entry.data
# 2. Fall back to database
stmt = select(GameDataCache).where(GameDataCache.data_type == data_type)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if row is None:
return None
# Populate in-memory cache from DB
self._memory[data_type] = _MemoryCacheEntry(
data=row.data,
fetched_at=time.monotonic(),
)
return row.data
async def _upsert_cache(
self,
db: AsyncSession,
data_type: str,
data: list[dict[str, Any]],
) -> None:
stmt = select(GameDataCache).where(GameDataCache.data_type == data_type)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing is not None:
existing.data = data
else:
db.add(GameDataCache(data_type=data_type, data=data))

View file

View file

@ -0,0 +1,124 @@
"""Persistent WebSocket client for the Artifacts MMO game server.
Maintains a long-lived connection to ``wss://realtime.artifactsmmo.com``
and dispatches every incoming game event to the :class:`EventBus` so that
other components (event handlers, the frontend relay) can react in real
time.
Reconnection is handled automatically with exponential back-off.
"""
from __future__ import annotations
import asyncio
import json
import logging
import websockets
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
class GameWebSocketClient:
"""Persistent WebSocket connection to the Artifacts game server."""
WS_URL = "wss://realtime.artifactsmmo.com"
def __init__(self, token: str, event_bus: EventBus) -> None:
self._token = token
self._event_bus = event_bus
self._ws: websockets.WebSocketClientProtocol | None = None
self._task: asyncio.Task | None = None
self._reconnect_delay = 1.0
self._max_reconnect_delay = 60.0
self._running = False
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> asyncio.Task:
"""Start the persistent WebSocket connection in a background task."""
self._running = True
self._task = asyncio.create_task(
self._connection_loop(),
name="game-ws-client",
)
logger.info("Game WebSocket client starting")
return self._task
async def stop(self) -> None:
"""Gracefully shut down the WebSocket connection."""
self._running = False
if self._ws is not None:
try:
await self._ws.close()
except Exception:
pass
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Game WebSocket client stopped")
# ------------------------------------------------------------------
# Connection loop
# ------------------------------------------------------------------
async def _connection_loop(self) -> None:
"""Reconnect loop with exponential back-off."""
while self._running:
try:
async with websockets.connect(
self.WS_URL,
additional_headers={"Authorization": f"Bearer {self._token}"},
) as ws:
self._ws = ws
self._reconnect_delay = 1.0
logger.info("Game WebSocket connected")
await self._event_bus.publish("ws_status", {"connected": True})
async for message in ws:
try:
data = json.loads(message)
await self._handle_message(data)
except json.JSONDecodeError:
logger.warning(
"Invalid JSON from game WS: %s", message[:100]
)
except asyncio.CancelledError:
raise
except websockets.ConnectionClosed:
logger.warning("Game WebSocket disconnected")
except Exception:
logger.exception("Game WebSocket error")
self._ws = None
if self._running:
await self._event_bus.publish("ws_status", {"connected": False})
logger.info("Reconnecting in %.1fs", self._reconnect_delay)
await asyncio.sleep(self._reconnect_delay)
self._reconnect_delay = min(
self._reconnect_delay * 2,
self._max_reconnect_delay,
)
# ------------------------------------------------------------------
# Message dispatch
# ------------------------------------------------------------------
async def _handle_message(self, data: dict) -> None:
"""Dispatch a game event to the event bus.
Game events are published under the key ``game_{type}`` where
*type* is the value of the ``"type"`` field in the incoming
message (defaults to ``"unknown"`` if absent).
"""
event_type = data.get("type", "unknown")
await self._event_bus.publish(f"game_{event_type}", data)

View file

@ -0,0 +1,74 @@
"""Async pub/sub event bus for internal communication.
Provides a simple in-process publish/subscribe mechanism built on
``asyncio.Queue``. Components can subscribe to specific event types
(string keys) or use ``subscribe_all`` to receive every published event.
"""
from __future__ import annotations
import asyncio
import logging
from collections import defaultdict
logger = logging.getLogger(__name__)
class EventBus:
"""Async pub/sub event bus for internal communication."""
def __init__(self) -> None:
self._subscribers: dict[str, list[asyncio.Queue]] = defaultdict(list)
# ------------------------------------------------------------------
# Subscribe / unsubscribe
# ------------------------------------------------------------------
def subscribe(self, event_type: str) -> asyncio.Queue:
"""Subscribe to a specific event type.
Returns an ``asyncio.Queue`` that will receive dicts of the form
``{"type": event_type, "data": ...}`` whenever an event of that
type is published.
"""
queue: asyncio.Queue = asyncio.Queue()
self._subscribers[event_type].append(queue)
return queue
def subscribe_all(self) -> asyncio.Queue:
"""Subscribe to **all** events (wildcard ``*``).
Returns a queue that receives every event regardless of type.
"""
queue: asyncio.Queue = asyncio.Queue()
self._subscribers["*"].append(queue)
return queue
def unsubscribe(self, event_type: str, queue: asyncio.Queue) -> None:
"""Remove a queue from a given event type's subscriber list."""
if queue in self._subscribers[event_type]:
self._subscribers[event_type].remove(queue)
# ------------------------------------------------------------------
# Publish
# ------------------------------------------------------------------
async def publish(self, event_type: str, data: dict) -> None:
"""Publish an event to type-specific *and* wildcard subscribers.
Parameters
----------
event_type:
A string key identifying the event (e.g. ``"automation_action"``).
data:
Arbitrary dict payload delivered to subscribers.
"""
event = {"type": event_type, "data": data}
# Deliver to type-specific subscribers
for queue in self._subscribers[event_type]:
await queue.put(event)
# Deliver to wildcard subscribers
for queue in self._subscribers["*"]:
await queue.put(event)

View file

@ -0,0 +1,80 @@
"""Event handlers for processing game events from the WebSocket.
The :class:`GameEventHandler` subscribes to all events on the bus and
can be extended with domain-specific logic (e.g. updating caches,
triggering automation adjustments, etc.).
"""
from __future__ import annotations
import asyncio
import logging
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
class GameEventHandler:
"""Process game events received via the EventBus."""
def __init__(self, event_bus: EventBus) -> None:
self._event_bus = event_bus
self._queue: asyncio.Queue | None = None
self._task: asyncio.Task | None = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> asyncio.Task:
"""Subscribe to all events and start the processing loop."""
self._queue = self._event_bus.subscribe_all()
self._task = asyncio.create_task(
self._process_loop(),
name="game-event-handler",
)
logger.info("Game event handler started")
return self._task
async def stop(self) -> None:
"""Stop the processing loop and unsubscribe."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
if self._queue is not None:
self._event_bus.unsubscribe("*", self._queue)
logger.info("Game event handler stopped")
# ------------------------------------------------------------------
# Processing
# ------------------------------------------------------------------
async def _process_loop(self) -> None:
"""Read events from the queue and dispatch to handlers."""
assert self._queue is not None
try:
while True:
event = await self._queue.get()
try:
await self._handle(event)
except Exception:
logger.exception(
"Error handling event: %s", event.get("type")
)
except asyncio.CancelledError:
logger.debug("Event handler process loop cancelled")
async def _handle(self, event: dict) -> None:
"""Handle a single event.
Override or extend this method to add domain-specific logic.
Currently logs notable game events for observability.
"""
event_type = event.get("type", "")
if event_type.startswith("game_"):
logger.info("Game event: %s", event_type)

43
backend/pyproject.toml Normal file
View file

@ -0,0 +1,43 @@
[project]
name = "artifacts-dashboard-backend"
version = "0.1.0"
description = "Backend for Artifacts MMO Dashboard & Automation Platform"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
"httpx>=0.28.0",
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0",
"alembic>=1.14.0",
"pydantic>=2.10.0",
"pydantic-settings>=2.7.0",
"websockets>=14.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24.0",
"pytest-httpx>=0.35.0",
"ruff>=0.8.0",
"mypy>=1.13.0",
"httpx[http2]>=0.28.0",
]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
strict = false
warn_return_any = true
warn_unused_configs = true

View file

120
backend/tests/conftest.py Normal file
View file

@ -0,0 +1,120 @@
"""Common fixtures for the Artifacts MMO Dashboard backend test suite."""
import pytest
from app.engine.pathfinder import Pathfinder
from app.schemas.game import (
CharacterSchema,
ContentSchema,
InventorySlot,
MapSchema,
MonsterSchema,
ResourceSchema,
)
@pytest.fixture
def make_character():
"""Factory fixture that returns a CharacterSchema with sensible defaults.
Any field can be overridden via keyword arguments.
"""
def _factory(**overrides) -> CharacterSchema:
defaults = {
"name": "TestHero",
"account": "test_account",
"level": 10,
"hp": 100,
"max_hp": 100,
"x": 0,
"y": 0,
"inventory_max_items": 20,
"inventory": [],
"mining_level": 5,
"woodcutting_level": 5,
"fishing_level": 5,
}
defaults.update(overrides)
# Convert raw inventory dicts to InventorySlot instances if needed
raw_inv = defaults.get("inventory", [])
if raw_inv and isinstance(raw_inv[0], dict):
defaults["inventory"] = [InventorySlot(**slot) for slot in raw_inv]
return CharacterSchema(**defaults)
return _factory
@pytest.fixture
def make_monster():
"""Factory fixture that returns a MonsterSchema with sensible defaults."""
def _factory(**overrides) -> MonsterSchema:
defaults = {
"name": "Chicken",
"code": "chicken",
"level": 1,
"hp": 50,
}
defaults.update(overrides)
return MonsterSchema(**defaults)
return _factory
@pytest.fixture
def make_resource():
"""Factory fixture that returns a ResourceSchema with sensible defaults."""
def _factory(**overrides) -> ResourceSchema:
defaults = {
"name": "Copper Rocks",
"code": "copper_rocks",
"skill": "mining",
"level": 1,
}
defaults.update(overrides)
return ResourceSchema(**defaults)
return _factory
@pytest.fixture
def make_map_tile():
"""Factory fixture that returns a MapSchema tile with content."""
def _factory(
x: int,
y: int,
content_type: str | None = None,
content_code: str | None = None,
) -> MapSchema:
content = None
if content_type and content_code:
content = ContentSchema(type=content_type, code=content_code)
return MapSchema(x=x, y=y, content=content)
return _factory
@pytest.fixture
def pathfinder_with_maps(make_map_tile):
"""Fixture that returns a Pathfinder pre-loaded with tiles.
Usage::
pf = pathfinder_with_maps([
(0, 0, "monster", "chicken"),
(5, 5, "bank", "bank"),
])
"""
def _factory(tile_specs: list[tuple[int, int, str, str]]) -> Pathfinder:
tiles = [make_map_tile(x, y, ct, cc) for x, y, ct, cc in tile_specs]
pf = Pathfinder()
pf.load_maps(tiles)
return pf
return _factory

View file

@ -0,0 +1,267 @@
"""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"

View file

@ -0,0 +1,171 @@
"""Tests for CooldownTracker."""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from app.engine.cooldown import CooldownTracker, _BUFFER_SECONDS
class TestCooldownTrackerIsReady:
"""Tests for CooldownTracker.is_ready()."""
def test_no_cooldown_ready(self):
"""A character with no recorded cooldown should be ready immediately."""
tracker = CooldownTracker()
assert tracker.is_ready("Hero") is True
def test_not_ready_during_cooldown(self):
"""A character with an active cooldown should not be ready."""
tracker = CooldownTracker()
tracker.update("Hero", cooldown_seconds=60)
assert tracker.is_ready("Hero") is False
def test_ready_after_cooldown_expires(self):
"""A character whose cooldown has passed should be ready."""
tracker = CooldownTracker()
# Set an expiration in the past
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
tracker.update("Hero", cooldown_seconds=0, cooldown_expiration=past)
assert tracker.is_ready("Hero") is True
def test_unknown_character_is_ready(self):
"""A character that was never tracked should be ready."""
tracker = CooldownTracker()
tracker.update("Other", cooldown_seconds=60)
assert tracker.is_ready("Unknown") is True
class TestCooldownTrackerRemaining:
"""Tests for CooldownTracker.remaining()."""
def test_remaining_no_cooldown(self):
"""Remaining should be 0 for a character with no cooldown."""
tracker = CooldownTracker()
assert tracker.remaining("Hero") == 0.0
def test_remaining_active_cooldown(self):
"""Remaining should be positive during an active cooldown."""
tracker = CooldownTracker()
tracker.update("Hero", cooldown_seconds=10)
remaining = tracker.remaining("Hero")
assert remaining > 0
assert remaining <= 10.0
def test_remaining_expired_cooldown(self):
"""Remaining should be 0 after cooldown has expired."""
tracker = CooldownTracker()
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
tracker.update("Hero", cooldown_seconds=0, cooldown_expiration=past)
assert tracker.remaining("Hero") == 0.0
def test_remaining_calculation_accuracy(self):
"""Remaining should approximate the actual duration set."""
tracker = CooldownTracker()
future = datetime.now(timezone.utc) + timedelta(seconds=5)
tracker.update("Hero", cooldown_seconds=5, cooldown_expiration=future.isoformat())
remaining = tracker.remaining("Hero")
# Should be close to 5 seconds (within 0.5s tolerance for execution time)
assert 4.0 <= remaining <= 5.5
class TestCooldownTrackerUpdate:
"""Tests for CooldownTracker.update()."""
def test_update_with_expiration_string(self):
"""update() should parse an ISO-8601 expiration string."""
tracker = CooldownTracker()
future = datetime.now(timezone.utc) + timedelta(seconds=30)
tracker.update("Hero", cooldown_seconds=30, cooldown_expiration=future.isoformat())
assert tracker.is_ready("Hero") is False
assert tracker.remaining("Hero") > 25
def test_update_with_seconds_fallback(self):
"""update() without expiration should use cooldown_seconds as duration."""
tracker = CooldownTracker()
tracker.update("Hero", cooldown_seconds=10)
assert tracker.is_ready("Hero") is False
def test_update_with_invalid_expiration_falls_back(self):
"""update() with an unparseable expiration should fall back to duration."""
tracker = CooldownTracker()
tracker.update("Hero", cooldown_seconds=5, cooldown_expiration="not-a-date")
assert tracker.is_ready("Hero") is False
remaining = tracker.remaining("Hero")
assert remaining > 0
def test_update_naive_datetime_gets_utc(self):
"""A naive datetime in expiration should be treated as UTC."""
tracker = CooldownTracker()
future = datetime.now(timezone.utc) + timedelta(seconds=10)
# Strip timezone to create a naive ISO string
naive_str = future.replace(tzinfo=None).isoformat()
tracker.update("Hero", cooldown_seconds=10, cooldown_expiration=naive_str)
assert tracker.is_ready("Hero") is False
class TestCooldownTrackerMultipleCharacters:
"""Tests for tracking multiple characters independently."""
def test_multiple_characters(self):
"""Different characters should have independent cooldowns."""
tracker = CooldownTracker()
tracker.update("Hero", cooldown_seconds=60)
# Second character has no cooldown
assert tracker.is_ready("Hero") is False
assert tracker.is_ready("Sidekick") is True
def test_multiple_characters_different_durations(self):
"""Different characters can have different cooldown durations."""
tracker = CooldownTracker()
tracker.update("Fast", cooldown_seconds=2)
tracker.update("Slow", cooldown_seconds=120)
assert tracker.remaining("Fast") < tracker.remaining("Slow")
def test_updating_one_does_not_affect_another(self):
"""Updating one character's cooldown should not affect another."""
tracker = CooldownTracker()
tracker.update("A", cooldown_seconds=60)
remaining_a = tracker.remaining("A")
tracker.update("B", cooldown_seconds=5)
# A's remaining should not have changed (within execution tolerance)
assert abs(tracker.remaining("A") - remaining_a) < 0.5
class TestCooldownTrackerWait:
"""Tests for the async CooldownTracker.wait() method."""
@pytest.mark.asyncio
async def test_wait_no_cooldown(self):
"""wait() should return immediately when no cooldown is set."""
tracker = CooldownTracker()
with patch("app.engine.cooldown.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await tracker.wait("Hero")
mock_sleep.assert_not_called()
@pytest.mark.asyncio
async def test_wait_expired_cooldown(self):
"""wait() should return immediately when cooldown has already expired."""
tracker = CooldownTracker()
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
tracker.update("Hero", cooldown_seconds=0, cooldown_expiration=past)
with patch("app.engine.cooldown.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await tracker.wait("Hero")
mock_sleep.assert_not_called()
@pytest.mark.asyncio
async def test_wait_active_cooldown_sleeps(self):
"""wait() should sleep for the remaining time plus buffer."""
tracker = CooldownTracker()
future = datetime.now(timezone.utc) + timedelta(seconds=2)
tracker.update("Hero", cooldown_seconds=2, cooldown_expiration=future.isoformat())
with patch("app.engine.cooldown.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
await tracker.wait("Hero")
mock_sleep.assert_called_once()
sleep_duration = mock_sleep.call_args[0][0]
# Should sleep for ~2 seconds + buffer
assert sleep_duration > 0
assert sleep_duration <= 2.0 + _BUFFER_SECONDS + 0.5

View file

@ -0,0 +1,220 @@
"""Tests for the GatheringStrategy state machine."""
import pytest
from app.engine.strategies.base import ActionType
from app.engine.strategies.gathering import GatheringStrategy
from app.schemas.game import InventorySlot
class TestGatheringStrategyMovement:
"""Tests for movement to resource tiles."""
@pytest.mark.asyncio
async def test_move_to_resource(self, make_character, pathfinder_with_maps):
"""When not at resource location, the strategy should return MOVE."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy({"resource_code": "copper_rocks"}, pf)
char = make_character(x=0, y=0)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.MOVE
assert plan.params == {"x": 3, "y": 4}
@pytest.mark.asyncio
async def test_idle_when_no_resource_found(self, make_character, pathfinder_with_maps):
"""When no matching resource tile exists, the strategy should IDLE."""
pf = pathfinder_with_maps([
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy({"resource_code": "gold_rocks"}, pf)
char = make_character(x=0, y=0)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.IDLE
class TestGatheringStrategyGathering:
"""Tests for gathering behavior at the resource tile."""
@pytest.mark.asyncio
async def test_gather_when_at_resource(self, make_character, pathfinder_with_maps):
"""When at resource and inventory has space, the strategy should GATHER."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy({"resource_code": "copper_rocks"}, pf)
char = make_character(x=3, y=4, inventory_max_items=20, inventory=[])
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.GATHER
@pytest.mark.asyncio
async def test_gather_when_inventory_has_some_items(self, make_character, pathfinder_with_maps):
"""Gathering should continue as long as inventory is not completely full."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy({"resource_code": "copper_rocks"}, pf)
items = [InventorySlot(slot=0, code="copper_ore", quantity=5)]
char = make_character(x=3, y=4, inventory_max_items=20, inventory=items)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.GATHER
class TestGatheringStrategyDeposit:
"""Tests for deposit behavior when inventory is full."""
@pytest.mark.asyncio
async def test_deposit_when_full(self, make_character, pathfinder_with_maps):
"""When inventory is full and deposit_on_full is True, move to bank."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy(
{"resource_code": "copper_rocks", "deposit_on_full": True},
pf,
)
items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)]
char = make_character(x=3, y=4, inventory_max_items=20, inventory=items)
plan = await strategy.next_action(char)
# When at resource with full inventory, the gather handler detects full inventory
# and transitions to check_inventory -> move_to_bank -> MOVE
assert plan.action_type == ActionType.MOVE
assert plan.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([
(3, 4, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy(
{"resource_code": "copper_rocks", "deposit_on_full": True},
pf,
)
items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)]
# First, move to bank
char_full = make_character(x=3, y=4, inventory_max_items=20, inventory=items)
await strategy.next_action(char_full) # MOVE to bank
# Now at bank
char_at_bank = make_character(x=10, y=0, 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"] == "copper_ore"
@pytest.mark.asyncio
async def test_complete_when_full_no_deposit(self, make_character, pathfinder_with_maps):
"""When inventory is full and deposit_on_full is False, COMPLETE."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
])
strategy = GatheringStrategy(
{"resource_code": "copper_rocks", "deposit_on_full": False},
pf,
)
items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)]
char = make_character(x=3, y=4, inventory_max_items=20, inventory=items)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.COMPLETE
class TestGatheringStrategyMaxLoops:
"""Tests for the max_loops limit."""
@pytest.mark.asyncio
async def test_max_loops(self, make_character, pathfinder_with_maps):
"""Strategy should return COMPLETE after max_loops deposit cycles."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy(
{"resource_code": "copper_rocks", "max_loops": 1},
pf,
)
# Simulate a complete gather-deposit cycle to increment _loop_count
strategy._loop_count = 1 # Simulate one completed cycle
char = make_character(x=3, y=4)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.COMPLETE
@pytest.mark.asyncio
async def test_no_max_loops(self, make_character, pathfinder_with_maps):
"""With max_loops=0 (default), the strategy should never COMPLETE due to loops."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy(
{"resource_code": "copper_rocks", "max_loops": 0},
pf,
)
# Even with many loops completed, max_loops=0 means infinite
strategy._loop_count = 999
char = make_character(x=3, y=4)
plan = await strategy.next_action(char)
# Should still gather, not COMPLETE
assert plan.action_type != ActionType.COMPLETE
@pytest.mark.asyncio
async def test_loop_count_increments_after_deposit(self, make_character, pathfinder_with_maps):
"""The loop counter should increment after depositing all items."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
strategy = GatheringStrategy(
{"resource_code": "copper_rocks", "max_loops": 5},
pf,
)
assert strategy._loop_count == 0
# Simulate being at bank with empty inventory (all items deposited)
# and in DEPOSIT state
strategy._state = strategy._state.__class__("deposit")
strategy._resource_pos = (3, 4)
strategy._bank_pos = (10, 0)
char = make_character(x=10, y=0, inventory=[])
await strategy.next_action(char)
assert strategy._loop_count == 1
class TestGatheringStrategyGetState:
"""Tests for get_state() reporting."""
def test_initial_state(self, pathfinder_with_maps):
"""Initial state should be move_to_resource."""
pf = pathfinder_with_maps([
(3, 4, "resource", "copper_rocks"),
])
strategy = GatheringStrategy({"resource_code": "copper_rocks"}, pf)
assert strategy.get_state() == "move_to_resource"

View file

@ -0,0 +1,166 @@
"""Tests for HealPolicy."""
from app.engine.decision.heal_policy import HealPolicy
from app.engine.strategies.base import ActionType
from app.schemas.game import InventorySlot
class TestHealPolicyShouldHeal:
"""Tests for HealPolicy.should_heal()."""
def test_should_heal_low_hp(self, make_character):
"""Returns True when HP is below the threshold percentage."""
policy = HealPolicy()
char = make_character(hp=30, max_hp=100)
assert policy.should_heal(char, threshold=50) is True
def test_should_not_heal_full(self, make_character):
"""Returns False when character is at full HP."""
policy = HealPolicy()
char = make_character(hp=100, max_hp=100)
assert policy.should_heal(char, threshold=50) is False
def test_should_not_heal_above_threshold(self, make_character):
"""Returns False when HP is above threshold."""
policy = HealPolicy()
char = make_character(hp=80, max_hp=100)
assert policy.should_heal(char, threshold=50) is False
def test_should_heal_exactly_at_threshold(self, make_character):
"""Returns False when HP is exactly at threshold (not strictly below)."""
policy = HealPolicy()
char = make_character(hp=50, max_hp=100)
# 50/100 = 50%, threshold=50 => 50 < 50 is False
assert policy.should_heal(char, threshold=50) is False
def test_should_heal_one_below_threshold(self, make_character):
"""Returns True when HP is just 1 below the threshold."""
policy = HealPolicy()
char = make_character(hp=49, max_hp=100)
assert policy.should_heal(char, threshold=50) is True
def test_should_heal_zero_hp(self, make_character):
"""Returns True when HP is zero."""
policy = HealPolicy()
char = make_character(hp=0, max_hp=100)
assert policy.should_heal(char, threshold=50) is True
def test_should_heal_zero_max_hp(self, make_character):
"""Returns False when max_hp is 0 to avoid division by zero."""
policy = HealPolicy()
char = make_character(hp=0, max_hp=0)
assert policy.should_heal(char, threshold=50) is False
def test_should_heal_high_threshold(self, make_character):
"""With a threshold of 100, any missing HP triggers healing."""
policy = HealPolicy()
char = make_character(hp=99, max_hp=100)
assert policy.should_heal(char, threshold=100) is True
class TestHealPolicyIsFullHealth:
"""Tests for HealPolicy.is_full_health()."""
def test_full_health(self, make_character):
"""Returns True when hp == max_hp."""
policy = HealPolicy()
char = make_character(hp=100, max_hp=100)
assert policy.is_full_health(char) is True
def test_not_full_health(self, make_character):
"""Returns False when hp < max_hp."""
policy = HealPolicy()
char = make_character(hp=50, max_hp=100)
assert policy.is_full_health(char) is False
def test_overheal(self, make_character):
"""Returns True when hp > max_hp (edge case)."""
policy = HealPolicy()
char = make_character(hp=150, max_hp=100)
assert policy.is_full_health(char) is True
class TestHealPolicyChooseHealMethod:
"""Tests for HealPolicy.choose_heal_method()."""
def test_choose_rest(self, make_character):
"""Default heal method should return REST."""
policy = HealPolicy()
char = make_character(hp=30, max_hp=100)
config = {"heal_method": "rest"}
plan = policy.choose_heal_method(char, config)
assert plan.action_type == ActionType.REST
def test_choose_rest_default(self, make_character):
"""Empty config should default to REST."""
policy = HealPolicy()
char = make_character(hp=30, max_hp=100)
plan = policy.choose_heal_method(char, {})
assert plan.action_type == ActionType.REST
def test_choose_consumable(self, make_character):
"""When heal_method is consumable and character has the item, returns USE_ITEM."""
policy = HealPolicy()
char = make_character(
hp=30,
max_hp=100,
inventory=[InventorySlot(slot=0, code="cooked_chicken", quantity=3)],
)
config = {
"heal_method": "consumable",
"consumable_code": "cooked_chicken",
}
plan = policy.choose_heal_method(char, config)
assert plan.action_type == ActionType.USE_ITEM
assert plan.params["code"] == "cooked_chicken"
assert plan.params["quantity"] == 1
def test_choose_consumable_not_in_inventory(self, make_character):
"""When consumable is not in inventory, falls back to REST."""
policy = HealPolicy()
char = make_character(hp=30, max_hp=100, inventory=[])
config = {
"heal_method": "consumable",
"consumable_code": "cooked_chicken",
}
plan = policy.choose_heal_method(char, config)
assert plan.action_type == ActionType.REST
def test_choose_consumable_no_code(self, make_character):
"""When heal_method is consumable but no consumable_code, falls back to REST."""
policy = HealPolicy()
char = make_character(hp=30, max_hp=100)
config = {"heal_method": "consumable"}
plan = policy.choose_heal_method(char, config)
assert plan.action_type == ActionType.REST
def test_plan_contains_reason(self, make_character):
"""The returned plan should always have a non-empty reason string."""
policy = HealPolicy()
char = make_character(hp=30, max_hp=100)
plan = policy.choose_heal_method(char, {})
assert plan.reason != ""
assert "30" in plan.reason # HP value should appear in reason

View file

@ -0,0 +1,156 @@
"""Tests for MonsterSelector."""
from app.engine.decision.monster_selector import MonsterSelector
class TestMonsterSelectorSelectOptimal:
"""Tests for MonsterSelector.select_optimal()."""
def test_select_optimal_near_level(self, make_character, make_monster):
"""Prefers monsters within +/- 5 levels of the character."""
selector = MonsterSelector()
char = make_character(level=10)
monsters = [
make_monster(code="chicken", level=1),
make_monster(code="wolf", level=8),
make_monster(code="bear", level=12),
make_monster(code="dragon", level=30),
]
result = selector.select_optimal(char, monsters)
# wolf (8) and bear (12) are within +/- 5 of level 10
# bear (12) should be preferred because higher level = more XP
assert result is not None
assert result.code == "bear"
def test_select_optimal_no_monsters(self, make_character):
"""Returns None for an empty monster list."""
selector = MonsterSelector()
char = make_character(level=10)
result = selector.select_optimal(char, [])
assert result is None
def test_prefer_higher_level(self, make_character, make_monster):
"""Among candidates within range, prefers higher level."""
selector = MonsterSelector()
char = make_character(level=10)
monsters = [
make_monster(code="wolf", level=8),
make_monster(code="ogre", level=13),
make_monster(code="bear", level=11),
]
result = selector.select_optimal(char, monsters)
# All within +/- 5 of 10; ogre at 13 is highest
assert result is not None
assert result.code == "ogre"
def test_select_exact_level(self, make_character, make_monster):
"""A monster at exactly the character's level should be a valid candidate."""
selector = MonsterSelector()
char = make_character(level=5)
monsters = [
make_monster(code="goblin", level=5),
]
result = selector.select_optimal(char, monsters)
assert result is not None
assert result.code == "goblin"
def test_fallback_below_level(self, make_character, make_monster):
"""When no monsters are in range, falls back to the best below-level monster."""
selector = MonsterSelector()
char = make_character(level=20)
monsters = [
make_monster(code="chicken", level=1),
make_monster(code="rat", level=3),
make_monster(code="wolf", level=10),
]
result = selector.select_optimal(char, monsters)
# All are more than 5 levels below 20, so fallback to highest below-level
assert result is not None
assert result.code == "wolf"
def test_fallback_all_above(self, make_character, make_monster):
"""When all monsters are above the character, picks the lowest-level one."""
selector = MonsterSelector()
char = make_character(level=1)
monsters = [
make_monster(code="dragon", level=30),
make_monster(code="demon", level=25),
make_monster(code="ogre", level=20),
]
result = selector.select_optimal(char, monsters)
# All above and out of range; within range [1-5..1+5] = [-4..6] none qualify.
# No monsters at or below level 1, so absolute fallback to lowest
assert result is not None
assert result.code == "ogre"
def test_boundary_level_included(self, make_character, make_monster):
"""Monsters exactly 5 levels away should be included in candidates."""
selector = MonsterSelector()
char = make_character(level=10)
monsters = [
make_monster(code="exactly_minus_5", level=5),
make_monster(code="exactly_plus_5", level=15),
]
result = selector.select_optimal(char, monsters)
# Both are exactly at the boundary; prefer higher level
assert result is not None
assert result.code == "exactly_plus_5"
def test_single_monster(self, make_character, make_monster):
"""With a single monster, it should always be selected."""
selector = MonsterSelector()
char = make_character(level=10)
monsters = [make_monster(code="solo", level=50)]
result = selector.select_optimal(char, monsters)
assert result is not None
assert result.code == "solo"
class TestMonsterSelectorFilterByCode:
"""Tests for MonsterSelector.filter_by_code()."""
def test_filter_by_code_found(self, make_monster):
"""filter_by_code should return the matching monster."""
selector = MonsterSelector()
monsters = [
make_monster(code="chicken"),
make_monster(code="wolf"),
]
result = selector.filter_by_code(monsters, "wolf")
assert result is not None
assert result.code == "wolf"
def test_filter_by_code_not_found(self, make_monster):
"""filter_by_code should return None when no monster matches."""
selector = MonsterSelector()
monsters = [make_monster(code="chicken")]
result = selector.filter_by_code(monsters, "dragon")
assert result is None
def test_filter_by_code_empty_list(self):
"""filter_by_code should return None for an empty list."""
selector = MonsterSelector()
result = selector.filter_by_code([], "chicken")
assert result is None

View file

@ -0,0 +1,190 @@
"""Tests for the Pathfinder spatial index."""
from app.engine.pathfinder import Pathfinder
from app.schemas.game import ContentSchema, MapSchema
class TestPathfinderFindNearest:
"""Tests for Pathfinder.find_nearest()."""
def test_find_nearest(self, pathfinder_with_maps):
"""find_nearest should return the closest tile matching type and code."""
pf = pathfinder_with_maps([
(10, 10, "monster", "chicken"),
(2, 2, "monster", "chicken"),
(20, 20, "monster", "chicken"),
])
result = pf.find_nearest(0, 0, "monster", "chicken")
assert result == (2, 2)
def test_find_nearest_prefers_manhattan_distance(self, pathfinder_with_maps):
"""find_nearest should use Manhattan distance, not Euclidean."""
pf = pathfinder_with_maps([
(3, 0, "monster", "wolf"), # Manhattan=3
(2, 2, "monster", "wolf"), # Manhattan=4
])
result = pf.find_nearest(0, 0, "monster", "wolf")
assert result == (3, 0)
def test_find_nearest_no_match(self, pathfinder_with_maps):
"""find_nearest should return None when no tile matches."""
pf = pathfinder_with_maps([
(1, 1, "monster", "chicken"),
])
result = pf.find_nearest(0, 0, "monster", "dragon")
assert result is None
def test_find_nearest_empty_map(self):
"""find_nearest should return None on an empty map."""
pf = Pathfinder()
result = pf.find_nearest(0, 0, "monster", "chicken")
assert result is None
def test_find_nearest_ignores_different_type(self, pathfinder_with_maps):
"""find_nearest should not match tiles with a different content type."""
pf = pathfinder_with_maps([
(1, 1, "resource", "chicken"), # same code, different type
])
result = pf.find_nearest(0, 0, "monster", "chicken")
assert result is None
def test_find_nearest_at_origin(self, pathfinder_with_maps):
"""find_nearest should return a tile at (0, 0) if it matches."""
pf = pathfinder_with_maps([
(0, 0, "bank", "bank"),
(5, 5, "bank", "bank"),
])
result = pf.find_nearest(0, 0, "bank", "bank")
assert result == (0, 0)
class TestPathfinderFindNearestByType:
"""Tests for Pathfinder.find_nearest_by_type()."""
def test_find_nearest_by_type(self, pathfinder_with_maps):
"""find_nearest_by_type should find by type regardless of code."""
pf = pathfinder_with_maps([
(10, 10, "bank", "city_bank"),
(3, 3, "bank", "village_bank"),
])
result = pf.find_nearest_by_type(0, 0, "bank")
assert result == (3, 3)
def test_find_nearest_by_type_no_match(self, pathfinder_with_maps):
"""find_nearest_by_type should return None when no type matches."""
pf = pathfinder_with_maps([
(1, 1, "monster", "chicken"),
])
result = pf.find_nearest_by_type(0, 0, "bank")
assert result is None
class TestPathfinderFindAll:
"""Tests for Pathfinder.find_all()."""
def test_find_all(self, pathfinder_with_maps):
"""find_all should return all tiles matching type and code."""
pf = pathfinder_with_maps([
(1, 1, "monster", "chicken"),
(5, 5, "monster", "chicken"),
(3, 3, "monster", "wolf"),
(7, 7, "resource", "copper"),
])
result = pf.find_all("monster", "chicken")
assert sorted(result) == [(1, 1), (5, 5)]
def test_find_all_by_type_only(self, pathfinder_with_maps):
"""find_all with code=None should return all tiles of the given type."""
pf = pathfinder_with_maps([
(1, 1, "monster", "chicken"),
(5, 5, "monster", "wolf"),
(7, 7, "resource", "copper"),
])
result = pf.find_all("monster")
assert sorted(result) == [(1, 1), (5, 5)]
def test_find_all_no_match(self, pathfinder_with_maps):
"""find_all should return an empty list when nothing matches."""
pf = pathfinder_with_maps([
(1, 1, "monster", "chicken"),
])
result = pf.find_all("bank", "bank")
assert result == []
def test_find_all_empty_map(self):
"""find_all should return an empty list on an empty map."""
pf = Pathfinder()
result = pf.find_all("monster")
assert result == []
class TestPathfinderTileHasContent:
"""Tests for Pathfinder.tile_has_content() and tile_has_content_type()."""
def test_tile_has_content(self, pathfinder_with_maps):
"""tile_has_content should return True for an exact match."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
])
assert pf.tile_has_content(5, 5, "monster", "chicken") is True
def test_tile_has_content_wrong_code(self, pathfinder_with_maps):
"""tile_has_content should return False for a code mismatch."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
])
assert pf.tile_has_content(5, 5, "monster", "wolf") is False
def test_tile_has_content_missing_tile(self):
"""tile_has_content should return False for a non-existent tile."""
pf = Pathfinder()
assert pf.tile_has_content(99, 99, "monster", "chicken") is False
def test_tile_has_content_no_content(self, make_map_tile):
"""tile_has_content should return False for a tile with no content."""
pf = Pathfinder()
tile = make_map_tile(1, 1) # No content_type/content_code
pf.load_maps([tile])
assert pf.tile_has_content(1, 1, "monster", "chicken") is False
def test_tile_has_content_type(self, pathfinder_with_maps):
"""tile_has_content_type should match on type alone."""
pf = pathfinder_with_maps([
(5, 5, "monster", "chicken"),
])
assert pf.tile_has_content_type(5, 5, "monster") is True
assert pf.tile_has_content_type(5, 5, "bank") is False
class TestPathfinderMisc:
"""Tests for miscellaneous Pathfinder methods."""
def test_is_loaded_false_initially(self):
"""is_loaded should be False before any maps are loaded."""
pf = Pathfinder()
assert pf.is_loaded is False
def test_is_loaded_true_after_load(self, pathfinder_with_maps):
"""is_loaded should be True after loading maps."""
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
assert pf.is_loaded is True
def test_get_tile(self, pathfinder_with_maps):
"""get_tile should return the MapSchema at the given coordinates."""
pf = pathfinder_with_maps([(3, 7, "monster", "chicken")])
tile = pf.get_tile(3, 7)
assert tile is not None
assert tile.x == 3
assert tile.y == 7
assert tile.content.code == "chicken"
def test_get_tile_missing(self):
"""get_tile should return None for coordinates not in the index."""
pf = Pathfinder()
assert pf.get_tile(99, 99) is None
def test_manhattan_distance(self):
"""manhattan_distance should compute |x1-x2| + |y1-y2|."""
assert Pathfinder.manhattan_distance(0, 0, 3, 4) == 7
assert Pathfinder.manhattan_distance(5, 5, 5, 5) == 0
assert Pathfinder.manhattan_distance(-2, 3, 1, -1) == 7

View file

@ -0,0 +1,238 @@
"""Tests for ResourceSelector."""
from app.engine.decision.resource_selector import ResourceSelector
class TestResourceSelectorSelectOptimal:
"""Tests for ResourceSelector.select_optimal()."""
def test_select_optimal(self, make_character, make_resource):
"""Picks the best resource for the character's skill level."""
selector = ResourceSelector()
char = make_character(mining_level=5)
resources = [
make_resource(code="copper_rocks", skill="mining", level=1),
make_resource(code="iron_rocks", skill="mining", level=5),
make_resource(code="gold_rocks", skill="mining", level=8),
make_resource(code="diamond_rocks", skill="mining", level=20),
]
result = selector.select_optimal(char, resources, "mining")
assert result is not None
# gold_rocks (level 8) is within range [2..8] and highest => best XP
assert result.resource.code == "gold_rocks"
def test_no_matching_skill(self, make_character, make_resource):
"""Returns None for a non-matching skill."""
selector = ResourceSelector()
char = make_character(mining_level=5)
resources = [
make_resource(code="oak_tree", skill="woodcutting", level=5),
]
result = selector.select_optimal(char, resources, "mining")
assert result is None
def test_unknown_skill(self, make_character, make_resource):
"""Returns None when the skill attribute does not exist on the character."""
selector = ResourceSelector()
char = make_character()
resources = [
make_resource(code="mystery", skill="alchemy_brewing", level=1),
]
# "alchemy_brewing_level" does not exist on CharacterSchema
result = selector.select_optimal(char, resources, "alchemy_brewing")
assert result is None
def test_empty_resources(self, make_character):
"""Returns None for an empty resource list."""
selector = ResourceSelector()
char = make_character(mining_level=5)
result = selector.select_optimal(char, [], "mining")
assert result is None
def test_prefers_higher_level_in_range(self, make_character, make_resource):
"""Among resources in the optimal range, prefers higher level."""
selector = ResourceSelector()
char = make_character(mining_level=10)
resources = [
make_resource(code="iron", skill="mining", level=8),
make_resource(code="mithril", skill="mining", level=12),
make_resource(code="gold", skill="mining", level=10),
]
result = selector.select_optimal(char, resources, "mining")
assert result is not None
# mithril (12) is within range and above skill level => best score
assert result.resource.code == "mithril"
def test_prefers_above_skill_over_below(self, make_character, make_resource):
"""Resources at or above skill level are preferred (bonus score)."""
selector = ResourceSelector()
char = make_character(mining_level=10)
resources = [
make_resource(code="lower", skill="mining", level=7), # diff=-3, in range, below
make_resource(code="higher", skill="mining", level=11), # diff=+1, in range, above
]
result = selector.select_optimal(char, resources, "mining")
assert result is not None
assert result.resource.code == "higher"
def test_fallback_to_highest_gatherable(self, make_character, make_resource):
"""When no resource is in optimal range, the scoring prefers the one closest to range."""
selector = ResourceSelector()
char = make_character(mining_level=10)
resources = [
make_resource(code="copper", skill="mining", level=1), # diff=-9, penalty=6, score=0.1
make_resource(code="iron", skill="mining", level=5), # diff=-5, penalty=2, score=3.0
make_resource(code="gold", skill="mining", level=6), # diff=-4, penalty=1, score=4.0
]
result = selector.select_optimal(char, resources, "mining")
assert result is not None
# gold (level 6) has the smallest penalty outside the +/- 3 range
# diff=-4, penalty=1, score=4.0 -- highest among below-range candidates
assert result.resource.code == "gold"
def test_absolute_fallback_to_lowest(self, make_character, make_resource):
"""When nothing is gatherable (all too high), absolute fallback to lowest level."""
selector = ResourceSelector()
char = make_character(mining_level=1)
resources = [
make_resource(code="high1", skill="mining", level=50),
make_resource(code="high2", skill="mining", level=30),
]
result = selector.select_optimal(char, resources, "mining")
assert result is not None
# high1 at level 50 has diff=+49 (too high, score=0), high2 diff=+29 (too high, score=0)
# Fallback: no gatherable (all above skill), so absolute fallback picks lowest
assert result.resource.code == "high2"
def test_selection_score_is_positive(self, make_character, make_resource):
"""The returned selection should have a positive score."""
selector = ResourceSelector()
char = make_character(mining_level=5)
resources = [
make_resource(code="copper", skill="mining", level=5),
]
result = selector.select_optimal(char, resources, "mining")
assert result is not None
assert result.score > 0
def test_selection_has_reason(self, make_character, make_resource):
"""The returned selection should have a non-empty reason string."""
selector = ResourceSelector()
char = make_character(mining_level=5)
resources = [
make_resource(code="copper", skill="mining", level=5),
]
result = selector.select_optimal(char, resources, "mining")
assert result is not None
assert result.reason != ""
class TestResourceSelectorSkills:
"""Tests for different skill types."""
def test_woodcutting_skill(self, make_character, make_resource):
"""ResourceSelector works with woodcutting skill."""
selector = ResourceSelector()
char = make_character(woodcutting_level=8)
resources = [
make_resource(code="ash_tree", skill="woodcutting", level=1),
make_resource(code="spruce_tree", skill="woodcutting", level=7),
make_resource(code="birch_tree", skill="woodcutting", level=10),
]
result = selector.select_optimal(char, resources, "woodcutting")
assert result is not None
assert result.resource.skill == "woodcutting"
def test_fishing_skill(self, make_character, make_resource):
"""ResourceSelector works with fishing skill."""
selector = ResourceSelector()
char = make_character(fishing_level=3)
resources = [
make_resource(code="shrimp_spot", skill="fishing", level=1),
make_resource(code="trout_spot", skill="fishing", level=5),
]
result = selector.select_optimal(char, resources, "fishing")
assert result is not None
assert result.resource.skill == "fishing"
def test_mixed_skills_filtered(self, make_character, make_resource):
"""Only resources matching the requested skill are considered."""
selector = ResourceSelector()
char = make_character(mining_level=5, woodcutting_level=5)
resources = [
make_resource(code="copper", skill="mining", level=5),
make_resource(code="ash_tree", skill="woodcutting", level=5),
]
result = selector.select_optimal(char, resources, "mining")
assert result is not None
assert result.resource.code == "copper"
assert result.resource.skill == "mining"
class TestResourceSelectorScoring:
"""Tests for the internal scoring logic."""
def test_score_resource_too_high(self, make_resource):
"""Resources more than LEVEL_RANGE above skill get score 0."""
selector = ResourceSelector()
resource = make_resource(level=20)
score, reason = selector._score_resource(resource, skill_level=5)
assert score == 0.0
def test_score_resource_in_range(self, make_resource):
"""Resources within range get a positive score."""
selector = ResourceSelector()
resource = make_resource(level=5)
score, reason = selector._score_resource(resource, skill_level=5)
assert score > 0
def test_score_resource_above_gets_bonus(self, make_resource):
"""Resources at or above skill level within range get a bonus."""
selector = ResourceSelector()
above = make_resource(code="above", level=7)
below = make_resource(code="below", level=3)
score_above, _ = selector._score_resource(above, skill_level=5)
score_below, _ = selector._score_resource(below, skill_level=5)
assert score_above > score_below
def test_score_resource_far_below(self, make_resource):
"""Resources far below skill level get a diminishing score."""
selector = ResourceSelector()
resource = make_resource(level=1)
score, reason = selector._score_resource(resource, skill_level=20)
assert score > 0
assert "Below" in reason

54
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,54 @@
services:
db:
image: postgres:17
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
restart: always
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- DATABASE_URL=${DATABASE_URL}
- ARTIFACTS_TOKEN=${ARTIFACTS_TOKEN}
- CORS_ORIGINS=${CORS_ORIGINS}
depends_on:
db:
condition: service_healthy
restart: always
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: prod
environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
depends_on:
- backend
restart: always
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- frontend
- backend
restart: always
volumes:
pgdata:

51
docker-compose.yml Normal file
View file

@ -0,0 +1,51 @@
services:
db:
image: postgres:17
environment:
POSTGRES_USER: ${POSTGRES_USER:-artifacts}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-artifacts}
POSTGRES_DB: ${POSTGRES_DB:-artifacts}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-artifacts}"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://artifacts:artifacts@db:5432/artifacts}
- ARTIFACTS_TOKEN=${ARTIFACTS_TOKEN}
- CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:3000"]}
depends_on:
db:
condition: service_healthy
volumes:
- ./backend/app:/app/app
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000}
depends_on:
- backend
volumes:
- ./frontend/src:/app/src
restart: unless-stopped
volumes:
pgdata:

78
docs/API.md Normal file
View file

@ -0,0 +1,78 @@
# API Reference
Base URL: `http://localhost:8000`
## Health
### `GET /health`
Returns service health status.
## Characters
### `GET /api/characters`
List all characters with current state.
### `GET /api/characters/{name}`
Get detailed character info including equipment, inventory, and skills.
## Game Data
### `GET /api/game/items`
All game items (cached).
### `GET /api/game/monsters`
All monsters (cached).
### `GET /api/game/resources`
All resources (cached).
### `GET /api/game/maps`
All map tiles (cached).
## Dashboard
### `GET /api/dashboard`
Aggregated dashboard data for all characters.
## Automations
### `GET /api/automations`
List all automation configs.
### `POST /api/automations`
Create a new automation.
### `POST /api/automations/{id}/start`
Start an automation.
### `POST /api/automations/{id}/stop`
Stop an automation.
### `POST /api/automations/{id}/pause`
Pause an automation.
### `POST /api/automations/{id}/resume`
Resume a paused automation.
## Bank
### `GET /api/bank`
Bank contents with item details.
## Exchange
### `GET /api/exchange/orders`
Active GE orders.
### `GET /api/exchange/prices/{item_code}`
Price history for an item.
## Events
### `GET /api/events`
Active and historical game events.
## WebSocket
### `WS /ws/live`
Real-time event stream (character updates, automation status, game events).

63
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,63 @@
# Architecture
## Overview
Artifacts Dashboard is a monorepo with a Python/FastAPI backend and Next.js frontend, connected via REST API and WebSocket.
```
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Frontend │────▶│ Backend │────▶│ Artifacts API│
│ (Next.js) │◀────│ (FastAPI) │◀────│ │
└─────────────┘ WS └──────┬───────┘ └──────────────┘
┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
```
## Backend
### Layers
1. **API Layer** (`app/api/`) — FastAPI routes, request/response handling
2. **Service Layer** (`app/services/`) — business logic, external API communication
3. **Engine Layer** (`app/engine/`) — automation engine with strategies and decision modules
4. **Data Layer** (`app/models/`) — SQLAlchemy models, database access
### Key Patterns
- **Strategy Pattern** — each automation type (combat, gathering, crafting, trading, task) implements a common interface
- **State Machine** — strategies operate as state machines (e.g., move → fight → heal → deposit → repeat)
- **Token Bucket** — rate limiting shared across all automation runners
- **Event Bus** — asyncio pub/sub connecting engine events with WebSocket relay
- **A* Pathfinding** — map navigation using cached tile data
### Automation Engine
```
AutomationManager
├── AutomationRunner (per character)
│ ├── Strategy (combat/gathering/crafting/trading/task)
│ ├── CooldownTracker
│ └── RateLimiter (shared)
└── Coordinator (multi-character)
```
## Frontend
- **App Router** — file-based routing with layouts
- **TanStack Query** — server state management with WebSocket-driven invalidation
- **shadcn/ui** — component library built on Radix UI
- **Recharts** — analytics charts
## Database
| Table | Purpose |
|-------|---------|
| `game_data_cache` | Cached static game data |
| `character_snapshots` | Periodic character state snapshots |
| `automation_configs` | Automation configurations |
| `automation_runs` | Automation execution state |
| `automation_logs` | Action logs |
| `price_history` | Grand Exchange price history |
| `event_log` | Game event history |

49
docs/AUTOMATION.md Normal file
View file

@ -0,0 +1,49 @@
# Automation Guide
## Strategies
### Combat
Automated monster fighting with healing and loot management.
**Config:**
- `monster_code` — target monster
- `auto_heal_threshold` — HP% to trigger healing (default: 50)
- `heal_method``rest` or `consumable`
- `consumable_code` — item to use for healing
- `min_inventory_slots` — minimum free slots before depositing (default: 3)
- `deposit_loot` — auto-deposit at bank (default: true)
### Gathering
Automated resource collection.
**Config:**
- `resource_code` — target resource
- `deposit_on_full` — deposit when inventory full (default: true)
- `max_loops` — stop after N loops (0 = infinite)
### Crafting
Automated crafting with optional material gathering.
**Config:**
- `item_code` — item to craft
- `quantity` — how many to craft
- `gather_materials` — auto-gather missing materials
- `recycle_excess` — recycle excess items
### Trading
Grand Exchange automation.
**Config:**
- `mode``sell` or `buy`
- `item_code` — item to trade
- `min_price` / `max_price` — price range
- `quantity` — trade quantity
### Task
NPC task automation.
**Config:**
- Auto-accept, complete requirements, deliver, exchange coins.
### Leveling
Composite strategy for optimal XP gain.

27
docs/DEPLOYMENT.md Normal file
View file

@ -0,0 +1,27 @@
# Deployment
## Docker Compose (Production)
```bash
cp .env.example .env
# Edit .env with production values
docker compose -f docker-compose.prod.yml up -d
```
## Coolify
1. Connect your GitHub repository in Coolify
2. Set environment variables in the Coolify dashboard
3. Deploy — Coolify will build from `docker-compose.prod.yml`
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `ARTIFACTS_TOKEN` | Yes | Your Artifacts MMO API token |
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `CORS_ORIGINS` | No | Allowed CORS origins (JSON array) |
| `POSTGRES_USER` | Yes | PostgreSQL username |
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password |
| `POSTGRES_DB` | Yes | PostgreSQL database name |
| `NEXT_PUBLIC_API_URL` | Yes | Backend API URL for frontend |

19
frontend/Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM node:22-slim AS base
RUN corepack enable pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
# Development target
FROM base AS dev
EXPOSE 3000
CMD ["pnpm", "dev"]
# Production target
FROM base AS prod
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]

23
frontend/components.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
frontend/next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

37
frontend/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

8026
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -0,0 +1,452 @@
"use client";
import { useMemo, useState } from "react";
import {
BarChart3,
Loader2,
Activity,
Coins,
TrendingUp,
Zap,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { useCharacters } from "@/hooks/use-characters";
import { useAnalytics } from "@/hooks/use-analytics";
import { SKILLS, SKILL_COLOR_TEXT_MAP } from "@/lib/constants";
import type { Character } from "@/lib/types";
const TIME_RANGES = [
{ label: "Last 1h", hours: 1 },
{ label: "Last 6h", hours: 6 },
{ label: "Last 24h", hours: 24 },
{ label: "Last 7d", hours: 168 },
] as const;
const SKILL_CHART_COLORS = [
"#f59e0b", // amber
"#22c55e", // green
"#3b82f6", // blue
"#ef4444", // red
"#64748b", // slate
"#a855f7", // purple
"#f97316", // orange
"#10b981", // emerald
];
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
interface ChartTooltipPayloadItem {
name: string;
value: number;
color: string;
}
function ChartTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: ChartTooltipPayloadItem[];
label?: string;
}) {
if (!active || !payload?.length || !label) return null;
return (
<Card className="p-3 border border-border bg-background/95 backdrop-blur-sm shadow-lg">
<p className="text-xs text-muted-foreground mb-2">{label}</p>
{payload.map((entry: ChartTooltipPayloadItem) => (
<div key={entry.name} className="flex items-center gap-2 text-sm">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium text-foreground tabular-nums">
{entry.value.toLocaleString()}
</span>
</div>
))}
</Card>
);
}
function SkillLevelChart({ character }: { character: Character }) {
const skillData = SKILLS.map((skill, idx) => ({
skill: skill.label,
level: character[`${skill.key}_level` as keyof Character] as number,
fill: SKILL_CHART_COLORS[idx],
}));
return (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={skillData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />
<XAxis
dataKey="skill"
tick={{ fill: "#9ca3af", fontSize: 10 }}
stroke="#4b5563"
angle={-35}
textAnchor="end"
height={60}
/>
<YAxis tick={{ fill: "#9ca3af", fontSize: 11 }} stroke="#4b5563" />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="level" name="Level" radius={[4, 4, 0, 0]}>
{skillData.map((entry, index) => (
<rect key={index} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
export default function AnalyticsPage() {
const { data: characters, isLoading: loadingChars } = useCharacters();
const [selectedChar, setSelectedChar] = useState("_all");
const [timeRange, setTimeRange] = useState<number>(24);
const characterName =
selectedChar === "_all" ? undefined : selectedChar;
const {
data: analytics,
isLoading: loadingAnalytics,
error,
} = useAnalytics(characterName, timeRange);
const selectedCharacter = useMemo(() => {
if (!characters || selectedChar === "_all") return null;
return characters.find((c) => c.name === selectedChar) ?? null;
}, [characters, selectedChar]);
const xpChartData = useMemo(() => {
if (!analytics?.xp_history) return [];
return analytics.xp_history.map((point) => ({
time: formatTime(point.timestamp),
xp: point.value,
label: point.label ?? "XP",
}));
}, [analytics]);
const goldChartData = useMemo(() => {
if (!analytics?.gold_history) return [];
return analytics.gold_history.map((point) => ({
time: formatTime(point.timestamp),
gold: point.value,
}));
}, [analytics]);
const isLoading = loadingChars || loadingAnalytics;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Analytics
</h1>
<p className="text-sm text-muted-foreground mt-1">
Track XP gains, gold progression, and activity metrics
</p>
</div>
{error && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load analytics. Make sure the backend is running.
</p>
</Card>
)}
{/* Filters */}
<div className="flex flex-wrap gap-3">
<Select value={selectedChar} onValueChange={setSelectedChar}>
<SelectTrigger className="w-52">
<SelectValue placeholder="All Characters" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All Characters</SelectItem>
{characters?.map((char) => (
<SelectItem key={char.name} value={char.name}>
{char.name} (Lv. {char.level})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1">
{TIME_RANGES.map((range) => (
<button
key={range.hours}
onClick={() => setTimeRange(range.hours)}
className={`px-3 py-1.5 text-xs rounded-md border transition-colors ${
timeRange === range.hours
? "bg-primary text-primary-foreground border-primary"
: "bg-background text-muted-foreground border-border hover:bg-accent"
}`}
>
{range.label}
</button>
))}
</div>
</div>
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{analytics && (
<>
{/* Stats Card */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Zap className="size-4 text-amber-400" />
Actions / Hour
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-3xl font-bold text-foreground tabular-nums">
{analytics.actions_per_hour.toFixed(1)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<TrendingUp className="size-4 text-blue-400" />
XP Data Points
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-3xl font-bold text-foreground tabular-nums">
{analytics.xp_history.length}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Coins className="size-4 text-amber-400" />
Gold Data Points
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-3xl font-bold text-foreground tabular-nums">
{analytics.gold_history.length}
</p>
</CardContent>
</Card>
</div>
{/* XP Gain Chart */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Activity className="size-5 text-blue-400" />
XP Gain Over Time
</CardTitle>
</CardHeader>
<CardContent>
{xpChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={xpChartData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#374151"
opacity={0.3}
/>
<XAxis
dataKey="time"
tick={{ fill: "#9ca3af", fontSize: 11 }}
stroke="#4b5563"
/>
<YAxis
tick={{ fill: "#9ca3af", fontSize: 11 }}
stroke="#4b5563"
/>
<Tooltip content={<ChartTooltip />} />
<Line
type="monotone"
dataKey="xp"
name="XP"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "#3b82f6" }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
No XP data available for the selected time range.
</div>
)}
</CardContent>
</Card>
{/* Gold Tracking Chart */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Coins className="size-5 text-amber-400" />
Gold Tracking
</CardTitle>
</CardHeader>
<CardContent>
{goldChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<AreaChart
data={goldChartData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient
id="goldGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="#f59e0b"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="#f59e0b"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="#374151"
opacity={0.3}
/>
<XAxis
dataKey="time"
tick={{ fill: "#9ca3af", fontSize: 11 }}
stroke="#4b5563"
/>
<YAxis
tick={{ fill: "#9ca3af", fontSize: 11 }}
stroke="#4b5563"
/>
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone"
dataKey="gold"
name="Gold"
stroke="#f59e0b"
strokeWidth={2}
fill="url(#goldGradient)"
activeDot={{ r: 4, fill: "#f59e0b" }}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
No gold data available for the selected time range.
</div>
)}
</CardContent>
</Card>
{/* Level Progression */}
{selectedCharacter && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<BarChart3 className="size-5 text-purple-400" />
Skill Levels - {selectedCharacter.name}
</CardTitle>
</CardHeader>
<CardContent>
<SkillLevelChart character={selectedCharacter} />
{/* Skill badges */}
<div className="flex flex-wrap gap-2 mt-4">
{SKILLS.map((skill) => {
const level = selectedCharacter[
`${skill.key}_level` as keyof Character
] as number;
return (
<Badge
key={skill.key}
variant="outline"
className="text-xs gap-1.5"
>
<span className={SKILL_COLOR_TEXT_MAP[skill.color]}>
{skill.label}
</span>
<span className="text-foreground font-semibold tabular-nums">
{level}
</span>
</Badge>
);
})}
</div>
</CardContent>
</Card>
)}
{!selectedCharacter && characters && characters.length > 0 && (
<Card className="p-6 text-center">
<BarChart3 className="size-10 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground text-sm">
Select a specific character above to view skill level
progression.
</p>
</Card>
)}
</>
)}
{/* Empty state when no analytics */}
{!analytics && !isLoading && !error && (
<Card className="p-8 text-center">
<BarChart3 className="size-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
No analytics data available yet. Start automations or perform
actions to generate data.
</p>
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,287 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
Loader2,
Swords,
Pickaxe,
Bot,
Clock,
Calendar,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
useAutomation,
useAutomationStatuses,
useAutomationLogs,
} from "@/hooks/use-automations";
import { RunControls } from "@/components/automation/run-controls";
import { LogStream } from "@/components/automation/log-stream";
import { cn } from "@/lib/utils";
const STRATEGY_ICONS: Record<string, React.ReactNode> = {
combat: <Swords className="size-5 text-red-400" />,
gathering: <Pickaxe className="size-5 text-green-400" />,
crafting: <Bot className="size-5 text-blue-400" />,
trading: <Bot className="size-5 text-yellow-400" />,
task: <Bot className="size-5 text-purple-400" />,
leveling: <Bot className="size-5 text-cyan-400" />,
};
const STATUS_BADGE_CLASSES: Record<string, string> = {
running: "bg-green-600 text-white",
paused: "bg-yellow-600 text-white",
stopped: "",
error: "bg-red-600 text-white",
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
function formatDuration(start: string, end: string | null): string {
const startTime = new Date(start).getTime();
const endTime = end ? new Date(end).getTime() : Date.now();
const diffMs = endTime - startTime;
const diffS = Math.floor(diffMs / 1000);
if (diffS < 60) return `${diffS}s`;
const m = Math.floor(diffS / 60);
const s = diffS % 60;
if (m < 60) return `${m}m ${s}s`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
function ConfigDisplay({ config }: { config: Record<string, unknown> }) {
const entries = Object.entries(config).filter(
([, v]) => v !== undefined && v !== null && v !== ""
);
if (entries.length === 0) {
return (
<p className="text-sm text-muted-foreground">No configuration set.</p>
);
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{entries.map(([key, value]) => (
<div key={key} className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
<span className="text-xs text-muted-foreground">
{key.replace(/_/g, " ")}
</span>
<span className="text-sm font-medium text-foreground">
{typeof value === "boolean" ? (value ? "Yes" : "No") : String(value)}
</span>
</div>
))}
</div>
);
}
export default function AutomationDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: idStr } = use(params);
const id = parseInt(idStr, 10);
const router = useRouter();
const { data, isLoading, error } = useAutomation(id);
const { data: statuses } = useAutomationStatuses();
const { data: logs } = useAutomationLogs(id);
const status = statuses?.find((s) => s.config_id === id);
const currentStatus = status?.status ?? "stopped";
const actionsCount = status?.actions_count ?? 0;
if (isLoading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data) {
return (
<div className="space-y-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/automations")}
>
<ArrowLeft className="size-4" />
Back
</Button>
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load automation. It may have been deleted.
</p>
</Card>
</div>
);
}
const { config: automation, runs } = data;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start gap-3">
<Button
variant="ghost"
size="icon-sm"
className="mt-1"
onClick={() => router.push("/automations")}
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex-1">
<div className="flex items-center gap-3">
{STRATEGY_ICONS[automation.strategy_type] ?? (
<Bot className="size-5" />
)}
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{automation.name}
</h1>
</div>
<p className="text-sm text-muted-foreground mt-1">
{automation.character_name} &middot;{" "}
<span className="capitalize">{automation.strategy_type}</span>{" "}
strategy
</p>
</div>
</div>
{/* Controls */}
<Card className="px-4 py-3">
<RunControls
automationId={id}
status={currentStatus}
actionsCount={actionsCount}
/>
</Card>
{/* Tabs: Config / Runs / Logs */}
<Tabs defaultValue="logs">
<TabsList>
<TabsTrigger value="logs">Live Logs</TabsTrigger>
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="runs">Run History</TabsTrigger>
</TabsList>
<TabsContent value="logs">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Live Log Stream</CardTitle>
</CardHeader>
<CardContent>
<LogStream logs={logs ?? []} maxHeight="500px" />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="config">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Strategy Configuration</CardTitle>
</CardHeader>
<CardContent>
<ConfigDisplay config={automation.config} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="runs">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Run History</CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
No runs yet. Start the automation to create a run.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Actions</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow key={run.id}>
<TableCell>
<Badge
variant={
run.status === "error"
? "destructive"
: "secondary"
}
className={cn(
STATUS_BADGE_CLASSES[run.status]
)}
>
{run.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="size-3" />
{formatDate(run.started_at)}
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="size-3" />
{formatDuration(
run.started_at,
run.stopped_at
)}
</div>
</TableCell>
<TableCell className="tabular-nums">
{run.actions_count.toLocaleString()}
</TableCell>
<TableCell className="max-w-[200px]">
{run.error_message && (
<span className="text-xs text-red-400 truncate block">
{run.error_message}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,235 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { StrategySelector } from "@/components/automation/strategy-selector";
import {
ConfigForm,
DEFAULT_COMBAT_CONFIG,
DEFAULT_GATHERING_CONFIG,
DEFAULT_CRAFTING_CONFIG,
DEFAULT_TRADING_CONFIG,
DEFAULT_TASK_CONFIG,
DEFAULT_LEVELING_CONFIG,
} from "@/components/automation/config-form";
import { useCharacters } from "@/hooks/use-characters";
import { useCreateAutomation } from "@/hooks/use-automations";
import { toast } from "sonner";
type StrategyType =
| "combat"
| "gathering"
| "crafting"
| "trading"
| "task"
| "leveling";
const DEFAULT_CONFIGS: Record<StrategyType, Record<string, unknown>> = {
combat: DEFAULT_COMBAT_CONFIG as unknown as Record<string, unknown>,
gathering: DEFAULT_GATHERING_CONFIG as unknown as Record<string, unknown>,
crafting: DEFAULT_CRAFTING_CONFIG as unknown as Record<string, unknown>,
trading: DEFAULT_TRADING_CONFIG as unknown as Record<string, unknown>,
task: DEFAULT_TASK_CONFIG as unknown as Record<string, unknown>,
leveling: DEFAULT_LEVELING_CONFIG as unknown as Record<string, unknown>,
};
export default function NewAutomationPage() {
const router = useRouter();
const { data: characters, isLoading: loadingCharacters } = useCharacters();
const createMutation = useCreateAutomation();
const [name, setName] = useState("");
const [characterName, setCharacterName] = useState("");
const [strategyType, setStrategyType] = useState<StrategyType | null>(null);
const [config, setConfig] = useState<Record<string, unknown>>({});
function handleStrategyChange(strategy: StrategyType) {
setStrategyType(strategy);
setConfig(DEFAULT_CONFIGS[strategy]);
}
function handleCreate() {
if (!name.trim()) {
toast.error("Please enter a name for the automation");
return;
}
if (!characterName) {
toast.error("Please select a character");
return;
}
if (!strategyType) {
toast.error("Please select a strategy");
return;
}
createMutation.mutate(
{
name: name.trim(),
character_name: characterName,
strategy_type: strategyType,
config,
},
{
onSuccess: () => {
toast.success("Automation created successfully");
router.push("/automations");
},
onError: (err) => {
toast.error(`Failed to create automation: ${err.message}`);
},
}
);
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/automations")}
>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
New Automation
</h1>
<p className="text-sm text-muted-foreground mt-1">
Configure a new automated strategy
</p>
</div>
</div>
{/* Step 1: Name + Character */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
1
</span>
Basic Info
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="automation-name">Automation Name</Label>
<Input
id="automation-name"
placeholder="e.g. Farm Chickens, Mine Copper"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Character</Label>
<Select value={characterName} onValueChange={setCharacterName}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a character" />
</SelectTrigger>
<SelectContent>
{loadingCharacters && (
<SelectItem value="_loading" disabled>
Loading characters...
</SelectItem>
)}
{characters?.map((char) => (
<SelectItem key={char.name} value={char.name}>
{char.name} (Lv. {char.level})
</SelectItem>
))}
{characters?.length === 0 && (
<SelectItem value="_empty" disabled>
No characters found
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Step 2: Strategy */}
<Card className={!characterName ? "opacity-50 pointer-events-none" : ""}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
2
</span>
Select Strategy
</CardTitle>
</CardHeader>
<CardContent>
<StrategySelector
value={strategyType}
onChange={handleStrategyChange}
/>
</CardContent>
</Card>
{/* Step 3: Configuration */}
<Card
className={
!strategyType ? "opacity-50 pointer-events-none" : ""
}
>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
3
</span>
Configure Strategy
</CardTitle>
</CardHeader>
<CardContent>
{strategyType && (
<ConfigForm
strategyType={strategyType}
config={config}
onChange={setConfig}
/>
)}
</CardContent>
</Card>
<Separator />
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
onClick={() => router.push("/automations")}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={
!name.trim() ||
!characterName ||
!strategyType ||
createMutation.isPending
}
>
{createMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Create Automation
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Plus,
Trash2,
Swords,
Pickaxe,
Bot,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
useAutomations,
useAutomationStatuses,
useDeleteAutomation,
useControlAutomation,
} from "@/hooks/use-automations";
import { RunControls } from "@/components/automation/run-controls";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const STRATEGY_ICONS: Record<string, React.ReactNode> = {
combat: <Swords className="size-4 text-red-400" />,
gathering: <Pickaxe className="size-4 text-green-400" />,
crafting: <Bot className="size-4 text-blue-400" />,
trading: <Bot className="size-4 text-yellow-400" />,
task: <Bot className="size-4 text-purple-400" />,
leveling: <Bot className="size-4 text-cyan-400" />,
};
const STRATEGY_COLORS: Record<string, string> = {
combat: "text-red-400",
gathering: "text-green-400",
crafting: "text-blue-400",
trading: "text-yellow-400",
task: "text-purple-400",
leveling: "text-cyan-400",
};
export default function AutomationsPage() {
const router = useRouter();
const { data: automations, isLoading, error } = useAutomations();
const { data: statuses } = useAutomationStatuses();
const deleteMutation = useDeleteAutomation();
const [deleteTarget, setDeleteTarget] = useState<{
id: number;
name: string;
} | null>(null);
const statusMap = new Map(
(statuses ?? []).map((s) => [s.config_id, s])
);
function handleDelete() {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
toast.success(`Automation "${deleteTarget.name}" deleted`);
setDeleteTarget(null);
},
onError: (err) => {
toast.error(`Failed to delete: ${err.message}`);
},
});
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Automations
</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage automated strategies for your characters
</p>
</div>
<Button onClick={() => router.push("/automations/new")}>
<Plus className="size-4" />
New Automation
</Button>
</div>
{error && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load automations. Make sure the backend is running.
</p>
</Card>
)}
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{automations && automations.length === 0 && !isLoading && (
<Card className="p-8 text-center">
<Bot className="size-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">
No automations configured yet. Create one to get started.
</p>
<Button onClick={() => router.push("/automations/new")}>
<Plus className="size-4" />
Create Automation
</Button>
</Card>
)}
{automations && automations.length > 0 && (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Character</TableHead>
<TableHead>Strategy</TableHead>
<TableHead>Status / Controls</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{automations.map((automation) => {
const status = statusMap.get(automation.id);
const currentStatus = status?.status ?? "stopped";
const actionsCount = status?.actions_count ?? 0;
return (
<TableRow
key={automation.id}
className="cursor-pointer"
onClick={() =>
router.push(`/automations/${automation.id}`)
}
>
<TableCell className="font-medium">
{automation.name}
</TableCell>
<TableCell className="text-muted-foreground">
{automation.character_name}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{STRATEGY_ICONS[automation.strategy_type] ?? (
<Bot className="size-4" />
)}
<span
className={cn(
"capitalize text-sm",
STRATEGY_COLORS[automation.strategy_type]
)}
>
{automation.strategy_type}
</span>
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<RunControls
automationId={automation.id}
status={currentStatus}
actionsCount={actionsCount}
/>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() =>
setDeleteTarget({
id: automation.id,
name: automation.name,
})
}
>
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Automation</DialogTitle>
<DialogDescription>
Are you sure you want to delete &ldquo;{deleteTarget?.name}
&rdquo;? This action cannot be undone. Any running automation
will be stopped.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,212 @@
"use client";
import { useMemo, useState } from "react";
import { Coins, Loader2, Package, Search, Vault } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useBank } from "@/hooks/use-bank";
interface BankItem {
code: string;
quantity: number;
type?: string;
[key: string]: unknown;
}
export default function BankPage() {
const { data, isLoading, error } = useBank();
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"code" | "quantity">("code");
const bankDetails = data?.details as
| { gold?: number; slots?: number; max_slots?: number }
| undefined;
const bankItems = (data?.items ?? []) as BankItem[];
const filteredItems = useMemo(() => {
let items = [...bankItems];
if (search.trim()) {
const q = search.toLowerCase().trim();
items = items.filter((item) => item.code.toLowerCase().includes(q));
}
items.sort((a, b) => {
if (sortBy === "quantity") return b.quantity - a.quantity;
return a.code.localeCompare(b.code);
});
return items;
}, [bankItems, search, sortBy]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Bank
</h1>
<p className="text-sm text-muted-foreground mt-1">
View and manage your stored items and gold
</p>
</div>
{error && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load bank data. Make sure the backend is running.
</p>
</Card>
)}
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{data && (
<>
{/* Summary cards */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Coins className="size-4 text-amber-400" />
Gold
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-2xl font-bold text-amber-400">
{(bankDetails?.gold ?? 0).toLocaleString()}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Package className="size-4 text-blue-400" />
Items
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-2xl font-bold text-foreground">
{bankItems.length}
</p>
<p className="text-xs text-muted-foreground">unique items</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Vault className="size-4 text-purple-400" />
Slots
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-2xl font-bold text-foreground">
{bankDetails?.slots ?? 0}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/ {bankDetails?.max_slots ?? 0}
</span>
</p>
<div className="mt-1 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-purple-500 transition-all"
style={{
width: `${
bankDetails?.max_slots
? ((bankDetails.slots ?? 0) /
bankDetails.max_slots) *
100
: 0
}%`,
}}
/>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search items..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select
value={sortBy}
onValueChange={(v) => setSortBy(v as "code" | "quantity")}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="code">Sort by Code</SelectItem>
<SelectItem value="quantity">Sort by Quantity</SelectItem>
</SelectContent>
</Select>
</div>
{/* Items Grid */}
{filteredItems.length > 0 && (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filteredItems.map((item) => (
<Card
key={item.code}
className="py-3 px-3 hover:bg-accent/30 transition-colors"
>
<div className="space-y-1.5">
<p className="text-sm font-medium text-foreground truncate">
{item.code}
</p>
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-foreground tabular-nums">
{item.quantity.toLocaleString()}
</span>
{item.type && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 capitalize"
>
{item.type}
</Badge>
)}
</div>
</div>
</Card>
))}
</div>
)}
{/* Empty state */}
{filteredItems.length === 0 && !isLoading && (
<Card className="p-8 text-center">
<Package className="size-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
{search.trim()
? `No items matching "${search}"`
: "Your bank is empty. Deposit items to see them here."}
</p>
</Card>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,234 @@
"use client";
import { use, useState } from "react";
import Link from "next/link";
import {
ArrowLeft,
Loader2,
Move,
Swords,
Pickaxe,
BedDouble,
} from "lucide-react";
import { useCharacter } from "@/hooks/use-characters";
import { StatsPanel } from "@/components/character/stats-panel";
import { EquipmentGrid } from "@/components/character/equipment-grid";
import { InventoryGrid } from "@/components/character/inventory-grid";
import { SkillBars } from "@/components/character/skill-bars";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { executeAction } from "@/lib/api-client";
import { toast } from "sonner";
export default function CharacterPage({
params,
}: {
params: Promise<{ name: string }>;
}) {
const { name } = use(params);
const decodedName = decodeURIComponent(name);
const { data: character, isLoading, error } = useCharacter(decodedName);
const [moveX, setMoveX] = useState("");
const [moveY, setMoveY] = useState("");
const [actionPending, setActionPending] = useState<string | null>(null);
async function handleAction(
action: string,
params: Record<string, unknown> = {}
) {
setActionPending(action);
try {
await executeAction(decodedName, action, params);
toast.success(`Action "${action}" executed successfully`);
} catch (err) {
toast.error(
`Action "${action}" failed: ${err instanceof Error ? err.message : "Unknown error"}`
);
} finally {
setActionPending(null);
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/dashboard">
<ArrowLeft className="size-4" />
</Link>
</Button>
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="h-64 animate-pulse bg-muted/50" />
))}
</div>
</div>
);
}
if (error || !character) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/dashboard">
<ArrowLeft className="size-4" />
</Link>
</Button>
<h1 className="text-2xl font-bold tracking-tight">Character</h1>
</div>
<Card className="border-destructive bg-destructive/10 p-6">
<p className="text-sm text-destructive">
Failed to load character &quot;{decodedName}&quot;. Make sure the
backend is running and the character exists.
</p>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/dashboard">
<ArrowLeft className="size-4" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
{character.name}
</h1>
<p className="text-sm text-muted-foreground">
Level {character.level} &middot; {character.skin}
</p>
</div>
</div>
{/* Top row: Stats + Equipment */}
<div className="grid gap-4 lg:grid-cols-2">
<StatsPanel character={character} />
<EquipmentGrid character={character} />
</div>
{/* Bottom row: Inventory + Skills */}
<div className="grid gap-4 lg:grid-cols-2">
<InventoryGrid character={character} />
<SkillBars character={character} />
</div>
{/* Manual Actions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Manual Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3 items-end">
{/* Move action with x,y inputs */}
<div className="flex items-end gap-2">
<div className="space-y-1">
<Label htmlFor="move-x" className="text-xs">
X
</Label>
<Input
id="move-x"
type="number"
value={moveX}
onChange={(e) => setMoveX(e.target.value)}
placeholder={String(character.x)}
className="w-20 h-9"
/>
</div>
<div className="space-y-1">
<Label htmlFor="move-y" className="text-xs">
Y
</Label>
<Input
id="move-y"
type="number"
value={moveY}
onChange={(e) => setMoveY(e.target.value)}
placeholder={String(character.y)}
className="w-20 h-9"
/>
</div>
<Button
variant="outline"
size="sm"
disabled={actionPending !== null}
onClick={() =>
handleAction("move", {
x: parseInt(moveX, 10) || character.x,
y: parseInt(moveY, 10) || character.y,
})
}
>
{actionPending === "move" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Move className="size-4" />
)}
Move
</Button>
</div>
{/* Fight */}
<Button
variant="outline"
size="sm"
disabled={actionPending !== null}
onClick={() => handleAction("fight")}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
{actionPending === "fight" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Swords className="size-4" />
)}
Fight
</Button>
{/* Gather */}
<Button
variant="outline"
size="sm"
disabled={actionPending !== null}
onClick={() => handleAction("gather")}
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
>
{actionPending === "gather" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Pickaxe className="size-4" />
)}
Gather
</Button>
{/* Rest */}
<Button
variant="outline"
size="sm"
disabled={actionPending !== null}
onClick={() => handleAction("rest")}
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
>
{actionPending === "rest" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<BedDouble className="size-4" />
)}
Rest
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { useDashboard } from "@/hooks/use-characters";
import { CharacterCard } from "@/components/dashboard/character-card";
import { Card } from "@/components/ui/card";
function CharacterCardSkeleton() {
return (
<Card className="animate-pulse p-4">
<div className="flex items-center gap-3 mb-4">
<div className="h-5 w-24 rounded bg-muted" />
<div className="h-5 w-12 rounded bg-muted" />
</div>
<div className="space-y-3">
<div className="h-3 w-full rounded bg-muted" />
<div className="h-3 w-full rounded bg-muted" />
<div className="h-3 w-3/4 rounded bg-muted" />
<div className="flex gap-2 mt-4">
<div className="h-6 w-16 rounded bg-muted" />
<div className="h-6 w-16 rounded bg-muted" />
<div className="h-6 w-16 rounded bg-muted" />
</div>
</div>
</Card>
);
}
export default function DashboardPage() {
const { data, isLoading, error } = useDashboard();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Dashboard
</h1>
<p className="text-sm text-muted-foreground mt-1">
Overview of all characters and server status
</p>
</div>
{error && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load dashboard data. Make sure the backend is running.
</p>
</Card>
)}
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{isLoading &&
Array.from({ length: 3 }).map((_, i) => (
<CharacterCardSkeleton key={i} />
))}
{data?.characters.map((character) => (
<CharacterCard key={character.name} character={character} />
))}
</div>
{data && data.characters.length === 0 && !isLoading && (
<Card className="p-8 text-center">
<p className="text-muted-foreground">
No characters found. Make sure the backend is connected to the
Artifacts API.
</p>
</Card>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more