Initial release: Artifacts MMO Dashboard & Automation Platform
Some checks failed
Release / release (push) Has been cancelled
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:
commit
f845647934
157 changed files with 26332 additions and 0 deletions
18
.env.example
Normal file
18
.env.example
Normal 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
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
18
.github/pull_request_template.md
vendored
Normal 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
84
.github/workflows/ci.yml
vendored
Normal 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
45
.github/workflows/release.yml
vendored
Normal 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
55
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
107
README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 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
14
backend/Dockerfile
Normal 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
43
backend/alembic.ini
Normal 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
78
backend/alembic/env.py
Normal 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()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||||
73
backend/alembic/versions/001_create_initial_tables.py
Normal file
73
backend/alembic/versions/001_create_initial_tables.py
Normal 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")
|
||||||
126
backend/alembic/versions/002_add_automation_tables.py
Normal file
126
backend/alembic/versions/002_add_automation_tables.py
Normal 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")
|
||||||
141
backend/alembic/versions/003_add_price_history_event_log.py
Normal file
141
backend/alembic/versions/003_add_price_history_event_log.py
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
257
backend/app/api/automations.py
Normal file
257
backend/app/api/automations.py
Normal 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
134
backend/app/api/bank.py
Normal 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}
|
||||||
46
backend/app/api/characters.py
Normal file
46
backend/app/api/characters.py
Normal 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
|
||||||
48
backend/app/api/dashboard.py
Normal file
48
backend/app/api/dashboard.py
Normal 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
76
backend/app/api/events.py
Normal 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,
|
||||||
|
}
|
||||||
82
backend/app/api/exchange.py
Normal file
82
backend/app/api/exchange.py
Normal 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,
|
||||||
|
}
|
||||||
52
backend/app/api/game_data.py
Normal file
52
backend/app/api/game_data.py
Normal 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
101
backend/app/api/logs.py
Normal 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
113
backend/app/api/ws.py
Normal 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
21
backend/app/config.py
Normal 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
34
backend/app/database.py
Normal 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
|
||||||
11
backend/app/engine/__init__.py
Normal file
11
backend/app/engine/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
90
backend/app/engine/cooldown.py
Normal file
90
backend/app/engine/cooldown.py
Normal 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)
|
||||||
139
backend/app/engine/coordinator.py
Normal file
139
backend/app/engine/coordinator.py
Normal 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
|
||||||
11
backend/app/engine/decision/__init__.py
Normal file
11
backend/app/engine/decision/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
157
backend/app/engine/decision/equipment_optimizer.py
Normal file
157
backend/app/engine/decision/equipment_optimizer.py
Normal 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
|
||||||
77
backend/app/engine/decision/heal_policy.py
Normal file
77
backend/app/engine/decision/heal_policy.py
Normal 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)",
|
||||||
|
)
|
||||||
83
backend/app/engine/decision/monster_selector.py
Normal file
83
backend/app/engine/decision/monster_selector.py
Normal 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
|
||||||
149
backend/app/engine/decision/resource_selector.py
Normal file
149
backend/app/engine/decision/resource_selector.py
Normal 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
|
||||||
244
backend/app/engine/manager.py
Normal file
244
backend/app/engine/manager.py
Normal 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"
|
||||||
|
)
|
||||||
130
backend/app/engine/pathfinder.py
Normal file
130
backend/app/engine/pathfinder.py
Normal 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)
|
||||||
504
backend/app/engine/runner.py
Normal file
504
backend/app/engine/runner.py
Normal 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)
|
||||||
19
backend/app/engine/strategies/__init__.py
Normal file
19
backend/app/engine/strategies/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
99
backend/app/engine/strategies/base.py
Normal file
99
backend/app/engine/strategies/base.py
Normal 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
|
||||||
232
backend/app/engine/strategies/combat.py
Normal file
232
backend/app/engine/strategies/combat.py
Normal 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)
|
||||||
420
backend/app/engine/strategies/crafting.py
Normal file
420
backend/app/engine/strategies/crafting.py
Normal 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,
|
||||||
|
)
|
||||||
202
backend/app/engine/strategies/gathering.py
Normal file
202
backend/app/engine/strategies/gathering.py
Normal 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)
|
||||||
371
backend/app/engine/strategies/leveling.py
Normal file
371
backend/app/engine/strategies/leveling.py
Normal 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, "")
|
||||||
425
backend/app/engine/strategies/task.py
Normal file
425
backend/app/engine/strategies/task.py
Normal 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,
|
||||||
|
)
|
||||||
307
backend/app/engine/strategies/trading.py
Normal file
307
backend/app/engine/strategies/trading.py
Normal 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
242
backend/app/main.py
Normal 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"}
|
||||||
15
backend/app/models/__init__.py
Normal file
15
backend/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
108
backend/app/models/automation.py
Normal file
108
backend/app/models/automation.py
Normal 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})>"
|
||||||
|
)
|
||||||
22
backend/app/models/character_snapshot.py
Normal file
22
backend/app/models/character_snapshot.py
Normal 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})>"
|
||||||
54
backend/app/models/event_log.py
Normal file
54
backend/app/models/event_log.py
Normal 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})>"
|
||||||
|
)
|
||||||
31
backend/app/models/game_cache.py
Normal file
31
backend/app/models/game_cache.py
Normal 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})>"
|
||||||
49
backend/app/models/price_history.py
Normal file
49
backend/app/models/price_history.py
Normal 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})>"
|
||||||
|
)
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
214
backend/app/schemas/automation.py
Normal file
214
backend/app/schemas/automation.py
Normal 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)
|
||||||
51
backend/app/schemas/exchange.py
Normal file
51
backend/app/schemas/exchange.py
Normal 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
205
backend/app/schemas/game.py
Normal 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
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
234
backend/app/services/analytics_service.py
Normal file
234
backend/app/services/analytics_service.py
Normal 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,
|
||||||
|
}
|
||||||
465
backend/app/services/artifacts_client.py
Normal file
465
backend/app/services/artifacts_client.py
Normal 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()
|
||||||
102
backend/app/services/bank_service.py
Normal file
102
backend/app/services/bank_service.py
Normal 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,
|
||||||
|
}
|
||||||
45
backend/app/services/character_service.py
Normal file
45
backend/app/services/character_service.py
Normal 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
|
||||||
210
backend/app/services/exchange_service.py
Normal file
210
backend/app/services/exchange_service.py
Normal 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()
|
||||||
178
backend/app/services/game_data_cache.py
Normal file
178
backend/app/services/game_data_cache.py
Normal 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))
|
||||||
0
backend/app/websocket/__init__.py
Normal file
0
backend/app/websocket/__init__.py
Normal file
124
backend/app/websocket/client.py
Normal file
124
backend/app/websocket/client.py
Normal 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)
|
||||||
74
backend/app/websocket/event_bus.py
Normal file
74
backend/app/websocket/event_bus.py
Normal 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)
|
||||||
80
backend/app/websocket/handlers.py
Normal file
80
backend/app/websocket/handlers.py
Normal 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
43
backend/pyproject.toml
Normal 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
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
120
backend/tests/conftest.py
Normal file
120
backend/tests/conftest.py
Normal 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
|
||||||
267
backend/tests/test_combat_strategy.py
Normal file
267
backend/tests/test_combat_strategy.py
Normal 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"
|
||||||
171
backend/tests/test_cooldown.py
Normal file
171
backend/tests/test_cooldown.py
Normal 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
|
||||||
220
backend/tests/test_gathering_strategy.py
Normal file
220
backend/tests/test_gathering_strategy.py
Normal 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"
|
||||||
166
backend/tests/test_heal_policy.py
Normal file
166
backend/tests/test_heal_policy.py
Normal 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
|
||||||
156
backend/tests/test_monster_selector.py
Normal file
156
backend/tests/test_monster_selector.py
Normal 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
|
||||||
190
backend/tests/test_pathfinder.py
Normal file
190
backend/tests/test_pathfinder.py
Normal 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
|
||||||
238
backend/tests/test_resource_selector.py
Normal file
238
backend/tests/test_resource_selector.py
Normal 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
54
docker-compose.prod.yml
Normal 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
51
docker-compose.yml
Normal 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
78
docs/API.md
Normal 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
63
docs/ARCHITECTURE.md
Normal 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
49
docs/AUTOMATION.md
Normal 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
27
docs/DEPLOYMENT.md
Normal 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
19
frontend/Dockerfile
Normal 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
23
frontend/components.json
Normal 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": {}
|
||||||
|
}
|
||||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal 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
7
frontend/next.config.ts
Normal 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
37
frontend/package.json
Normal 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
8026
frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
452
frontend/src/app/analytics/page.tsx
Normal file
452
frontend/src/app/analytics/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
frontend/src/app/automations/[id]/page.tsx
Normal file
287
frontend/src/app/automations/[id]/page.tsx
Normal 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} ·{" "}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
frontend/src/app/automations/new/page.tsx
Normal file
235
frontend/src/app/automations/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
frontend/src/app/automations/page.tsx
Normal file
244
frontend/src/app/automations/page.tsx
Normal 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 “{deleteTarget?.name}
|
||||||
|
”? 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
frontend/src/app/bank/page.tsx
Normal file
212
frontend/src/app/bank/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
frontend/src/app/characters/[name]/page.tsx
Normal file
234
frontend/src/app/characters/[name]/page.tsx
Normal 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 "{decodedName}". 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} · {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
frontend/src/app/dashboard/page.tsx
Normal file
71
frontend/src/app/dashboard/page.tsx
Normal 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
Loading…
Reference in a new issue