Compare commits
No commits in common. "main" and "v0.2.0" have entirely different histories.
96 changed files with 814 additions and 16905 deletions
|
|
@ -1,114 +0,0 @@
|
||||||
"""Add workflow_configs, workflow_runs tables
|
|
||||||
|
|
||||||
Revision ID: 004_workflows
|
|
||||||
Revises: 003_price_event
|
|
||||||
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 = "004_workflows"
|
|
||||||
down_revision: Union[str, None] = "003_price_event"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# workflow_configs
|
|
||||||
op.create_table(
|
|
||||||
"workflow_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("description", sa.Text(), nullable=False, server_default=sa.text("''")),
|
|
||||||
sa.Column(
|
|
||||||
"steps",
|
|
||||||
sa.JSON(),
|
|
||||||
nullable=False,
|
|
||||||
comment="JSON array of workflow steps",
|
|
||||||
),
|
|
||||||
sa.Column("loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
|
||||||
sa.Column("max_loops", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
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_workflow_configs_character_name"),
|
|
||||||
"workflow_configs",
|
|
||||||
["character_name"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# workflow_runs
|
|
||||||
op.create_table(
|
|
||||||
"workflow_runs",
|
|
||||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"workflow_id",
|
|
||||||
sa.Integer(),
|
|
||||||
sa.ForeignKey("workflow_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("current_step_index", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
sa.Column(
|
|
||||||
"current_step_id",
|
|
||||||
sa.String(length=100),
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("''"),
|
|
||||||
),
|
|
||||||
sa.Column("loop_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
sa.Column("total_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
sa.Column("step_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
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("error_message", sa.Text(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"step_history",
|
|
||||||
sa.JSON(),
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("'[]'::json"),
|
|
||||||
comment="JSON array of completed step records",
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_workflow_runs_workflow_id"),
|
|
||||||
"workflow_runs",
|
|
||||||
["workflow_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index(op.f("ix_workflow_runs_workflow_id"), table_name="workflow_runs")
|
|
||||||
op.drop_table("workflow_runs")
|
|
||||||
op.drop_index(op.f("ix_workflow_configs_character_name"), table_name="workflow_configs")
|
|
||||||
op.drop_table("workflow_configs")
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
"""Add app_errors table for error tracking
|
|
||||||
|
|
||||||
Revision ID: 005_app_errors
|
|
||||||
Revises: 004_workflows
|
|
||||||
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 = "005_app_errors"
|
|
||||||
down_revision: Union[str, None] = "004_workflows"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"app_errors",
|
|
||||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"severity",
|
|
||||||
sa.String(length=20),
|
|
||||||
nullable=False,
|
|
||||||
server_default="error",
|
|
||||||
comment="error | warning | critical",
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"source",
|
|
||||||
sa.String(length=50),
|
|
||||||
nullable=False,
|
|
||||||
comment="backend | frontend | automation | middleware",
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"error_type",
|
|
||||||
sa.String(length=200),
|
|
||||||
nullable=False,
|
|
||||||
comment="Exception class name or error category",
|
|
||||||
),
|
|
||||||
sa.Column("message", sa.Text(), nullable=False),
|
|
||||||
sa.Column("stack_trace", sa.Text(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"context",
|
|
||||||
sa.JSON(),
|
|
||||||
nullable=True,
|
|
||||||
comment="Arbitrary JSON context",
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"correlation_id",
|
|
||||||
sa.String(length=36),
|
|
||||||
nullable=True,
|
|
||||||
comment="Request correlation ID",
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"resolved",
|
|
||||||
sa.Boolean(),
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("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_app_errors_correlation_id"),
|
|
||||||
"app_errors",
|
|
||||||
["correlation_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_app_errors_created_at"),
|
|
||||||
"app_errors",
|
|
||||||
["created_at"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_app_errors_severity"),
|
|
||||||
"app_errors",
|
|
||||||
["severity"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_app_errors_source"),
|
|
||||||
"app_errors",
|
|
||||||
["source"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index(op.f("ix_app_errors_source"), table_name="app_errors")
|
|
||||||
op.drop_index(op.f("ix_app_errors_severity"), table_name="app_errors")
|
|
||||||
op.drop_index(op.f("ix_app_errors_created_at"), table_name="app_errors")
|
|
||||||
op.drop_index(op.f("ix_app_errors_correlation_id"), table_name="app_errors")
|
|
||||||
op.drop_table("app_errors")
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
"""Add pipeline_configs, pipeline_runs tables for multi-character pipelines
|
|
||||||
|
|
||||||
Revision ID: 006_pipelines
|
|
||||||
Revises: 005_app_errors
|
|
||||||
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 = "006_pipelines"
|
|
||||||
down_revision: Union[str, None] = "005_app_errors"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# pipeline_configs
|
|
||||||
op.create_table(
|
|
||||||
"pipeline_configs",
|
|
||||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("name", sa.String(length=100), nullable=False),
|
|
||||||
sa.Column("description", sa.Text(), nullable=False, server_default=sa.text("''")),
|
|
||||||
sa.Column(
|
|
||||||
"stages",
|
|
||||||
sa.JSON(),
|
|
||||||
nullable=False,
|
|
||||||
comment="JSON array of pipeline stages with character_steps",
|
|
||||||
),
|
|
||||||
sa.Column("loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
|
||||||
sa.Column("max_loops", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# pipeline_runs
|
|
||||||
op.create_table(
|
|
||||||
"pipeline_runs",
|
|
||||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"pipeline_id",
|
|
||||||
sa.Integer(),
|
|
||||||
sa.ForeignKey("pipeline_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("current_stage_index", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
sa.Column(
|
|
||||||
"current_stage_id",
|
|
||||||
sa.String(length=100),
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("''"),
|
|
||||||
),
|
|
||||||
sa.Column("loop_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
sa.Column("total_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
|
||||||
sa.Column(
|
|
||||||
"character_states",
|
|
||||||
sa.JSON(),
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("'{}'::json"),
|
|
||||||
comment="Per-character state JSON",
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"stage_history",
|
|
||||||
sa.JSON(),
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("'[]'::json"),
|
|
||||||
comment="JSON array of completed stage records",
|
|
||||||
),
|
|
||||||
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("error_message", sa.Text(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_pipeline_runs_pipeline_id"),
|
|
||||||
"pipeline_runs",
|
|
||||||
["pipeline_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index(op.f("ix_pipeline_runs_pipeline_id"), table_name="pipeline_runs")
|
|
||||||
op.drop_table("pipeline_runs")
|
|
||||||
op.drop_table("pipeline_configs")
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"""Add user_token_hash column to app_errors for per-user scoping
|
|
||||||
|
|
||||||
Revision ID: 007_user_token_hash
|
|
||||||
Revises: 006_pipelines
|
|
||||||
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 = "007_user_token_hash"
|
|
||||||
down_revision: Union[str, None] = "006_pipelines"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"app_errors",
|
|
||||||
sa.Column(
|
|
||||||
"user_token_hash",
|
|
||||||
sa.String(length=64),
|
|
||||||
nullable=True,
|
|
||||||
comment="SHA-256 hash of the user API token",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
op.f("ix_app_errors_user_token_hash"),
|
|
||||||
"app_errors",
|
|
||||||
["user_token_hash"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index(op.f("ix_app_errors_user_token_hash"), table_name="app_errors")
|
|
||||||
op.drop_column("app_errors", "user_token_hash")
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"""Auth endpoints for per-user API token management.
|
"""Auth endpoints for runtime API token management.
|
||||||
|
|
||||||
Each user provides their own Artifacts API token via the frontend.
|
When no ARTIFACTS_TOKEN is set in the environment, users can provide
|
||||||
The token is stored in the browser's localStorage and sent with every
|
their own token through the UI. The token is stored in memory only
|
||||||
request as the ``X-API-Token`` header. The backend validates the token
|
and must be re-sent if the backend restarts.
|
||||||
but does NOT store it globally — this allows true multi-user support.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -13,6 +12,7 @@ from fastapi import APIRouter, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
class AuthStatus(BaseModel):
|
class AuthStatus(BaseModel):
|
||||||
has_token: bool
|
has_token: bool
|
||||||
source: str # "header", "env", or "none"
|
source: str # "env", "user", or "none"
|
||||||
|
|
||||||
|
|
||||||
class SetTokenRequest(BaseModel):
|
class SetTokenRequest(BaseModel):
|
||||||
|
|
@ -37,24 +37,15 @@ class SetTokenResponse(BaseModel):
|
||||||
|
|
||||||
@router.get("/status", response_model=AuthStatus)
|
@router.get("/status", response_model=AuthStatus)
|
||||||
async def auth_status(request: Request) -> AuthStatus:
|
async def auth_status(request: Request) -> AuthStatus:
|
||||||
"""Check whether the *requesting* client has a valid token.
|
client: ArtifactsClient = request.app.state.artifacts_client
|
||||||
|
return AuthStatus(
|
||||||
The frontend sends the token in the ``X-API-Token`` header.
|
has_token=client.has_token,
|
||||||
This endpoint tells the frontend whether that token is present.
|
source=client.token_source,
|
||||||
"""
|
)
|
||||||
token = request.headers.get("X-API-Token")
|
|
||||||
if token:
|
|
||||||
return AuthStatus(has_token=True, source="header")
|
|
||||||
return AuthStatus(has_token=False, source="none")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/token", response_model=SetTokenResponse)
|
@router.post("/token", response_model=SetTokenResponse)
|
||||||
async def validate_token(body: SetTokenRequest) -> SetTokenResponse:
|
async def set_token(body: SetTokenRequest, request: Request) -> SetTokenResponse:
|
||||||
"""Validate an Artifacts API token.
|
|
||||||
|
|
||||||
Does NOT store the token on the server. The frontend is responsible
|
|
||||||
for persisting it in localStorage and sending it with every request.
|
|
||||||
"""
|
|
||||||
token = body.token.strip()
|
token = body.token.strip()
|
||||||
if not token:
|
if not token:
|
||||||
return SetTokenResponse(success=False, source="none", error="Token cannot be empty")
|
return SetTokenResponse(success=False, source="none", error="Token cannot be empty")
|
||||||
|
|
@ -87,11 +78,37 @@ async def validate_token(body: SetTokenRequest) -> SetTokenResponse:
|
||||||
error="Could not validate token. Check your network connection.",
|
error="Could not validate token. Check your network connection.",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("API token validated via UI")
|
# Token is valid — apply it
|
||||||
|
client: ArtifactsClient = request.app.state.artifacts_client
|
||||||
|
client.set_token(token)
|
||||||
|
|
||||||
|
# Reconnect WebSocket with new token
|
||||||
|
game_ws_client = getattr(request.app.state, "game_ws_client", None)
|
||||||
|
if game_ws_client is not None:
|
||||||
|
try:
|
||||||
|
await game_ws_client.reconnect_with_token(token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to reconnect WebSocket with new token")
|
||||||
|
|
||||||
|
logger.info("API token updated via UI (source: user)")
|
||||||
return SetTokenResponse(success=True, source="user")
|
return SetTokenResponse(success=True, source="user")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/token")
|
@router.delete("/token")
|
||||||
async def clear_token() -> AuthStatus:
|
async def clear_token(request: Request) -> AuthStatus:
|
||||||
"""No-op on the backend — the frontend clears its own localStorage."""
|
client: ArtifactsClient = request.app.state.artifacts_client
|
||||||
return AuthStatus(has_token=False, source="none")
|
client.clear_token()
|
||||||
|
|
||||||
|
# Reconnect WebSocket with env token (or empty)
|
||||||
|
game_ws_client = getattr(request.app.state, "game_ws_client", None)
|
||||||
|
if game_ws_client is not None and settings.artifacts_token:
|
||||||
|
try:
|
||||||
|
await game_ws_client.reconnect_with_token(settings.artifacts_token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to reconnect WebSocket after token clear")
|
||||||
|
|
||||||
|
logger.info("API token cleared, reverted to env")
|
||||||
|
return AuthStatus(
|
||||||
|
has_token=client.has_token,
|
||||||
|
source=client.token_source,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import get_user_character_names
|
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
from app.engine.manager import AutomationManager
|
from app.engine.manager import AutomationManager
|
||||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||||
|
|
@ -47,14 +46,9 @@ def _get_manager(request: Request) -> AutomationManager:
|
||||||
|
|
||||||
@router.get("/", response_model=list[AutomationConfigResponse])
|
@router.get("/", response_model=list[AutomationConfigResponse])
|
||||||
async def list_configs(request: Request) -> list[AutomationConfigResponse]:
|
async def list_configs(request: Request) -> list[AutomationConfigResponse]:
|
||||||
"""List automation configurations belonging to the current user."""
|
"""List all automation configurations with their current status."""
|
||||||
user_chars = await get_user_character_names(request)
|
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
stmt = (
|
stmt = select(AutomationConfig).order_by(AutomationConfig.id)
|
||||||
select(AutomationConfig)
|
|
||||||
.where(AutomationConfig.character_name.in_(user_chars))
|
|
||||||
.order_by(AutomationConfig.id)
|
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
configs = result.scalars().all()
|
configs = result.scalars().all()
|
||||||
return [AutomationConfigResponse.model_validate(c) for c in configs]
|
return [AutomationConfigResponse.model_validate(c) for c in configs]
|
||||||
|
|
@ -208,10 +202,9 @@ async def resume_automation(config_id: int, request: Request) -> None:
|
||||||
|
|
||||||
@router.get("/status/all", response_model=list[AutomationStatusResponse])
|
@router.get("/status/all", response_model=list[AutomationStatusResponse])
|
||||||
async def get_all_statuses(request: Request) -> list[AutomationStatusResponse]:
|
async def get_all_statuses(request: Request) -> list[AutomationStatusResponse]:
|
||||||
"""Get live status for active automations belonging to the current user."""
|
"""Get live status for all active automations."""
|
||||||
manager = _get_manager(request)
|
manager = _get_manager(request)
|
||||||
user_chars = set(await get_user_character_names(request))
|
return manager.get_all_statuses()
|
||||||
return [s for s in manager.get_all_statuses() if s.character_name in user_chars]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{config_id}/status", response_model=AutomationStatusResponse)
|
@router.get("/{config_id}/status", response_model=AutomationStatusResponse)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ from fastapi import APIRouter, HTTPException, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.deps import get_user_client
|
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
from app.services.bank_service import BankService
|
from app.services.bank_service import BankService
|
||||||
from app.services.game_data_cache import GameDataCacheService
|
from app.services.game_data_cache import GameDataCacheService
|
||||||
|
|
||||||
|
|
@ -15,6 +15,10 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["bank"])
|
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:
|
def _get_cache_service(request: Request) -> GameDataCacheService:
|
||||||
return request.app.state.cache_service
|
return request.app.state.cache_service
|
||||||
|
|
||||||
|
|
@ -29,17 +33,11 @@ class ManualActionRequest(BaseModel):
|
||||||
|
|
||||||
action: str = Field(
|
action: str = Field(
|
||||||
...,
|
...,
|
||||||
description=(
|
description="Action to perform: 'move', 'fight', 'gather', 'rest'",
|
||||||
"Action to perform: move, fight, gather, rest, equip, unequip, "
|
|
||||||
"use_item, deposit, withdraw, deposit_gold, withdraw_gold, "
|
|
||||||
"craft, recycle, ge_buy, ge_create_buy, ge_sell, ge_fill, ge_cancel, "
|
|
||||||
"task_new, task_trade, task_complete, task_exchange, task_cancel, "
|
|
||||||
"npc_buy, npc_sell"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
params: dict = Field(
|
params: dict = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Action parameters (varies per action type)",
|
description="Action parameters (e.g. {x, y} for move)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -51,7 +49,7 @@ class ManualActionRequest(BaseModel):
|
||||||
@router.get("/bank")
|
@router.get("/bank")
|
||||||
async def get_bank(request: Request) -> dict[str, Any]:
|
async def get_bank(request: Request) -> dict[str, Any]:
|
||||||
"""Return bank details with enriched item data from game cache."""
|
"""Return bank details with enriched item data from game cache."""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
cache_service = _get_cache_service(request)
|
cache_service = _get_cache_service(request)
|
||||||
bank_service = BankService()
|
bank_service = BankService()
|
||||||
|
|
||||||
|
|
@ -77,7 +75,7 @@ async def get_bank(request: Request) -> dict[str, Any]:
|
||||||
@router.get("/bank/summary")
|
@router.get("/bank/summary")
|
||||||
async def get_bank_summary(request: Request) -> dict[str, Any]:
|
async def get_bank_summary(request: Request) -> dict[str, Any]:
|
||||||
"""Return a summary of bank contents: gold, item count, slots."""
|
"""Return a summary of bank contents: gold, item count, slots."""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
bank_service = BankService()
|
bank_service = BankService()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -89,16 +87,6 @@ async def get_bank_summary(request: Request) -> dict[str, Any]:
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _require(params: dict, *keys: str) -> None:
|
|
||||||
"""Raise 400 if any required key is missing from params."""
|
|
||||||
missing = [k for k in keys if params.get(k) is None]
|
|
||||||
if missing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Missing required params: {', '.join(missing)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/characters/{name}/action")
|
@router.post("/characters/{name}/action")
|
||||||
async def manual_action(
|
async def manual_action(
|
||||||
name: str,
|
name: str,
|
||||||
|
|
@ -107,154 +95,35 @@ async def manual_action(
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute a manual action on a character.
|
"""Execute a manual action on a character.
|
||||||
|
|
||||||
Supported actions and their params:
|
Supported actions:
|
||||||
- **move**: {x: int, y: int}
|
- **move**: Move to coordinates. Params: {"x": int, "y": int}
|
||||||
- **fight**: no params
|
- **fight**: Fight the monster at the current tile. No params.
|
||||||
- **gather**: no params
|
- **gather**: Gather the resource at the current tile. No params.
|
||||||
- **rest**: no params
|
- **rest**: Rest to recover HP. No params.
|
||||||
- **equip**: {code: str, slot: str, quantity?: int}
|
|
||||||
- **unequip**: {slot: str, quantity?: int}
|
|
||||||
- **use_item**: {code: str, quantity?: int}
|
|
||||||
- **deposit**: {code: str, quantity: int}
|
|
||||||
- **withdraw**: {code: str, quantity: int}
|
|
||||||
- **deposit_gold**: {quantity: int}
|
|
||||||
- **withdraw_gold**: {quantity: int}
|
|
||||||
- **craft**: {code: str, quantity?: int}
|
|
||||||
- **recycle**: {code: str, quantity?: int}
|
|
||||||
- **ge_buy**: {id: str, quantity: int} — buy from an existing sell order
|
|
||||||
- **ge_create_buy**: {code: str, quantity: int, price: int} — create a standing buy order
|
|
||||||
- **ge_sell**: {code: str, quantity: int, price: int} — create a sell order
|
|
||||||
- **ge_fill**: {id: str, quantity: int} — fill an existing buy order
|
|
||||||
- **ge_cancel**: {order_id: str}
|
|
||||||
- **task_new**: no params
|
|
||||||
- **task_trade**: {code: str, quantity: int}
|
|
||||||
- **task_complete**: no params
|
|
||||||
- **task_exchange**: no params
|
|
||||||
- **task_cancel**: no params
|
|
||||||
- **npc_buy**: {code: str, quantity: int}
|
|
||||||
- **npc_sell**: {code: str, quantity: int}
|
|
||||||
"""
|
"""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
p = body.params
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
match body.action:
|
match body.action:
|
||||||
# --- Basic actions ---
|
|
||||||
case "move":
|
case "move":
|
||||||
_require(p, "x", "y")
|
x = body.params.get("x")
|
||||||
result = await client.move(name, int(p["x"]), int(p["y"]))
|
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":
|
case "fight":
|
||||||
result = await client.fight(name)
|
result = await client.fight(name)
|
||||||
case "gather":
|
case "gather":
|
||||||
result = await client.gather(name)
|
result = await client.gather(name)
|
||||||
case "rest":
|
case "rest":
|
||||||
result = await client.rest(name)
|
result = await client.rest(name)
|
||||||
|
|
||||||
# --- Equipment ---
|
|
||||||
case "equip":
|
|
||||||
_require(p, "code", "slot")
|
|
||||||
result = await client.equip(
|
|
||||||
name, p["code"], p["slot"], int(p.get("quantity", 1))
|
|
||||||
)
|
|
||||||
case "unequip":
|
|
||||||
_require(p, "slot")
|
|
||||||
result = await client.unequip(
|
|
||||||
name, p["slot"], int(p.get("quantity", 1))
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Consumables ---
|
|
||||||
case "use_item":
|
|
||||||
_require(p, "code")
|
|
||||||
result = await client.use_item(
|
|
||||||
name, p["code"], int(p.get("quantity", 1))
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Bank ---
|
|
||||||
case "deposit":
|
|
||||||
_require(p, "code", "quantity")
|
|
||||||
result = await client.deposit_item(
|
|
||||||
name, p["code"], int(p["quantity"])
|
|
||||||
)
|
|
||||||
case "withdraw":
|
|
||||||
_require(p, "code", "quantity")
|
|
||||||
result = await client.withdraw_item(
|
|
||||||
name, p["code"], int(p["quantity"])
|
|
||||||
)
|
|
||||||
case "deposit_gold":
|
|
||||||
_require(p, "quantity")
|
|
||||||
result = await client.deposit_gold(name, int(p["quantity"]))
|
|
||||||
case "withdraw_gold":
|
|
||||||
_require(p, "quantity")
|
|
||||||
result = await client.withdraw_gold(name, int(p["quantity"]))
|
|
||||||
|
|
||||||
# --- Crafting ---
|
|
||||||
case "craft":
|
|
||||||
_require(p, "code")
|
|
||||||
result = await client.craft(
|
|
||||||
name, p["code"], int(p.get("quantity", 1))
|
|
||||||
)
|
|
||||||
case "recycle":
|
|
||||||
_require(p, "code")
|
|
||||||
result = await client.recycle(
|
|
||||||
name, p["code"], int(p.get("quantity", 1))
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Grand Exchange ---
|
|
||||||
case "ge_buy":
|
|
||||||
_require(p, "id", "quantity")
|
|
||||||
result = await client.ge_buy(
|
|
||||||
name, str(p["id"]), int(p["quantity"])
|
|
||||||
)
|
|
||||||
case "ge_create_buy":
|
|
||||||
_require(p, "code", "quantity", "price")
|
|
||||||
result = await client.ge_create_buy_order(
|
|
||||||
name, p["code"], int(p["quantity"]), int(p["price"])
|
|
||||||
)
|
|
||||||
case "ge_sell":
|
|
||||||
_require(p, "code", "quantity", "price")
|
|
||||||
result = await client.ge_sell_order(
|
|
||||||
name, p["code"], int(p["quantity"]), int(p["price"])
|
|
||||||
)
|
|
||||||
case "ge_fill":
|
|
||||||
_require(p, "id", "quantity")
|
|
||||||
result = await client.ge_fill_buy_order(
|
|
||||||
name, str(p["id"]), int(p["quantity"])
|
|
||||||
)
|
|
||||||
case "ge_cancel":
|
|
||||||
_require(p, "order_id")
|
|
||||||
result = await client.ge_cancel(name, p["order_id"])
|
|
||||||
|
|
||||||
# --- Tasks ---
|
|
||||||
case "task_new":
|
|
||||||
result = await client.task_new(name)
|
|
||||||
case "task_trade":
|
|
||||||
_require(p, "code", "quantity")
|
|
||||||
result = await client.task_trade(
|
|
||||||
name, p["code"], int(p["quantity"])
|
|
||||||
)
|
|
||||||
case "task_complete":
|
|
||||||
result = await client.task_complete(name)
|
|
||||||
case "task_exchange":
|
|
||||||
result = await client.task_exchange(name)
|
|
||||||
case "task_cancel":
|
|
||||||
result = await client.task_cancel(name)
|
|
||||||
|
|
||||||
# --- NPC ---
|
|
||||||
case "npc_buy":
|
|
||||||
_require(p, "code", "quantity")
|
|
||||||
result = await client.npc_buy(
|
|
||||||
name, p["code"], int(p["quantity"])
|
|
||||||
)
|
|
||||||
case "npc_sell":
|
|
||||||
_require(p, "code", "quantity")
|
|
||||||
result = await client.npc_sell(
|
|
||||||
name, p["code"], int(p["quantity"])
|
|
||||||
)
|
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Unknown action: {body.action!r}",
|
detail=f"Unknown action: {body.action!r}. Supported: move, fight, gather, rest",
|
||||||
)
|
)
|
||||||
except HTTPStatusError as exc:
|
except HTTPStatusError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
from app.api.deps import get_user_client
|
|
||||||
from app.schemas.game import CharacterSchema
|
from app.schemas.game import CharacterSchema
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
from app.services.character_service import CharacterService
|
from app.services.character_service import CharacterService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/characters", tags=["characters"])
|
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:
|
def _get_service(request: Request) -> CharacterService:
|
||||||
return request.app.state.character_service
|
return request.app.state.character_service
|
||||||
|
|
||||||
|
|
@ -15,7 +19,7 @@ def _get_service(request: Request) -> CharacterService:
|
||||||
@router.get("/", response_model=list[CharacterSchema])
|
@router.get("/", response_model=list[CharacterSchema])
|
||||||
async def list_characters(request: Request) -> list[CharacterSchema]:
|
async def list_characters(request: Request) -> list[CharacterSchema]:
|
||||||
"""Return all characters belonging to the authenticated account."""
|
"""Return all characters belonging to the authenticated account."""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
try:
|
try:
|
||||||
return await service.get_all(client)
|
return await service.get_all(client)
|
||||||
|
|
@ -29,7 +33,7 @@ async def list_characters(request: Request) -> list[CharacterSchema]:
|
||||||
@router.get("/{name}", response_model=CharacterSchema)
|
@router.get("/{name}", response_model=CharacterSchema)
|
||||||
async def get_character(name: str, request: Request) -> CharacterSchema:
|
async def get_character(name: str, request: Request) -> CharacterSchema:
|
||||||
"""Return a single character by name."""
|
"""Return a single character by name."""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
try:
|
try:
|
||||||
return await service.get_one(client, name)
|
return await service.get_one(client, name)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import logging
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
from app.api.deps import get_user_client
|
|
||||||
from app.schemas.game import DashboardData
|
from app.schemas.game import DashboardData
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
from app.services.character_service import CharacterService
|
from app.services.character_service import CharacterService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -12,6 +12,10 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["dashboard"])
|
router = APIRouter(prefix="/api", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client(request: Request) -> ArtifactsClient:
|
||||||
|
return request.app.state.artifacts_client
|
||||||
|
|
||||||
|
|
||||||
def _get_service(request: Request) -> CharacterService:
|
def _get_service(request: Request) -> CharacterService:
|
||||||
return request.app.state.character_service
|
return request.app.state.character_service
|
||||||
|
|
||||||
|
|
@ -19,7 +23,7 @@ def _get_service(request: Request) -> CharacterService:
|
||||||
@router.get("/dashboard", response_model=DashboardData)
|
@router.get("/dashboard", response_model=DashboardData)
|
||||||
async def get_dashboard(request: Request) -> DashboardData:
|
async def get_dashboard(request: Request) -> DashboardData:
|
||||||
"""Return aggregated dashboard data: all characters + server status."""
|
"""Return aggregated dashboard data: all characters + server status."""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
service = _get_service(request)
|
service = _get_service(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"""Shared FastAPI dependencies for API endpoints."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
|
||||||
|
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_client(request: Request) -> ArtifactsClient:
|
|
||||||
"""Return an ArtifactsClient scoped to the requesting user's token.
|
|
||||||
|
|
||||||
Reads the ``X-API-Token`` header sent by the frontend and creates a
|
|
||||||
lightweight clone of the global client that uses that token. Falls
|
|
||||||
back to the global client when no per-request token is provided (e.g.
|
|
||||||
for public / unauthenticated endpoints).
|
|
||||||
"""
|
|
||||||
token = request.headers.get("X-API-Token")
|
|
||||||
base_client: ArtifactsClient = request.app.state.artifacts_client
|
|
||||||
|
|
||||||
if token:
|
|
||||||
return base_client.with_token(token)
|
|
||||||
|
|
||||||
# No per-request token — use the global client if it has a token
|
|
||||||
if base_client.has_token:
|
|
||||||
return base_client
|
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="No API token provided")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_character_names(request: Request) -> list[str]:
|
|
||||||
"""Return the character names belonging to the requesting user.
|
|
||||||
|
|
||||||
Calls the Artifacts API with the user's token to get their characters,
|
|
||||||
then returns just the names. Used to scope DB queries to a single user.
|
|
||||||
"""
|
|
||||||
client = get_user_client(request)
|
|
||||||
characters = await client.get_characters()
|
|
||||||
return [c.name for c in characters]
|
|
||||||
|
|
||||||
|
|
||||||
def get_token_hash(request: Request) -> str | None:
|
|
||||||
"""Return a SHA-256 hash of the user's API token, or None."""
|
|
||||||
token = request.headers.get("X-API-Token")
|
|
||||||
if token:
|
|
||||||
return hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
return None
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
"""Errors API router - browse, filter, resolve, and report errors.
|
|
||||||
|
|
||||||
All read endpoints are scoped to the requesting user's token so that
|
|
||||||
one user never sees errors belonging to another user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.database import async_session_factory
|
|
||||||
from app.models.app_error import AppError
|
|
||||||
from app.schemas.errors import (
|
|
||||||
AppErrorListResponse,
|
|
||||||
AppErrorResponse,
|
|
||||||
AppErrorStats,
|
|
||||||
FrontendErrorReport,
|
|
||||||
)
|
|
||||||
from app.services.error_service import hash_token, log_error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/errors", tags=["errors"])
|
|
||||||
|
|
||||||
|
|
||||||
def _get_user_hash(request: Request) -> str | None:
|
|
||||||
"""Extract and hash the user token from the request."""
|
|
||||||
token = request.headers.get("X-API-Token")
|
|
||||||
return hash_token(token) if token else None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=AppErrorListResponse)
|
|
||||||
async def list_errors(
|
|
||||||
request: Request,
|
|
||||||
severity: str = Query(default="", description="Filter by severity"),
|
|
||||||
source: str = Query(default="", description="Filter by source"),
|
|
||||||
resolved: str = Query(default="", description="Filter by resolved status: true/false"),
|
|
||||||
page: int = Query(default=1, ge=1),
|
|
||||||
size: int = Query(default=50, ge=1, le=100),
|
|
||||||
) -> AppErrorListResponse:
|
|
||||||
"""List errors with optional filtering and pagination.
|
|
||||||
|
|
||||||
Only returns errors belonging to the authenticated user.
|
|
||||||
"""
|
|
||||||
user_hash = _get_user_hash(request)
|
|
||||||
if not user_hash:
|
|
||||||
return AppErrorListResponse(errors=[], total=0, page=1, pages=1)
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
user_filter = AppError.user_token_hash == user_hash
|
|
||||||
stmt = select(AppError).where(user_filter).order_by(AppError.created_at.desc())
|
|
||||||
count_stmt = select(func.count(AppError.id)).where(user_filter)
|
|
||||||
|
|
||||||
if severity:
|
|
||||||
stmt = stmt.where(AppError.severity == severity)
|
|
||||||
count_stmt = count_stmt.where(AppError.severity == severity)
|
|
||||||
if source:
|
|
||||||
stmt = stmt.where(AppError.source == source)
|
|
||||||
count_stmt = count_stmt.where(AppError.source == source)
|
|
||||||
if resolved in ("true", "false"):
|
|
||||||
val = resolved == "true"
|
|
||||||
stmt = stmt.where(AppError.resolved == val)
|
|
||||||
count_stmt = count_stmt.where(AppError.resolved == val)
|
|
||||||
|
|
||||||
total = (await db.execute(count_stmt)).scalar() or 0
|
|
||||||
pages = max(1, (total + size - 1) // size)
|
|
||||||
|
|
||||||
offset = (page - 1) * size
|
|
||||||
stmt = stmt.offset(offset).limit(size)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
rows = result.scalars().all()
|
|
||||||
|
|
||||||
return AppErrorListResponse(
|
|
||||||
errors=[AppErrorResponse.model_validate(r) for r in rows],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
pages=pages,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=AppErrorStats)
|
|
||||||
async def error_stats(request: Request) -> AppErrorStats:
|
|
||||||
"""Aggregated error statistics scoped to the authenticated user."""
|
|
||||||
user_hash = _get_user_hash(request)
|
|
||||||
if not user_hash:
|
|
||||||
return AppErrorStats()
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
user_filter = AppError.user_token_hash == user_hash
|
|
||||||
|
|
||||||
total = (
|
|
||||||
await db.execute(select(func.count(AppError.id)).where(user_filter))
|
|
||||||
).scalar() or 0
|
|
||||||
unresolved = (
|
|
||||||
await db.execute(
|
|
||||||
select(func.count(AppError.id)).where(
|
|
||||||
user_filter, AppError.resolved == False # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
||||||
last_hour = (
|
|
||||||
await db.execute(
|
|
||||||
select(func.count(AppError.id)).where(
|
|
||||||
user_filter, AppError.created_at >= one_hour_ago
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
# By severity
|
|
||||||
sev_rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(AppError.severity, func.count(AppError.id))
|
|
||||||
.where(user_filter)
|
|
||||||
.group_by(AppError.severity)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
by_severity = {row[0]: row[1] for row in sev_rows}
|
|
||||||
|
|
||||||
# By source
|
|
||||||
src_rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(AppError.source, func.count(AppError.id))
|
|
||||||
.where(user_filter)
|
|
||||||
.group_by(AppError.source)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
by_source = {row[0]: row[1] for row in src_rows}
|
|
||||||
|
|
||||||
return AppErrorStats(
|
|
||||||
total=total,
|
|
||||||
unresolved=unresolved,
|
|
||||||
last_hour=last_hour,
|
|
||||||
by_severity=by_severity,
|
|
||||||
by_source=by_source,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{error_id}/resolve", response_model=AppErrorResponse)
|
|
||||||
async def resolve_error(error_id: int, request: Request) -> AppErrorResponse:
|
|
||||||
"""Mark an error as resolved (only if it belongs to the requesting user)."""
|
|
||||||
user_hash = _get_user_hash(request)
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
stmt = select(AppError).where(AppError.id == error_id)
|
|
||||||
if user_hash:
|
|
||||||
stmt = stmt.where(AppError.user_token_hash == user_hash)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
record = result.scalar_one_or_none()
|
|
||||||
if record is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Error not found")
|
|
||||||
record.resolved = True
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(record)
|
|
||||||
return AppErrorResponse.model_validate(record)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/report", status_code=201)
|
|
||||||
async def report_frontend_error(
|
|
||||||
body: FrontendErrorReport, request: Request
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Receive error reports from the frontend."""
|
|
||||||
user_hash = _get_user_hash(request)
|
|
||||||
|
|
||||||
await log_error(
|
|
||||||
async_session_factory,
|
|
||||||
severity=body.severity,
|
|
||||||
source="frontend",
|
|
||||||
error_type=body.error_type,
|
|
||||||
message=body.message,
|
|
||||||
context=body.context,
|
|
||||||
user_token_hash=user_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Also capture in Sentry if available
|
|
||||||
try:
|
|
||||||
import sentry_sdk
|
|
||||||
|
|
||||||
sentry_sdk.capture_message(
|
|
||||||
f"[Frontend] {body.error_type}: {body.message}",
|
|
||||||
level="error",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"status": "recorded"}
|
|
||||||
|
|
@ -7,19 +7,23 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.deps import get_user_client
|
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
from app.models.event_log import EventLog
|
from app.models.event_log import EventLog
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/events", tags=["events"])
|
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client(request: Request) -> ArtifactsClient:
|
||||||
|
return request.app.state.artifacts_client
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_active_events(request: Request) -> dict[str, Any]:
|
async def get_active_events(request: Request) -> dict[str, Any]:
|
||||||
"""Get currently active game events from the Artifacts API."""
|
"""Get currently active game events from the Artifacts API."""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
events = await client.get_events()
|
events = await client.get_events()
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ from typing import Any
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
from app.api.deps import get_user_client
|
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
from app.services.exchange_service import ExchangeService
|
from app.services.exchange_service import ExchangeService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -15,6 +15,10 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
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:
|
def _get_exchange_service(request: Request) -> ExchangeService:
|
||||||
service: ExchangeService | None = getattr(request.app.state, "exchange_service", None)
|
service: ExchangeService | None = getattr(request.app.state, "exchange_service", None)
|
||||||
if service is None:
|
if service is None:
|
||||||
|
|
@ -26,34 +30,13 @@ def _get_exchange_service(request: Request) -> ExchangeService:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orders")
|
@router.get("/orders")
|
||||||
async def browse_orders(
|
async def get_orders(request: Request) -> dict[str, Any]:
|
||||||
request: Request,
|
"""Get all active Grand Exchange orders."""
|
||||||
code: str | None = Query(default=None, description="Filter by item code"),
|
client = _get_client(request)
|
||||||
type: str | None = Query(default=None, description="Filter by order type (sell or buy)"),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Browse all active Grand Exchange orders (public market data)."""
|
|
||||||
client = get_user_client(request)
|
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
orders = await service.browse_orders(client, code=code, order_type=type)
|
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("/my-orders")
|
|
||||||
async def get_my_orders(request: Request) -> dict[str, Any]:
|
|
||||||
"""Get the authenticated account's own active GE orders."""
|
|
||||||
client = get_user_client(request)
|
|
||||||
service = _get_exchange_service(request)
|
|
||||||
|
|
||||||
try:
|
|
||||||
orders = await service.get_my_orders(client)
|
|
||||||
except HTTPStatusError as exc:
|
except HTTPStatusError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=exc.response.status_code,
|
status_code=exc.response.status_code,
|
||||||
|
|
@ -65,8 +48,8 @@ async def get_my_orders(request: Request) -> dict[str, Any]:
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
async def get_history(request: Request) -> dict[str, Any]:
|
async def get_history(request: Request) -> dict[str, Any]:
|
||||||
"""Get the authenticated account's GE transaction history."""
|
"""Get Grand Exchange transaction history."""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -80,30 +63,13 @@ async def get_history(request: Request) -> dict[str, Any]:
|
||||||
return {"history": history}
|
return {"history": history}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sell-history/{item_code}")
|
|
||||||
async def get_sell_history(item_code: str, request: Request) -> dict[str, Any]:
|
|
||||||
"""Get public sale history for a specific item (last 7 days from API)."""
|
|
||||||
client = get_user_client(request)
|
|
||||||
service = _get_exchange_service(request)
|
|
||||||
|
|
||||||
try:
|
|
||||||
history = await service.get_sell_history(client, item_code)
|
|
||||||
except HTTPStatusError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=exc.response.status_code,
|
|
||||||
detail=f"Artifacts API error: {exc.response.text}",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
return {"item_code": item_code, "history": history}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/prices/{item_code}")
|
@router.get("/prices/{item_code}")
|
||||||
async def get_price_history(
|
async def get_price_history(
|
||||||
item_code: str,
|
item_code: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
days: int = Query(default=7, ge=1, le=90, description="Number of days of history"),
|
days: int = Query(default=7, ge=1, le=90, description="Number of days of history"),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Get locally captured price history for a specific item."""
|
"""Get price history for a specific item."""
|
||||||
service = _get_exchange_service(request)
|
service = _get_exchange_service(request)
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
|
|
|
||||||
|
|
@ -3,173 +3,76 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from sqlalchemy import select
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
from app.api.deps import get_user_character_names, get_user_client
|
|
||||||
from app.database import async_session_factory
|
from app.database import async_session_factory
|
||||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
|
||||||
from app.services.analytics_service import AnalyticsService
|
from app.services.analytics_service import AnalyticsService
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client(request: Request) -> ArtifactsClient:
|
||||||
|
return request.app.state.artifacts_client
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_logs(
|
async def get_character_logs(
|
||||||
request: Request,
|
request: Request,
|
||||||
character: str = Query(default="", description="Character name to filter logs"),
|
character: str = Query(default="", description="Character name to filter logs"),
|
||||||
type: str = Query(default="", description="Action type to filter (e.g. fight, gathering)"),
|
limit: int = Query(default=50, ge=1, le=200, description="Max entries to return"),
|
||||||
page: int = Query(default=1, ge=1, description="Page number"),
|
|
||||||
size: int = Query(default=50, ge=1, le=100, description="Page size"),
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Get action logs from the Artifacts game API.
|
"""Get character action logs from the Artifacts API.
|
||||||
|
|
||||||
Fetches the last 5000 character actions directly from the game server.
|
This endpoint retrieves the character's recent action logs directly
|
||||||
Falls back to local automation logs if the game API is unavailable.
|
from the game server.
|
||||||
"""
|
"""
|
||||||
client = get_user_client(request)
|
client = _get_client(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if character:
|
if character:
|
||||||
result = await client.get_character_logs(character, page=page, size=size)
|
# Get logs for a specific character
|
||||||
else:
|
char_data = await client.get_character(character)
|
||||||
result = await client.get_logs(page=page, size=size)
|
return {
|
||||||
|
"character": character,
|
||||||
raw_logs = result.get("data", [])
|
"logs": [], # The API doesn't have a dedicated logs endpoint per character;
|
||||||
total = result.get("total", 0)
|
# action data comes from the automation logs in our DB
|
||||||
pages = result.get("pages", 1)
|
"character_data": {
|
||||||
|
"name": char_data.name,
|
||||||
# Filter by type if specified
|
"level": char_data.level,
|
||||||
if type:
|
"xp": char_data.xp,
|
||||||
raw_logs = [log for log in raw_logs if log.get("type") == type]
|
"gold": char_data.gold,
|
||||||
|
"x": char_data.x,
|
||||||
logs = []
|
"y": char_data.y,
|
||||||
for entry in raw_logs:
|
"task": char_data.task,
|
||||||
content = entry.get("content", {})
|
"task_progress": char_data.task_progress,
|
||||||
action_type = entry.get("type", "unknown")
|
"task_total": char_data.task_total,
|
||||||
|
},
|
||||||
# Build details - description is the main human-readable field
|
|
||||||
details: dict[str, Any] = {}
|
|
||||||
description = entry.get("description", "")
|
|
||||||
if description:
|
|
||||||
details["description"] = description
|
|
||||||
|
|
||||||
# Extract structured data per action type
|
|
||||||
if "fight" in content:
|
|
||||||
fight = content["fight"]
|
|
||||||
details["monster"] = fight.get("opponent", "")
|
|
||||||
details["result"] = fight.get("result", "")
|
|
||||||
details["turns"] = fight.get("turns", 0)
|
|
||||||
|
|
||||||
if "gathering" in content:
|
|
||||||
g = content["gathering"]
|
|
||||||
details["resource"] = g.get("resource", "")
|
|
||||||
details["skill"] = g.get("skill", "")
|
|
||||||
details["xp"] = g.get("xp_gained", 0)
|
|
||||||
|
|
||||||
if "drops" in content:
|
|
||||||
items = content["drops"].get("items", [])
|
|
||||||
if items:
|
|
||||||
details["drops"] = [
|
|
||||||
f"{i.get('quantity', 1)}x {i.get('code', '?')}" for i in items
|
|
||||||
]
|
|
||||||
|
|
||||||
if "map" in content:
|
|
||||||
m = content["map"]
|
|
||||||
details["x"] = m.get("x")
|
|
||||||
details["y"] = m.get("y")
|
|
||||||
details["map_name"] = m.get("name", "")
|
|
||||||
|
|
||||||
if "crafting" in content:
|
|
||||||
c = content["crafting"]
|
|
||||||
details["item"] = c.get("code", "")
|
|
||||||
details["skill"] = c.get("skill", "")
|
|
||||||
details["xp"] = c.get("xp_gained", 0)
|
|
||||||
|
|
||||||
if "hp_restored" in content:
|
|
||||||
details["hp_restored"] = content["hp_restored"]
|
|
||||||
|
|
||||||
logs.append({
|
|
||||||
"id": hash(f"{entry.get('character', '')}-{entry.get('created_at', '')}") & 0x7FFFFFFF,
|
|
||||||
"character_name": entry.get("character", ""),
|
|
||||||
"action_type": action_type,
|
|
||||||
"details": details,
|
|
||||||
"success": True,
|
|
||||||
"created_at": entry.get("created_at", ""),
|
|
||||||
"cooldown": entry.get("cooldown", 0),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"logs": logs,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"pages": pages,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to fetch logs from game API, falling back to local DB", exc_info=True)
|
|
||||||
user_chars = await get_user_character_names(request)
|
|
||||||
return await _get_local_logs(character, type, page, size, user_chars)
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_local_logs(
|
|
||||||
character: str,
|
|
||||||
type: str,
|
|
||||||
page: int,
|
|
||||||
size: int,
|
|
||||||
user_characters: list[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Fallback: get automation logs from local database."""
|
|
||||||
offset = (page - 1) * size
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
stmt = (
|
|
||||||
select(
|
|
||||||
AutomationLog.id,
|
|
||||||
AutomationLog.action_type,
|
|
||||||
AutomationLog.details,
|
|
||||||
AutomationLog.success,
|
|
||||||
AutomationLog.created_at,
|
|
||||||
AutomationConfig.character_name,
|
|
||||||
)
|
|
||||||
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
|
|
||||||
.join(AutomationConfig, AutomationRun.config_id == AutomationConfig.id)
|
|
||||||
.order_by(AutomationLog.created_at.desc())
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scope to the current user's characters
|
|
||||||
if user_characters is not None:
|
|
||||||
stmt = stmt.where(AutomationConfig.character_name.in_(user_characters))
|
|
||||||
|
|
||||||
if character:
|
|
||||||
stmt = stmt.where(AutomationConfig.character_name == character)
|
|
||||||
|
|
||||||
if type:
|
|
||||||
stmt = stmt.where(AutomationLog.action_type == type)
|
|
||||||
|
|
||||||
stmt = stmt.offset(offset).limit(size)
|
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"logs": [
|
|
||||||
{
|
|
||||||
"id": row.id,
|
|
||||||
"character_name": row.character_name,
|
|
||||||
"action_type": row.action_type,
|
|
||||||
"details": row.details,
|
|
||||||
"success": row.success,
|
|
||||||
"created_at": row.created_at.isoformat(),
|
|
||||||
}
|
}
|
||||||
for row in rows
|
else:
|
||||||
],
|
# Get all characters as a summary
|
||||||
"total": len(rows),
|
characters = await client.get_characters()
|
||||||
"page": page,
|
return {
|
||||||
"pages": 1,
|
"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")
|
@router.get("/analytics")
|
||||||
|
|
@ -181,21 +84,15 @@ async def get_analytics(
|
||||||
"""Get analytics aggregations for a character.
|
"""Get analytics aggregations for a character.
|
||||||
|
|
||||||
Returns XP history, gold history, and estimated actions per hour.
|
Returns XP history, gold history, and estimated actions per hour.
|
||||||
If no character is specified, aggregates across the current user's characters.
|
If no character is specified, aggregates across all characters with snapshots.
|
||||||
"""
|
"""
|
||||||
analytics = AnalyticsService()
|
analytics = AnalyticsService()
|
||||||
user_chars = await get_user_character_names(request)
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
if character:
|
if character:
|
||||||
# Verify the requested character belongs to the current user
|
|
||||||
if character not in user_chars:
|
|
||||||
return {"xp_history": [], "gold_history": [], "actions_per_hour": 0}
|
|
||||||
characters = [character]
|
characters = [character]
|
||||||
else:
|
else:
|
||||||
# Only aggregate characters belonging to the current user
|
characters = await analytics.get_tracked_characters(db)
|
||||||
tracked = await analytics.get_tracked_characters(db)
|
|
||||||
characters = [c for c in tracked if c in user_chars]
|
|
||||||
|
|
||||||
all_xp: list[dict[str, Any]] = []
|
all_xp: list[dict[str, Any]] = []
|
||||||
all_gold: list[dict[str, Any]] = []
|
all_gold: list[dict[str, Any]] = []
|
||||||
|
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.api.deps import get_user_character_names
|
|
||||||
from app.database import async_session_factory
|
|
||||||
from app.engine.manager import AutomationManager
|
|
||||||
from app.models.automation import AutomationLog
|
|
||||||
from app.models.pipeline import PipelineConfig, PipelineRun
|
|
||||||
from app.schemas.automation import AutomationLogResponse
|
|
||||||
from app.schemas.pipeline import (
|
|
||||||
PipelineConfigCreate,
|
|
||||||
PipelineConfigDetailResponse,
|
|
||||||
PipelineConfigResponse,
|
|
||||||
PipelineConfigUpdate,
|
|
||||||
PipelineRunResponse,
|
|
||||||
PipelineStatusResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/pipelines", tags=["pipelines"])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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 -- Pipeline Configs
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _pipeline_belongs_to_user(pipeline: PipelineConfig, user_chars: set[str]) -> bool:
|
|
||||||
"""Check if any character in the pipeline stages belongs to the user."""
|
|
||||||
for stage in (pipeline.stages or []):
|
|
||||||
for step in (stage.get("character_steps") or []):
|
|
||||||
if step.get("character_name") in user_chars:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[PipelineConfigResponse])
|
|
||||||
async def list_pipelines(request: Request) -> list[PipelineConfigResponse]:
|
|
||||||
"""List pipeline configurations belonging to the current user."""
|
|
||||||
user_chars = set(await get_user_character_names(request))
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
stmt = select(PipelineConfig).order_by(PipelineConfig.id)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
configs = result.scalars().all()
|
|
||||||
return [
|
|
||||||
PipelineConfigResponse.model_validate(c)
|
|
||||||
for c in configs
|
|
||||||
if _pipeline_belongs_to_user(c, user_chars)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=PipelineConfigResponse, status_code=201)
|
|
||||||
async def create_pipeline(
|
|
||||||
payload: PipelineConfigCreate,
|
|
||||||
request: Request,
|
|
||||||
) -> PipelineConfigResponse:
|
|
||||||
"""Create a new pipeline configuration."""
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = PipelineConfig(
|
|
||||||
name=payload.name,
|
|
||||||
description=payload.description,
|
|
||||||
stages=[stage.model_dump() for stage in payload.stages],
|
|
||||||
loop=payload.loop,
|
|
||||||
max_loops=payload.max_loops,
|
|
||||||
)
|
|
||||||
db.add(config)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(config)
|
|
||||||
return PipelineConfigResponse.model_validate(config)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status/all", response_model=list[PipelineStatusResponse])
|
|
||||||
async def get_all_pipeline_statuses(request: Request) -> list[PipelineStatusResponse]:
|
|
||||||
"""Get live status for active pipelines belonging to the current user."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
user_chars = set(await get_user_character_names(request))
|
|
||||||
return [
|
|
||||||
s for s in manager.get_all_pipeline_statuses()
|
|
||||||
if any(cs.character_name in user_chars for cs in s.character_states)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{pipeline_id}", response_model=PipelineConfigDetailResponse)
|
|
||||||
async def get_pipeline(pipeline_id: int, request: Request) -> PipelineConfigDetailResponse:
|
|
||||||
"""Get a pipeline configuration with its run history."""
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
stmt = (
|
|
||||||
select(PipelineConfig)
|
|
||||||
.options(selectinload(PipelineConfig.runs))
|
|
||||||
.where(PipelineConfig.id == pipeline_id)
|
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
config = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
||||||
|
|
||||||
return PipelineConfigDetailResponse(
|
|
||||||
config=PipelineConfigResponse.model_validate(config),
|
|
||||||
runs=[PipelineRunResponse.model_validate(r) for r in config.runs],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{pipeline_id}", response_model=PipelineConfigResponse)
|
|
||||||
async def update_pipeline(
|
|
||||||
pipeline_id: int,
|
|
||||||
payload: PipelineConfigUpdate,
|
|
||||||
request: Request,
|
|
||||||
) -> PipelineConfigResponse:
|
|
||||||
"""Update a pipeline configuration. Cannot update while running."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
if manager.is_pipeline_running(pipeline_id):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="Cannot update a pipeline while it is running. Stop it first.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = await db.get(PipelineConfig, pipeline_id)
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
||||||
|
|
||||||
if payload.name is not None:
|
|
||||||
config.name = payload.name
|
|
||||||
if payload.description is not None:
|
|
||||||
config.description = payload.description
|
|
||||||
if payload.stages is not None:
|
|
||||||
config.stages = [stage.model_dump() for stage in payload.stages]
|
|
||||||
if payload.loop is not None:
|
|
||||||
config.loop = payload.loop
|
|
||||||
if payload.max_loops is not None:
|
|
||||||
config.max_loops = payload.max_loops
|
|
||||||
if payload.enabled is not None:
|
|
||||||
config.enabled = payload.enabled
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(config)
|
|
||||||
return PipelineConfigResponse.model_validate(config)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{pipeline_id}", status_code=204)
|
|
||||||
async def delete_pipeline(pipeline_id: int, request: Request) -> None:
|
|
||||||
"""Delete a pipeline configuration. Cannot delete while running."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
if manager.is_pipeline_running(pipeline_id):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="Cannot delete a pipeline while it is running. Stop it first.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = await db.get(PipelineConfig, pipeline_id)
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
||||||
await db.delete(config)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Control -- Start / Stop / Pause / Resume
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{pipeline_id}/start", response_model=PipelineRunResponse)
|
|
||||||
async def start_pipeline(pipeline_id: int, request: Request) -> PipelineRunResponse:
|
|
||||||
"""Start a pipeline from its configuration."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
try:
|
|
||||||
return await manager.start_pipeline(pipeline_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{pipeline_id}/stop", status_code=204)
|
|
||||||
async def stop_pipeline(pipeline_id: int, request: Request) -> None:
|
|
||||||
"""Stop a running pipeline."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
try:
|
|
||||||
await manager.stop_pipeline(pipeline_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{pipeline_id}/pause", status_code=204)
|
|
||||||
async def pause_pipeline(pipeline_id: int, request: Request) -> None:
|
|
||||||
"""Pause a running pipeline."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
try:
|
|
||||||
await manager.pause_pipeline(pipeline_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{pipeline_id}/resume", status_code=204)
|
|
||||||
async def resume_pipeline(pipeline_id: int, request: Request) -> None:
|
|
||||||
"""Resume a paused pipeline."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
try:
|
|
||||||
await manager.resume_pipeline(pipeline_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Status & Logs
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{pipeline_id}/status", response_model=PipelineStatusResponse)
|
|
||||||
async def get_pipeline_status(
|
|
||||||
pipeline_id: int,
|
|
||||||
request: Request,
|
|
||||||
) -> PipelineStatusResponse:
|
|
||||||
"""Get live status for a specific pipeline."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
status = manager.get_pipeline_status(pipeline_id)
|
|
||||||
if status is None:
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = await db.get(PipelineConfig, pipeline_id)
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
||||||
return PipelineStatusResponse(
|
|
||||||
pipeline_id=pipeline_id,
|
|
||||||
status="stopped",
|
|
||||||
total_stages=len(config.stages),
|
|
||||||
)
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{pipeline_id}/logs", response_model=list[AutomationLogResponse])
|
|
||||||
async def get_pipeline_logs(
|
|
||||||
pipeline_id: int,
|
|
||||||
request: Request,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> list[AutomationLogResponse]:
|
|
||||||
"""Get recent logs for a pipeline (across all its runs)."""
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = await db.get(PipelineConfig, pipeline_id)
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Pipeline config not found")
|
|
||||||
|
|
||||||
stmt = (
|
|
||||||
select(AutomationLog)
|
|
||||||
.join(PipelineRun, AutomationLog.run_id == PipelineRun.id)
|
|
||||||
.where(PipelineRun.pipeline_id == pipeline_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]
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.api.deps import get_user_character_names
|
|
||||||
from app.database import async_session_factory
|
|
||||||
from app.engine.manager import AutomationManager
|
|
||||||
from app.models.automation import AutomationLog
|
|
||||||
from app.models.workflow import WorkflowConfig, WorkflowRun
|
|
||||||
from app.schemas.automation import AutomationLogResponse
|
|
||||||
from app.schemas.workflow import (
|
|
||||||
WorkflowConfigCreate,
|
|
||||||
WorkflowConfigDetailResponse,
|
|
||||||
WorkflowConfigResponse,
|
|
||||||
WorkflowConfigUpdate,
|
|
||||||
WorkflowRunResponse,
|
|
||||||
WorkflowStatusResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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 -- Workflow Configs
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[WorkflowConfigResponse])
|
|
||||||
async def list_workflows(request: Request) -> list[WorkflowConfigResponse]:
|
|
||||||
"""List workflow configurations belonging to the current user."""
|
|
||||||
user_chars = await get_user_character_names(request)
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
stmt = (
|
|
||||||
select(WorkflowConfig)
|
|
||||||
.where(WorkflowConfig.character_name.in_(user_chars))
|
|
||||||
.order_by(WorkflowConfig.id)
|
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
configs = result.scalars().all()
|
|
||||||
return [WorkflowConfigResponse.model_validate(c) for c in configs]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=WorkflowConfigResponse, status_code=201)
|
|
||||||
async def create_workflow(
|
|
||||||
payload: WorkflowConfigCreate,
|
|
||||||
request: Request,
|
|
||||||
) -> WorkflowConfigResponse:
|
|
||||||
"""Create a new workflow configuration."""
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = WorkflowConfig(
|
|
||||||
name=payload.name,
|
|
||||||
character_name=payload.character_name,
|
|
||||||
description=payload.description,
|
|
||||||
steps=[step.model_dump() for step in payload.steps],
|
|
||||||
loop=payload.loop,
|
|
||||||
max_loops=payload.max_loops,
|
|
||||||
)
|
|
||||||
db.add(config)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(config)
|
|
||||||
return WorkflowConfigResponse.model_validate(config)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status/all", response_model=list[WorkflowStatusResponse])
|
|
||||||
async def get_all_workflow_statuses(request: Request) -> list[WorkflowStatusResponse]:
|
|
||||||
"""Get live status for active workflows belonging to the current user."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
user_chars = set(await get_user_character_names(request))
|
|
||||||
return [s for s in manager.get_all_workflow_statuses() if s.character_name in user_chars]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{workflow_id}", response_model=WorkflowConfigDetailResponse)
|
|
||||||
async def get_workflow(workflow_id: int, request: Request) -> WorkflowConfigDetailResponse:
|
|
||||||
"""Get a workflow configuration with its run history."""
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
stmt = (
|
|
||||||
select(WorkflowConfig)
|
|
||||||
.options(selectinload(WorkflowConfig.runs))
|
|
||||||
.where(WorkflowConfig.id == workflow_id)
|
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
config = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
|
||||||
|
|
||||||
return WorkflowConfigDetailResponse(
|
|
||||||
config=WorkflowConfigResponse.model_validate(config),
|
|
||||||
runs=[WorkflowRunResponse.model_validate(r) for r in config.runs],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{workflow_id}", response_model=WorkflowConfigResponse)
|
|
||||||
async def update_workflow(
|
|
||||||
workflow_id: int,
|
|
||||||
payload: WorkflowConfigUpdate,
|
|
||||||
request: Request,
|
|
||||||
) -> WorkflowConfigResponse:
|
|
||||||
"""Update a workflow configuration. Cannot update while running."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
if manager.is_workflow_running(workflow_id):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="Cannot update a workflow while it is running. Stop it first.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = await db.get(WorkflowConfig, workflow_id)
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
|
||||||
|
|
||||||
if payload.name is not None:
|
|
||||||
config.name = payload.name
|
|
||||||
if payload.description is not None:
|
|
||||||
config.description = payload.description
|
|
||||||
if payload.steps is not None:
|
|
||||||
config.steps = [step.model_dump() for step in payload.steps]
|
|
||||||
if payload.loop is not None:
|
|
||||||
config.loop = payload.loop
|
|
||||||
if payload.max_loops is not None:
|
|
||||||
config.max_loops = payload.max_loops
|
|
||||||
if payload.enabled is not None:
|
|
||||||
config.enabled = payload.enabled
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(config)
|
|
||||||
return WorkflowConfigResponse.model_validate(config)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{workflow_id}", status_code=204)
|
|
||||||
async def delete_workflow(workflow_id: int, request: Request) -> None:
|
|
||||||
"""Delete a workflow configuration. Cannot delete while running."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
if manager.is_workflow_running(workflow_id):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="Cannot delete a workflow while it is running. Stop it first.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = await db.get(WorkflowConfig, workflow_id)
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
|
||||||
await db.delete(config)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Control -- Start / Stop / Pause / Resume
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{workflow_id}/start", response_model=WorkflowRunResponse)
|
|
||||||
async def start_workflow(workflow_id: int, request: Request) -> WorkflowRunResponse:
|
|
||||||
"""Start a workflow from its configuration."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
try:
|
|
||||||
return await manager.start_workflow(workflow_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{workflow_id}/stop", status_code=204)
|
|
||||||
async def stop_workflow(workflow_id: int, request: Request) -> None:
|
|
||||||
"""Stop a running workflow."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
try:
|
|
||||||
await manager.stop_workflow(workflow_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{workflow_id}/pause", status_code=204)
|
|
||||||
async def pause_workflow(workflow_id: int, request: Request) -> None:
|
|
||||||
"""Pause a running workflow."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
try:
|
|
||||||
await manager.pause_workflow(workflow_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{workflow_id}/resume", status_code=204)
|
|
||||||
async def resume_workflow(workflow_id: int, request: Request) -> None:
|
|
||||||
"""Resume a paused workflow."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
try:
|
|
||||||
await manager.resume_workflow(workflow_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Status & Logs
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{workflow_id}/status", response_model=WorkflowStatusResponse)
|
|
||||||
async def get_workflow_status(
|
|
||||||
workflow_id: int,
|
|
||||||
request: Request,
|
|
||||||
) -> WorkflowStatusResponse:
|
|
||||||
"""Get live status for a specific workflow."""
|
|
||||||
manager = _get_manager(request)
|
|
||||||
status = manager.get_workflow_status(workflow_id)
|
|
||||||
if status is None:
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = await db.get(WorkflowConfig, workflow_id)
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
|
||||||
return WorkflowStatusResponse(
|
|
||||||
workflow_id=workflow_id,
|
|
||||||
character_name=config.character_name,
|
|
||||||
status="stopped",
|
|
||||||
total_steps=len(config.steps),
|
|
||||||
)
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{workflow_id}/logs", response_model=list[AutomationLogResponse])
|
|
||||||
async def get_workflow_logs(
|
|
||||||
workflow_id: int,
|
|
||||||
request: Request,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> list[AutomationLogResponse]:
|
|
||||||
"""Get recent logs for a workflow (across all its runs)."""
|
|
||||||
async with async_session_factory() as db:
|
|
||||||
config = await db.get(WorkflowConfig, workflow_id)
|
|
||||||
if config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Workflow config not found")
|
|
||||||
|
|
||||||
# Fetch logs for all runs belonging to this workflow
|
|
||||||
stmt = (
|
|
||||||
select(AutomationLog)
|
|
||||||
.join(WorkflowRun, AutomationLog.run_id == WorkflowRun.id)
|
|
||||||
.where(WorkflowRun.workflow_id == workflow_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]
|
|
||||||
|
|
@ -15,10 +15,6 @@ class Settings(BaseSettings):
|
||||||
data_rate_limit: int = 20 # data requests per window
|
data_rate_limit: int = 20 # data requests per window
|
||||||
data_rate_window: float = 1.0 # seconds
|
data_rate_window: float = 1.0 # seconds
|
||||||
|
|
||||||
# Observability
|
|
||||||
sentry_dsn: str = ""
|
|
||||||
environment: str = "development"
|
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
"""Shared action execution logic.
|
|
||||||
|
|
||||||
Dispatches an ``ActionPlan`` to the appropriate ``ArtifactsClient`` method.
|
|
||||||
Used by ``AutomationRunner``, ``WorkflowRunner``, and ``CharacterWorker``
|
|
||||||
so the match statement is defined in exactly one place.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType
|
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_action(
|
|
||||||
client: ArtifactsClient,
|
|
||||||
character_name: str,
|
|
||||||
plan: ActionPlan,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Execute an action plan against the game API and return the raw result."""
|
|
||||||
match plan.action_type:
|
|
||||||
case ActionType.MOVE:
|
|
||||||
return await client.move(
|
|
||||||
character_name,
|
|
||||||
plan.params["x"],
|
|
||||||
plan.params["y"],
|
|
||||||
)
|
|
||||||
case ActionType.FIGHT:
|
|
||||||
return await client.fight(character_name)
|
|
||||||
case ActionType.GATHER:
|
|
||||||
return await client.gather(character_name)
|
|
||||||
case ActionType.REST:
|
|
||||||
return await client.rest(character_name)
|
|
||||||
case ActionType.EQUIP:
|
|
||||||
return await client.equip(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params["slot"],
|
|
||||||
plan.params.get("quantity", 1),
|
|
||||||
)
|
|
||||||
case ActionType.UNEQUIP:
|
|
||||||
return await client.unequip(
|
|
||||||
character_name,
|
|
||||||
plan.params["slot"],
|
|
||||||
plan.params.get("quantity", 1),
|
|
||||||
)
|
|
||||||
case ActionType.USE_ITEM:
|
|
||||||
return await client.use_item(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params.get("quantity", 1),
|
|
||||||
)
|
|
||||||
case ActionType.DEPOSIT_ITEM:
|
|
||||||
return await client.deposit_item(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case ActionType.WITHDRAW_ITEM:
|
|
||||||
return await client.withdraw_item(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case ActionType.CRAFT:
|
|
||||||
return await client.craft(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params.get("quantity", 1),
|
|
||||||
)
|
|
||||||
case ActionType.RECYCLE:
|
|
||||||
return await client.recycle(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params.get("quantity", 1),
|
|
||||||
)
|
|
||||||
case ActionType.GE_BUY:
|
|
||||||
return await client.ge_buy(
|
|
||||||
character_name,
|
|
||||||
plan.params["id"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case ActionType.GE_CREATE_BUY:
|
|
||||||
return await client.ge_create_buy_order(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
plan.params["price"],
|
|
||||||
)
|
|
||||||
case ActionType.GE_SELL:
|
|
||||||
return await client.ge_sell_order(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
plan.params["price"],
|
|
||||||
)
|
|
||||||
case ActionType.GE_FILL:
|
|
||||||
return await client.ge_fill_buy_order(
|
|
||||||
character_name,
|
|
||||||
plan.params["id"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case ActionType.GE_CANCEL:
|
|
||||||
return await client.ge_cancel(
|
|
||||||
character_name,
|
|
||||||
plan.params["order_id"],
|
|
||||||
)
|
|
||||||
case ActionType.TASK_NEW:
|
|
||||||
return await client.task_new(character_name)
|
|
||||||
case ActionType.TASK_TRADE:
|
|
||||||
return await client.task_trade(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case ActionType.TASK_COMPLETE:
|
|
||||||
return await client.task_complete(character_name)
|
|
||||||
case ActionType.TASK_EXCHANGE:
|
|
||||||
return await client.task_exchange(character_name)
|
|
||||||
case ActionType.TASK_CANCEL:
|
|
||||||
return await client.task_cancel(character_name)
|
|
||||||
case ActionType.DEPOSIT_GOLD:
|
|
||||||
return await client.deposit_gold(
|
|
||||||
character_name,
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case ActionType.WITHDRAW_GOLD:
|
|
||||||
return await client.withdraw_gold(
|
|
||||||
character_name,
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case ActionType.NPC_BUY:
|
|
||||||
return await client.npc_buy(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case ActionType.NPC_SELL:
|
|
||||||
return await client.npc_sell(
|
|
||||||
character_name,
|
|
||||||
plan.params["code"],
|
|
||||||
plan.params["quantity"],
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
logger.warning("Unhandled action type: %s", plan.action_type)
|
|
||||||
return {}
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.engine.cooldown import CooldownTracker
|
from app.engine.cooldown import CooldownTracker
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.runner import AutomationRunner
|
from app.engine.runner import AutomationRunner
|
||||||
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
|
||||||
from app.engine.decision.monster_selector import MonsterSelector
|
|
||||||
from app.engine.decision.resource_selector import ResourceSelector
|
|
||||||
from app.engine.strategies.base import BaseStrategy
|
from app.engine.strategies.base import BaseStrategy
|
||||||
from app.engine.strategies.combat import CombatStrategy
|
from app.engine.strategies.combat import CombatStrategy
|
||||||
from app.engine.strategies.crafting import CraftingStrategy
|
from app.engine.strategies.crafting import CraftingStrategy
|
||||||
|
|
@ -19,22 +18,12 @@ from app.engine.strategies.gathering import GatheringStrategy
|
||||||
from app.engine.strategies.leveling import LevelingStrategy
|
from app.engine.strategies.leveling import LevelingStrategy
|
||||||
from app.engine.strategies.task import TaskStrategy
|
from app.engine.strategies.task import TaskStrategy
|
||||||
from app.engine.strategies.trading import TradingStrategy
|
from app.engine.strategies.trading import TradingStrategy
|
||||||
from app.engine.pipeline.coordinator import PipelineCoordinator
|
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||||
from app.engine.workflow.runner import WorkflowRunner
|
|
||||||
from app.models.automation import AutomationConfig, AutomationRun
|
|
||||||
from app.models.pipeline import PipelineConfig, PipelineRun
|
|
||||||
from app.models.workflow import WorkflowConfig, WorkflowRun
|
|
||||||
from app.schemas.automation import (
|
from app.schemas.automation import (
|
||||||
|
AutomationLogResponse,
|
||||||
AutomationRunResponse,
|
AutomationRunResponse,
|
||||||
AutomationStatusResponse,
|
AutomationStatusResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.game import ItemSchema, MonsterSchema, ResourceSchema
|
|
||||||
from app.schemas.pipeline import (
|
|
||||||
CharacterStateResponse,
|
|
||||||
PipelineRunResponse,
|
|
||||||
PipelineStatusResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.workflow import WorkflowRunResponse, WorkflowStatusResponse
|
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -44,13 +33,12 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AutomationManager:
|
class AutomationManager:
|
||||||
"""Central manager that orchestrates all automation runners and workflow runners.
|
"""Central manager that orchestrates all automation runners.
|
||||||
|
|
||||||
One manager exists per application instance and is stored on
|
One manager exists per application instance and is stored on
|
||||||
``app.state.automation_manager``. It holds references to all active
|
``app.state.automation_manager``. It holds references to all active
|
||||||
runners (keyed by ``config_id``) and workflow runners (keyed by
|
runners (keyed by ``config_id``) and provides high-level start / stop /
|
||||||
``workflow_id``), and provides high-level start / stop / pause /
|
pause / resume operations.
|
||||||
resume operations.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -65,68 +53,24 @@ class AutomationManager:
|
||||||
self._pathfinder = pathfinder
|
self._pathfinder = pathfinder
|
||||||
self._event_bus = event_bus
|
self._event_bus = event_bus
|
||||||
self._runners: dict[int, AutomationRunner] = {}
|
self._runners: dict[int, AutomationRunner] = {}
|
||||||
self._workflow_runners: dict[int, WorkflowRunner] = {}
|
|
||||||
self._pipeline_coordinators: dict[int, PipelineCoordinator] = {}
|
|
||||||
self._cooldown_tracker = CooldownTracker()
|
self._cooldown_tracker = CooldownTracker()
|
||||||
|
|
||||||
# Lazy-loaded game data caches for smart strategies
|
|
||||||
self._monsters_cache: list[MonsterSchema] | None = None
|
|
||||||
self._resources_cache: list[ResourceSchema] | None = None
|
|
||||||
self._items_cache: list[ItemSchema] | None = None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Game data cache
|
# Lifecycle
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _ensure_game_data(self) -> None:
|
|
||||||
"""Load game data caches lazily on first use."""
|
|
||||||
if self._monsters_cache is None:
|
|
||||||
try:
|
|
||||||
raw = await self._client.get_all_monsters()
|
|
||||||
self._monsters_cache = [MonsterSchema(**m) for m in raw]
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to load monsters cache")
|
|
||||||
self._monsters_cache = []
|
|
||||||
|
|
||||||
if self._resources_cache is None:
|
|
||||||
try:
|
|
||||||
raw = await self._client.get_all_resources()
|
|
||||||
self._resources_cache = [ResourceSchema(**r) for r in raw]
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to load resources cache")
|
|
||||||
self._resources_cache = []
|
|
||||||
|
|
||||||
if self._items_cache is None:
|
|
||||||
try:
|
|
||||||
raw = await self._client.get_all_items()
|
|
||||||
self._items_cache = [ItemSchema(**i) for i in raw]
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to load items cache")
|
|
||||||
self._items_cache = []
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Character busy check
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def is_character_busy(self, character_name: str) -> bool:
|
|
||||||
"""Return True if the character is running any automation, workflow, or pipeline."""
|
|
||||||
for runner in self._runners.values():
|
|
||||||
if runner.character_name == character_name and (runner.is_running or runner.is_paused):
|
|
||||||
return True
|
|
||||||
for wf_runner in self._workflow_runners.values():
|
|
||||||
if wf_runner.character_name == character_name and (wf_runner.is_running or wf_runner.is_paused):
|
|
||||||
return True
|
|
||||||
for coord in self._pipeline_coordinators.values():
|
|
||||||
if (coord.is_running or coord.is_paused) and character_name in coord.all_characters:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Automation Lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def start(self, config_id: int) -> AutomationRunResponse:
|
async def start(self, config_id: int) -> AutomationRunResponse:
|
||||||
"""Start an automation from its persisted configuration."""
|
"""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:
|
if config_id in self._runners:
|
||||||
runner = self._runners[config_id]
|
runner = self._runners[config_id]
|
||||||
if runner.is_running or runner.is_paused:
|
if runner.is_running or runner.is_paused:
|
||||||
|
|
@ -136,23 +80,17 @@ class AutomationManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
async with self._db_factory() as db:
|
async with self._db_factory() as db:
|
||||||
|
# Load the config
|
||||||
config = await db.get(AutomationConfig, config_id)
|
config = await db.get(AutomationConfig, config_id)
|
||||||
if config is None:
|
if config is None:
|
||||||
raise ValueError(f"Automation config {config_id} not found")
|
raise ValueError(f"Automation config {config_id} not found")
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
raise ValueError(f"Automation config {config_id} is disabled")
|
raise ValueError(f"Automation config {config_id} is disabled")
|
||||||
|
|
||||||
# Check character busy
|
# Create strategy
|
||||||
if self.is_character_busy(config.character_name):
|
|
||||||
raise ValueError(
|
|
||||||
f"Character {config.character_name!r} is already running an automation or workflow"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure game data is loaded for smart strategies
|
|
||||||
await self._ensure_game_data()
|
|
||||||
|
|
||||||
strategy = self._create_strategy(config.strategy_type, config.config)
|
strategy = self._create_strategy(config.strategy_type, config.config)
|
||||||
|
|
||||||
|
# Create run record
|
||||||
run = AutomationRun(
|
run = AutomationRun(
|
||||||
config_id=config_id,
|
config_id=config_id,
|
||||||
status="running",
|
status="running",
|
||||||
|
|
@ -163,6 +101,7 @@ class AutomationManager:
|
||||||
|
|
||||||
run_response = AutomationRunResponse.model_validate(run)
|
run_response = AutomationRunResponse.model_validate(run)
|
||||||
|
|
||||||
|
# Build and start the runner
|
||||||
runner = AutomationRunner(
|
runner = AutomationRunner(
|
||||||
config_id=config_id,
|
config_id=config_id,
|
||||||
character_name=config.character_name,
|
character_name=config.character_name,
|
||||||
|
|
@ -186,31 +125,55 @@ class AutomationManager:
|
||||||
return run_response
|
return run_response
|
||||||
|
|
||||||
async def stop(self, config_id: int) -> None:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
raise ValueError(f"No active runner for config {config_id}")
|
raise ValueError(f"No active runner for config {config_id}")
|
||||||
|
|
||||||
await runner.stop()
|
await runner.stop()
|
||||||
del self._runners[config_id]
|
del self._runners[config_id]
|
||||||
logger.info("Stopped automation config=%d", config_id)
|
logger.info("Stopped automation config=%d", config_id)
|
||||||
|
|
||||||
async def pause(self, config_id: int) -> None:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
raise ValueError(f"No active runner for config {config_id}")
|
raise ValueError(f"No active runner for config {config_id}")
|
||||||
if not runner.is_running:
|
if not runner.is_running:
|
||||||
raise ValueError(f"Runner for config {config_id} is not running (status={runner.status})")
|
raise ValueError(f"Runner for config {config_id} is not running (status={runner.status})")
|
||||||
|
|
||||||
await runner.pause()
|
await runner.pause()
|
||||||
|
|
||||||
async def resume(self, config_id: int) -> None:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
raise ValueError(f"No active runner for config {config_id}")
|
raise ValueError(f"No active runner for config {config_id}")
|
||||||
if not runner.is_paused:
|
if not runner.is_paused:
|
||||||
raise ValueError(f"Runner for config {config_id} is not paused (status={runner.status})")
|
raise ValueError(f"Runner for config {config_id} is not paused (status={runner.status})")
|
||||||
|
|
||||||
await runner.resume()
|
await runner.resume()
|
||||||
|
|
||||||
async def stop_all(self) -> None:
|
async def stop_all(self) -> None:
|
||||||
"""Stop all running automations, workflows, and pipelines (used during shutdown)."""
|
"""Stop all running automations (used during shutdown)."""
|
||||||
config_ids = list(self._runners.keys())
|
config_ids = list(self._runners.keys())
|
||||||
for config_id in config_ids:
|
for config_id in config_ids:
|
||||||
try:
|
try:
|
||||||
|
|
@ -218,25 +181,12 @@ class AutomationManager:
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error stopping automation config=%d", config_id)
|
logger.exception("Error stopping automation config=%d", config_id)
|
||||||
|
|
||||||
workflow_ids = list(self._workflow_runners.keys())
|
|
||||||
for wf_id in workflow_ids:
|
|
||||||
try:
|
|
||||||
await self.stop_workflow(wf_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Error stopping workflow=%d", wf_id)
|
|
||||||
|
|
||||||
pipeline_ids = list(self._pipeline_coordinators.keys())
|
|
||||||
for pid in pipeline_ids:
|
|
||||||
try:
|
|
||||||
await self.stop_pipeline(pid)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Error stopping pipeline=%d", pid)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Automation Status queries
|
# Status queries
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_status(self, config_id: int) -> AutomationStatusResponse | None:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
if runner is None:
|
if runner is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -250,6 +200,7 @@ class AutomationManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_all_statuses(self) -> list[AutomationStatusResponse]:
|
def get_all_statuses(self) -> list[AutomationStatusResponse]:
|
||||||
|
"""Return live status for all active automations."""
|
||||||
return [
|
return [
|
||||||
AutomationStatusResponse(
|
AutomationStatusResponse(
|
||||||
config_id=r.config_id,
|
config_id=r.config_id,
|
||||||
|
|
@ -263,339 +214,29 @@ class AutomationManager:
|
||||||
]
|
]
|
||||||
|
|
||||||
def is_running(self, config_id: int) -> bool:
|
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)
|
runner = self._runners.get(config_id)
|
||||||
return runner is not None and (runner.is_running or runner.is_paused)
|
return runner is not None and (runner.is_running or runner.is_paused)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Workflow Lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def start_workflow(self, workflow_id: int) -> WorkflowRunResponse:
|
|
||||||
"""Start a workflow from its persisted configuration."""
|
|
||||||
if workflow_id in self._workflow_runners:
|
|
||||||
runner = self._workflow_runners[workflow_id]
|
|
||||||
if runner.is_running or runner.is_paused:
|
|
||||||
raise ValueError(
|
|
||||||
f"Workflow {workflow_id} is already running "
|
|
||||||
f"(run_id={runner.run_id}, status={runner.status})"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with self._db_factory() as db:
|
|
||||||
config = await db.get(WorkflowConfig, workflow_id)
|
|
||||||
if config is None:
|
|
||||||
raise ValueError(f"Workflow config {workflow_id} not found")
|
|
||||||
if not config.enabled:
|
|
||||||
raise ValueError(f"Workflow config {workflow_id} is disabled")
|
|
||||||
if not config.steps:
|
|
||||||
raise ValueError(f"Workflow config {workflow_id} has no steps")
|
|
||||||
|
|
||||||
# Check character busy
|
|
||||||
if self.is_character_busy(config.character_name):
|
|
||||||
raise ValueError(
|
|
||||||
f"Character {config.character_name!r} is already running an automation or workflow"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure game data for smart strategies
|
|
||||||
await self._ensure_game_data()
|
|
||||||
|
|
||||||
# Create workflow run record
|
|
||||||
run = WorkflowRun(
|
|
||||||
workflow_id=workflow_id,
|
|
||||||
status="running",
|
|
||||||
current_step_index=0,
|
|
||||||
current_step_id=config.steps[0].get("id", "") if config.steps else "",
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
|
|
||||||
run_response = WorkflowRunResponse.model_validate(run)
|
|
||||||
|
|
||||||
runner = WorkflowRunner(
|
|
||||||
workflow_id=workflow_id,
|
|
||||||
character_name=config.character_name,
|
|
||||||
steps=config.steps,
|
|
||||||
loop=config.loop,
|
|
||||||
max_loops=config.max_loops,
|
|
||||||
strategy_factory=self._create_strategy,
|
|
||||||
client=self._client,
|
|
||||||
cooldown_tracker=self._cooldown_tracker,
|
|
||||||
db_factory=self._db_factory,
|
|
||||||
run_id=run.id,
|
|
||||||
event_bus=self._event_bus,
|
|
||||||
)
|
|
||||||
self._workflow_runners[workflow_id] = runner
|
|
||||||
await runner.start()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Started workflow=%d character=%s steps=%d run=%d",
|
|
||||||
workflow_id,
|
|
||||||
config.character_name,
|
|
||||||
len(config.steps),
|
|
||||||
run.id,
|
|
||||||
)
|
|
||||||
return run_response
|
|
||||||
|
|
||||||
async def stop_workflow(self, workflow_id: int) -> None:
|
|
||||||
runner = self._workflow_runners.get(workflow_id)
|
|
||||||
if runner is None:
|
|
||||||
raise ValueError(f"No active runner for workflow {workflow_id}")
|
|
||||||
await runner.stop()
|
|
||||||
del self._workflow_runners[workflow_id]
|
|
||||||
logger.info("Stopped workflow=%d", workflow_id)
|
|
||||||
|
|
||||||
async def pause_workflow(self, workflow_id: int) -> None:
|
|
||||||
runner = self._workflow_runners.get(workflow_id)
|
|
||||||
if runner is None:
|
|
||||||
raise ValueError(f"No active runner for workflow {workflow_id}")
|
|
||||||
if not runner.is_running:
|
|
||||||
raise ValueError(f"Workflow runner {workflow_id} is not running (status={runner.status})")
|
|
||||||
await runner.pause()
|
|
||||||
|
|
||||||
async def resume_workflow(self, workflow_id: int) -> None:
|
|
||||||
runner = self._workflow_runners.get(workflow_id)
|
|
||||||
if runner is None:
|
|
||||||
raise ValueError(f"No active runner for workflow {workflow_id}")
|
|
||||||
if not runner.is_paused:
|
|
||||||
raise ValueError(f"Workflow runner {workflow_id} is not paused (status={runner.status})")
|
|
||||||
await runner.resume()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Workflow Status queries
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_workflow_status(self, workflow_id: int) -> WorkflowStatusResponse | None:
|
|
||||||
runner = self._workflow_runners.get(workflow_id)
|
|
||||||
if runner is None:
|
|
||||||
return None
|
|
||||||
return WorkflowStatusResponse(
|
|
||||||
workflow_id=runner.workflow_id,
|
|
||||||
character_name=runner.character_name,
|
|
||||||
status=runner.status,
|
|
||||||
run_id=runner.run_id,
|
|
||||||
current_step_index=runner.current_step_index,
|
|
||||||
current_step_id=runner.current_step_id,
|
|
||||||
total_steps=len(runner._steps),
|
|
||||||
loop_count=runner.loop_count,
|
|
||||||
total_actions_count=runner.total_actions_count,
|
|
||||||
step_actions_count=runner.step_actions_count,
|
|
||||||
strategy_state=runner.strategy_state,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_all_workflow_statuses(self) -> list[WorkflowStatusResponse]:
|
|
||||||
return [
|
|
||||||
WorkflowStatusResponse(
|
|
||||||
workflow_id=r.workflow_id,
|
|
||||||
character_name=r.character_name,
|
|
||||||
status=r.status,
|
|
||||||
run_id=r.run_id,
|
|
||||||
current_step_index=r.current_step_index,
|
|
||||||
current_step_id=r.current_step_id,
|
|
||||||
total_steps=len(r._steps),
|
|
||||||
loop_count=r.loop_count,
|
|
||||||
total_actions_count=r.total_actions_count,
|
|
||||||
step_actions_count=r.step_actions_count,
|
|
||||||
strategy_state=r.strategy_state,
|
|
||||||
)
|
|
||||||
for r in self._workflow_runners.values()
|
|
||||||
]
|
|
||||||
|
|
||||||
def is_workflow_running(self, workflow_id: int) -> bool:
|
|
||||||
runner = self._workflow_runners.get(workflow_id)
|
|
||||||
return runner is not None and (runner.is_running or runner.is_paused)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Pipeline Lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def start_pipeline(self, pipeline_id: int) -> PipelineRunResponse:
|
|
||||||
"""Start a pipeline from its persisted configuration."""
|
|
||||||
if pipeline_id in self._pipeline_coordinators:
|
|
||||||
coord = self._pipeline_coordinators[pipeline_id]
|
|
||||||
if coord.is_running or coord.is_paused:
|
|
||||||
raise ValueError(
|
|
||||||
f"Pipeline {pipeline_id} is already running "
|
|
||||||
f"(run_id={coord.run_id}, status={coord.status})"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with self._db_factory() as db:
|
|
||||||
config = await db.get(PipelineConfig, pipeline_id)
|
|
||||||
if config is None:
|
|
||||||
raise ValueError(f"Pipeline config {pipeline_id} not found")
|
|
||||||
if not config.enabled:
|
|
||||||
raise ValueError(f"Pipeline config {pipeline_id} is disabled")
|
|
||||||
if not config.stages:
|
|
||||||
raise ValueError(f"Pipeline config {pipeline_id} has no stages")
|
|
||||||
|
|
||||||
# Collect all characters and verify none are busy
|
|
||||||
all_chars: set[str] = set()
|
|
||||||
for stage in config.stages:
|
|
||||||
for cs in stage.get("character_steps", []):
|
|
||||||
all_chars.add(cs["character_name"])
|
|
||||||
|
|
||||||
busy = [c for c in all_chars if self.is_character_busy(c)]
|
|
||||||
if busy:
|
|
||||||
raise ValueError(
|
|
||||||
f"Characters already busy: {', '.join(sorted(busy))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure game data for strategies
|
|
||||||
await self._ensure_game_data()
|
|
||||||
|
|
||||||
run = PipelineRun(
|
|
||||||
pipeline_id=pipeline_id,
|
|
||||||
status="running",
|
|
||||||
current_stage_index=0,
|
|
||||||
current_stage_id=config.stages[0].get("id", "") if config.stages else "",
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
|
|
||||||
run_response = PipelineRunResponse.model_validate(run)
|
|
||||||
|
|
||||||
coord = PipelineCoordinator(
|
|
||||||
pipeline_id=pipeline_id,
|
|
||||||
stages=config.stages,
|
|
||||||
loop=config.loop,
|
|
||||||
max_loops=config.max_loops,
|
|
||||||
strategy_factory=self._create_strategy,
|
|
||||||
client=self._client,
|
|
||||||
cooldown_tracker=self._cooldown_tracker,
|
|
||||||
db_factory=self._db_factory,
|
|
||||||
run_id=run.id,
|
|
||||||
event_bus=self._event_bus,
|
|
||||||
)
|
|
||||||
self._pipeline_coordinators[pipeline_id] = coord
|
|
||||||
await coord.start()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Started pipeline=%d stages=%d characters=%s run=%d",
|
|
||||||
pipeline_id,
|
|
||||||
len(config.stages),
|
|
||||||
sorted(all_chars),
|
|
||||||
run.id,
|
|
||||||
)
|
|
||||||
return run_response
|
|
||||||
|
|
||||||
async def stop_pipeline(self, pipeline_id: int) -> None:
|
|
||||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
|
||||||
if coord is None:
|
|
||||||
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
|
|
||||||
await coord.stop()
|
|
||||||
del self._pipeline_coordinators[pipeline_id]
|
|
||||||
logger.info("Stopped pipeline=%d", pipeline_id)
|
|
||||||
|
|
||||||
async def pause_pipeline(self, pipeline_id: int) -> None:
|
|
||||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
|
||||||
if coord is None:
|
|
||||||
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
|
|
||||||
if not coord.is_running:
|
|
||||||
raise ValueError(f"Pipeline {pipeline_id} is not running (status={coord.status})")
|
|
||||||
await coord.pause()
|
|
||||||
|
|
||||||
async def resume_pipeline(self, pipeline_id: int) -> None:
|
|
||||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
|
||||||
if coord is None:
|
|
||||||
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
|
|
||||||
if not coord.is_paused:
|
|
||||||
raise ValueError(f"Pipeline {pipeline_id} is not paused (status={coord.status})")
|
|
||||||
await coord.resume()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Pipeline Status queries
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_pipeline_status(self, pipeline_id: int) -> PipelineStatusResponse | None:
|
|
||||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
|
||||||
if coord is None:
|
|
||||||
return None
|
|
||||||
return PipelineStatusResponse(
|
|
||||||
pipeline_id=coord.pipeline_id,
|
|
||||||
status=coord.status,
|
|
||||||
run_id=coord.run_id,
|
|
||||||
current_stage_index=coord.current_stage_index,
|
|
||||||
current_stage_id=coord.current_stage_id,
|
|
||||||
total_stages=len(coord._stages),
|
|
||||||
loop_count=coord.loop_count,
|
|
||||||
total_actions_count=coord.total_actions_count,
|
|
||||||
character_states=[
|
|
||||||
CharacterStateResponse(
|
|
||||||
character_name=name,
|
|
||||||
status=state.get("status", "idle"),
|
|
||||||
step_id=state.get("step_id", ""),
|
|
||||||
actions_count=state.get("actions_count", 0),
|
|
||||||
strategy_state=state.get("strategy_state", ""),
|
|
||||||
error=state.get("error"),
|
|
||||||
)
|
|
||||||
for name, state in coord.character_states.items()
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_all_pipeline_statuses(self) -> list[PipelineStatusResponse]:
|
|
||||||
return [
|
|
||||||
self.get_pipeline_status(pid)
|
|
||||||
for pid in self._pipeline_coordinators
|
|
||||||
if self.get_pipeline_status(pid) is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
def is_pipeline_running(self, pipeline_id: int) -> bool:
|
|
||||||
coord = self._pipeline_coordinators.get(pipeline_id)
|
|
||||||
return coord is not None and (coord.is_running or coord.is_paused)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Strategy factory
|
# Strategy factory
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _create_strategy(self, strategy_type: str, config: dict) -> BaseStrategy:
|
def _create_strategy(self, strategy_type: str, config: dict) -> BaseStrategy:
|
||||||
"""Instantiate a strategy by type name, injecting game data and decision modules."""
|
"""Instantiate a strategy by type name."""
|
||||||
monster_selector = MonsterSelector()
|
|
||||||
resource_selector = ResourceSelector()
|
|
||||||
equipment_optimizer = EquipmentOptimizer()
|
|
||||||
|
|
||||||
match strategy_type:
|
match strategy_type:
|
||||||
case "combat":
|
case "combat":
|
||||||
return CombatStrategy(
|
return CombatStrategy(config, self._pathfinder)
|
||||||
config,
|
|
||||||
self._pathfinder,
|
|
||||||
monster_selector=monster_selector,
|
|
||||||
monsters_data=self._monsters_cache,
|
|
||||||
equipment_optimizer=equipment_optimizer,
|
|
||||||
available_items=self._items_cache,
|
|
||||||
)
|
|
||||||
case "gathering":
|
case "gathering":
|
||||||
return GatheringStrategy(
|
return GatheringStrategy(config, self._pathfinder)
|
||||||
config,
|
|
||||||
self._pathfinder,
|
|
||||||
resource_selector=resource_selector,
|
|
||||||
resources_data=self._resources_cache,
|
|
||||||
)
|
|
||||||
case "crafting":
|
case "crafting":
|
||||||
return CraftingStrategy(
|
return CraftingStrategy(config, self._pathfinder)
|
||||||
config,
|
|
||||||
self._pathfinder,
|
|
||||||
items_data=self._items_cache,
|
|
||||||
resources_data=self._resources_cache,
|
|
||||||
)
|
|
||||||
case "trading":
|
case "trading":
|
||||||
return TradingStrategy(
|
return TradingStrategy(config, self._pathfinder)
|
||||||
config,
|
|
||||||
self._pathfinder,
|
|
||||||
client=self._client,
|
|
||||||
)
|
|
||||||
case "task":
|
case "task":
|
||||||
return TaskStrategy(config, self._pathfinder)
|
return TaskStrategy(config, self._pathfinder)
|
||||||
case "leveling":
|
case "leveling":
|
||||||
return LevelingStrategy(
|
return LevelingStrategy(config, self._pathfinder)
|
||||||
config,
|
|
||||||
self._pathfinder,
|
|
||||||
resources_data=self._resources_cache,
|
|
||||||
monsters_data=self._monsters_cache,
|
|
||||||
resource_selector=resource_selector,
|
|
||||||
monster_selector=monster_selector,
|
|
||||||
equipment_optimizer=equipment_optimizer,
|
|
||||||
available_items=self._items_cache,
|
|
||||||
)
|
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unknown strategy type: {strategy_type!r}. "
|
f"Unknown strategy type: {strategy_type!r}. "
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
from app.engine.pipeline.coordinator import PipelineCoordinator
|
|
||||||
from app.engine.pipeline.worker import CharacterWorker
|
|
||||||
|
|
||||||
__all__ = ["PipelineCoordinator", "CharacterWorker"]
|
|
||||||
|
|
@ -1,444 +0,0 @@
|
||||||
"""PipelineCoordinator — orchestrates stages sequentially with parallel character workers."""
|
|
||||||
|
|
||||||
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.pipeline.worker import CharacterWorker
|
|
||||||
from app.engine.strategies.base import BaseStrategy
|
|
||||||
from app.models.pipeline import PipelineRun
|
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.websocket.event_bus import EventBus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineCoordinator:
|
|
||||||
"""Orchestrates a multi-character pipeline.
|
|
||||||
|
|
||||||
Iterates stages sequentially. For each stage, spawns a
|
|
||||||
``CharacterWorker`` per character-step and waits for all of them to
|
|
||||||
complete their transition (or error). Then advances to the next stage.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
pipeline_id: int,
|
|
||||||
stages: list[dict],
|
|
||||||
loop: bool,
|
|
||||||
max_loops: int,
|
|
||||||
strategy_factory: Any, # callable(strategy_type, config) -> BaseStrategy
|
|
||||||
client: ArtifactsClient,
|
|
||||||
cooldown_tracker: CooldownTracker,
|
|
||||||
db_factory: async_sessionmaker[AsyncSession],
|
|
||||||
run_id: int,
|
|
||||||
event_bus: EventBus | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._pipeline_id = pipeline_id
|
|
||||||
self._stages = stages
|
|
||||||
self._loop = loop
|
|
||||||
self._max_loops = max_loops
|
|
||||||
self._strategy_factory = strategy_factory
|
|
||||||
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
|
|
||||||
|
|
||||||
# Runtime state
|
|
||||||
self._current_stage_index: int = 0
|
|
||||||
self._loop_count: int = 0
|
|
||||||
self._total_actions: int = 0
|
|
||||||
self._stage_history: list[dict] = []
|
|
||||||
self._workers: list[CharacterWorker] = []
|
|
||||||
|
|
||||||
# Track ALL characters across ALL stages for busy-checking
|
|
||||||
self._all_characters: set[str] = set()
|
|
||||||
for stage in stages:
|
|
||||||
for cs in stage.get("character_steps", []):
|
|
||||||
self._all_characters.add(cs["character_name"])
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public properties
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pipeline_id(self) -> int:
|
|
||||||
return self._pipeline_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def run_id(self) -> int:
|
|
||||||
return self._run_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def all_characters(self) -> set[str]:
|
|
||||||
return self._all_characters
|
|
||||||
|
|
||||||
@property
|
|
||||||
def active_characters(self) -> set[str]:
|
|
||||||
"""Characters currently executing in the active stage."""
|
|
||||||
return {w.character_name for w in self._workers if w.is_running}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_stage_index(self) -> int:
|
|
||||||
return self._current_stage_index
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_stage_id(self) -> str:
|
|
||||||
if 0 <= self._current_stage_index < len(self._stages):
|
|
||||||
return self._stages[self._current_stage_index].get("id", "")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loop_count(self) -> int:
|
|
||||||
return self._loop_count
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total_actions_count(self) -> int:
|
|
||||||
return self._total_actions + sum(w.actions_count for w in self._workers)
|
|
||||||
|
|
||||||
@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 character_states(self) -> dict[str, dict]:
|
|
||||||
"""Current state of each worker for status reporting."""
|
|
||||||
result: dict[str, dict] = {}
|
|
||||||
for w in self._workers:
|
|
||||||
result[w.character_name] = {
|
|
||||||
"status": w.status,
|
|
||||||
"step_id": w.step_id,
|
|
||||||
"actions_count": w.actions_count,
|
|
||||||
"strategy_state": w.strategy_state,
|
|
||||||
"error": w.error_message,
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Event bus helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _publish(self, event_type: str, data: dict) -> None:
|
|
||||||
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:
|
|
||||||
await self._publish(
|
|
||||||
"pipeline_status_changed",
|
|
||||||
{
|
|
||||||
"pipeline_id": self._pipeline_id,
|
|
||||||
"status": status,
|
|
||||||
"run_id": self._run_id,
|
|
||||||
"current_stage_index": self._current_stage_index,
|
|
||||||
"loop_count": self._loop_count,
|
|
||||||
"character_states": self.character_states,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
self._running = True
|
|
||||||
self._paused = False
|
|
||||||
self._task = asyncio.create_task(
|
|
||||||
self._run_loop(),
|
|
||||||
name=f"pipeline-coord-{self._pipeline_id}",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Started pipeline coordinator pipeline=%d run=%d stages=%d characters=%s",
|
|
||||||
self._pipeline_id,
|
|
||||||
self._run_id,
|
|
||||||
len(self._stages),
|
|
||||||
sorted(self._all_characters),
|
|
||||||
)
|
|
||||||
await self._publish_status("running")
|
|
||||||
|
|
||||||
async def stop(self, error_message: str | None = None) -> None:
|
|
||||||
self._running = False
|
|
||||||
# Stop all active workers
|
|
||||||
for w in self._workers:
|
|
||||||
await w.stop()
|
|
||||||
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
|
|
||||||
|
|
||||||
final_status = "error" if error_message else "stopped"
|
|
||||||
await self._finalize_run(status=final_status, error_message=error_message)
|
|
||||||
logger.info("Stopped pipeline %d (actions=%d)", self._pipeline_id, self.total_actions_count)
|
|
||||||
await self._publish_status(final_status)
|
|
||||||
|
|
||||||
async def pause(self) -> None:
|
|
||||||
self._paused = True
|
|
||||||
for w in self._workers:
|
|
||||||
await w.stop()
|
|
||||||
await self._update_run_status("paused")
|
|
||||||
await self._publish_status("paused")
|
|
||||||
|
|
||||||
async def resume(self) -> None:
|
|
||||||
self._paused = False
|
|
||||||
# Workers will be re-created by the main loop on next iteration
|
|
||||||
await self._update_run_status("running")
|
|
||||||
await self._publish_status("running")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Main loop
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
|
||||||
try:
|
|
||||||
while self._running:
|
|
||||||
if self._paused:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
completed = await self._run_stage(self._current_stage_index)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(
|
|
||||||
"Error running stage %d of pipeline %d: %s",
|
|
||||||
self._current_stage_index,
|
|
||||||
self._pipeline_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
await self._finalize_run(
|
|
||||||
status="error",
|
|
||||||
error_message=f"Stage {self._current_stage_index} error: {exc}",
|
|
||||||
)
|
|
||||||
self._running = False
|
|
||||||
await self._publish_status("error")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._running:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not completed:
|
|
||||||
# Stage had errors
|
|
||||||
await self._finalize_run(
|
|
||||||
status="error",
|
|
||||||
error_message="Stage workers encountered errors",
|
|
||||||
)
|
|
||||||
self._running = False
|
|
||||||
await self._publish_status("error")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Stage completed — record and advance
|
|
||||||
stage = self._stages[self._current_stage_index]
|
|
||||||
self._total_actions += sum(w.actions_count for w in self._workers)
|
|
||||||
self._stage_history.append({
|
|
||||||
"stage_id": stage.get("id", ""),
|
|
||||||
"stage_name": stage.get("name", ""),
|
|
||||||
"character_actions": {
|
|
||||||
w.character_name: w.actions_count for w in self._workers
|
|
||||||
},
|
|
||||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
})
|
|
||||||
self._workers = []
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Pipeline %d stage %d/%d completed (%s)",
|
|
||||||
self._pipeline_id,
|
|
||||||
self._current_stage_index + 1,
|
|
||||||
len(self._stages),
|
|
||||||
stage.get("name", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
next_index = self._current_stage_index + 1
|
|
||||||
if next_index >= len(self._stages):
|
|
||||||
# End of pipeline
|
|
||||||
if self._loop:
|
|
||||||
self._loop_count += 1
|
|
||||||
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
|
||||||
await self._finalize_run(status="completed")
|
|
||||||
self._running = False
|
|
||||||
await self._publish_status("completed")
|
|
||||||
return
|
|
||||||
# Loop back to stage 0
|
|
||||||
logger.info("Pipeline %d looping (loop %d)", self._pipeline_id, self._loop_count)
|
|
||||||
self._current_stage_index = 0
|
|
||||||
else:
|
|
||||||
await self._finalize_run(status="completed")
|
|
||||||
self._running = False
|
|
||||||
await self._publish_status("completed")
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
self._current_stage_index = next_index
|
|
||||||
|
|
||||||
await self._update_run_progress()
|
|
||||||
await self._publish_status("running")
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info("Pipeline coordinator %d cancelled", self._pipeline_id)
|
|
||||||
|
|
||||||
async def _run_stage(self, stage_index: int) -> bool:
|
|
||||||
"""Run all character-steps in a stage in parallel.
|
|
||||||
|
|
||||||
Returns True if all workers completed successfully, False if any errored.
|
|
||||||
"""
|
|
||||||
if stage_index < 0 or stage_index >= len(self._stages):
|
|
||||||
return False
|
|
||||||
|
|
||||||
stage = self._stages[stage_index]
|
|
||||||
character_steps = stage.get("character_steps", [])
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Pipeline %d starting stage %d/%d: %s (%d workers)",
|
|
||||||
self._pipeline_id,
|
|
||||||
stage_index + 1,
|
|
||||||
len(self._stages),
|
|
||||||
stage.get("name", ""),
|
|
||||||
len(character_steps),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create workers for each character-step
|
|
||||||
self._workers = []
|
|
||||||
for cs in character_steps:
|
|
||||||
try:
|
|
||||||
strategy = self._strategy_factory(
|
|
||||||
cs["strategy_type"],
|
|
||||||
cs.get("config", {}),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to create strategy for pipeline %d character %s",
|
|
||||||
self._pipeline_id,
|
|
||||||
cs.get("character_name", "?"),
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
worker = CharacterWorker(
|
|
||||||
pipeline_id=self._pipeline_id,
|
|
||||||
stage_id=stage.get("id", ""),
|
|
||||||
step=cs,
|
|
||||||
strategy=strategy,
|
|
||||||
client=self._client,
|
|
||||||
cooldown_tracker=self._cooldown,
|
|
||||||
event_bus=self._event_bus,
|
|
||||||
)
|
|
||||||
self._workers.append(worker)
|
|
||||||
|
|
||||||
# Start all workers in parallel
|
|
||||||
for w in self._workers:
|
|
||||||
await w.start()
|
|
||||||
|
|
||||||
# Wait for all workers to complete or error
|
|
||||||
while self._running and not self._paused:
|
|
||||||
all_done = all(w.is_completed or w.is_errored for w in self._workers)
|
|
||||||
if all_done:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
if self._paused or not self._running:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if any worker errored
|
|
||||||
errored = [w for w in self._workers if w.is_errored]
|
|
||||||
if errored:
|
|
||||||
error_msgs = "; ".join(
|
|
||||||
f"{w.character_name}: {w.error_message}" for w in errored
|
|
||||||
)
|
|
||||||
logger.error(
|
|
||||||
"Pipeline %d stage %d had worker errors: %s",
|
|
||||||
self._pipeline_id,
|
|
||||||
stage_index,
|
|
||||||
error_msgs,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Database helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _update_run_status(self, status: str) -> None:
|
|
||||||
try:
|
|
||||||
async with self._db_factory() as db:
|
|
||||||
stmt = select(PipelineRun).where(PipelineRun.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 pipeline run %d status", self._run_id)
|
|
||||||
|
|
||||||
async def _update_run_progress(self) -> None:
|
|
||||||
try:
|
|
||||||
async with self._db_factory() as db:
|
|
||||||
stmt = select(PipelineRun).where(PipelineRun.id == self._run_id)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
run = result.scalar_one_or_none()
|
|
||||||
if run is not None:
|
|
||||||
run.current_stage_index = self._current_stage_index
|
|
||||||
run.current_stage_id = self.current_stage_id
|
|
||||||
run.loop_count = self._loop_count
|
|
||||||
run.total_actions_count = self.total_actions_count
|
|
||||||
run.character_states = self.character_states
|
|
||||||
run.stage_history = self._stage_history
|
|
||||||
await db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to update pipeline run %d progress", self._run_id)
|
|
||||||
|
|
||||||
async def _finalize_run(
|
|
||||||
self,
|
|
||||||
status: str,
|
|
||||||
error_message: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
async with self._db_factory() as db:
|
|
||||||
stmt = select(PipelineRun).where(PipelineRun.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.current_stage_index = self._current_stage_index
|
|
||||||
run.current_stage_id = self.current_stage_id
|
|
||||||
run.loop_count = self._loop_count
|
|
||||||
run.total_actions_count = self.total_actions_count
|
|
||||||
run.character_states = self.character_states
|
|
||||||
run.stage_history = self._stage_history
|
|
||||||
if error_message:
|
|
||||||
run.error_message = error_message
|
|
||||||
await db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to finalize pipeline run %d", self._run_id)
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
"""CharacterWorker — runs one character's strategy within a pipeline stage.
|
|
||||||
|
|
||||||
Same tick loop pattern as WorkflowRunner._tick(): wait cooldown -> get
|
|
||||||
character -> get action -> check transition -> execute action. Reuses the
|
|
||||||
shared ``execute_action`` helper and the existing ``TransitionEvaluator``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from app.engine.action_executor import execute_action
|
|
||||||
from app.engine.cooldown import CooldownTracker
|
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
|
||||||
from app.engine.workflow.conditions import TransitionEvaluator
|
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.websocket.event_bus import EventBus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_ERROR_RETRY_DELAY: float = 2.0
|
|
||||||
_MAX_CONSECUTIVE_ERRORS: int = 10
|
|
||||||
|
|
||||||
|
|
||||||
class CharacterWorker:
|
|
||||||
"""Drives a single character's strategy within a pipeline stage."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
pipeline_id: int,
|
|
||||||
stage_id: str,
|
|
||||||
step: dict,
|
|
||||||
strategy: BaseStrategy,
|
|
||||||
client: ArtifactsClient,
|
|
||||||
cooldown_tracker: CooldownTracker,
|
|
||||||
event_bus: EventBus | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._pipeline_id = pipeline_id
|
|
||||||
self._stage_id = stage_id
|
|
||||||
self._step = step
|
|
||||||
self._character_name: str = step["character_name"]
|
|
||||||
self._step_id: str = step["id"]
|
|
||||||
self._strategy = strategy
|
|
||||||
self._client = client
|
|
||||||
self._cooldown = cooldown_tracker
|
|
||||||
self._event_bus = event_bus
|
|
||||||
|
|
||||||
self._transition_evaluator = TransitionEvaluator(client)
|
|
||||||
self._running = False
|
|
||||||
self._completed = False
|
|
||||||
self._errored = False
|
|
||||||
self._error_message: str | None = None
|
|
||||||
self._task: asyncio.Task[None] | None = None
|
|
||||||
self._actions_count: int = 0
|
|
||||||
self._step_start_time: float = 0.0
|
|
||||||
self._consecutive_errors: int = 0
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public properties
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def character_name(self) -> str:
|
|
||||||
return self._character_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def step_id(self) -> str:
|
|
||||||
return self._step_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_completed(self) -> bool:
|
|
||||||
return self._completed
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_errored(self) -> bool:
|
|
||||||
return self._errored
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_running(self) -> bool:
|
|
||||||
return self._running and not self._completed and not self._errored
|
|
||||||
|
|
||||||
@property
|
|
||||||
def actions_count(self) -> int:
|
|
||||||
return self._actions_count
|
|
||||||
|
|
||||||
@property
|
|
||||||
def strategy_state(self) -> str:
|
|
||||||
return self._strategy.get_state() if self._strategy else ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def error_message(self) -> str | None:
|
|
||||||
return self._error_message
|
|
||||||
|
|
||||||
@property
|
|
||||||
def status(self) -> str:
|
|
||||||
if self._errored:
|
|
||||||
return "error"
|
|
||||||
if self._completed:
|
|
||||||
return "completed"
|
|
||||||
if self._running:
|
|
||||||
return "running"
|
|
||||||
return "idle"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
self._running = True
|
|
||||||
self._step_start_time = time.time()
|
|
||||||
self._transition_evaluator.reset()
|
|
||||||
self._task = asyncio.create_task(
|
|
||||||
self._run_loop(),
|
|
||||||
name=f"pipeline-{self._pipeline_id}-{self._character_name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
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
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Main loop
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
|
||||||
try:
|
|
||||||
while self._running and not self._completed:
|
|
||||||
try:
|
|
||||||
await self._tick()
|
|
||||||
self._consecutive_errors = 0
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
self._consecutive_errors += 1
|
|
||||||
logger.exception(
|
|
||||||
"Error in pipeline worker %s/%s (error %d/%d): %s",
|
|
||||||
self._pipeline_id,
|
|
||||||
self._character_name,
|
|
||||||
self._consecutive_errors,
|
|
||||||
_MAX_CONSECUTIVE_ERRORS,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
|
||||||
self._errored = True
|
|
||||||
self._error_message = (
|
|
||||||
f"Stopped after {_MAX_CONSECUTIVE_ERRORS} "
|
|
||||||
f"consecutive errors. Last: {exc}"
|
|
||||||
)
|
|
||||||
self._running = False
|
|
||||||
return
|
|
||||||
await asyncio.sleep(_ERROR_RETRY_DELAY)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info(
|
|
||||||
"Pipeline worker %s/%s cancelled",
|
|
||||||
self._pipeline_id,
|
|
||||||
self._character_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _tick(self) -> None:
|
|
||||||
if self._strategy is None:
|
|
||||||
self._errored = True
|
|
||||||
self._error_message = "No strategy configured"
|
|
||||||
self._running = False
|
|
||||||
return
|
|
||||||
|
|
||||||
# 1. Wait for cooldown
|
|
||||||
await self._cooldown.wait(self._character_name)
|
|
||||||
|
|
||||||
# 2. Fetch character state
|
|
||||||
character = await self._client.get_character(self._character_name)
|
|
||||||
|
|
||||||
# 3. Ask strategy for next action
|
|
||||||
plan = await self._strategy.next_action(character)
|
|
||||||
strategy_completed = plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
# 4. Check transition condition
|
|
||||||
transition = self._step.get("transition")
|
|
||||||
if transition is not None:
|
|
||||||
should_advance = await self._transition_evaluator.should_transition(
|
|
||||||
transition,
|
|
||||||
character,
|
|
||||||
actions_count=self._actions_count,
|
|
||||||
step_start_time=self._step_start_time,
|
|
||||||
strategy_completed=strategy_completed,
|
|
||||||
)
|
|
||||||
if should_advance:
|
|
||||||
self._completed = True
|
|
||||||
self._running = False
|
|
||||||
return
|
|
||||||
|
|
||||||
# 5. If strategy completed and no transition, treat as done
|
|
||||||
if strategy_completed:
|
|
||||||
if transition is None:
|
|
||||||
self._completed = True
|
|
||||||
self._running = False
|
|
||||||
return
|
|
||||||
# Strategy completed but transition not met yet -- idle
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
return
|
|
||||||
|
|
||||||
if plan.action_type == ActionType.IDLE:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 6. Execute the action
|
|
||||||
result = await execute_action(self._client, self._character_name, plan)
|
|
||||||
|
|
||||||
# 7. Update cooldown
|
|
||||||
cooldown = result.get("cooldown")
|
|
||||||
if cooldown:
|
|
||||||
self._cooldown.update(
|
|
||||||
self._character_name,
|
|
||||||
cooldown.get("total_seconds", 0),
|
|
||||||
cooldown.get("expiration"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 8. Record
|
|
||||||
self._actions_count += 1
|
|
||||||
|
|
||||||
# 9. Publish character update
|
|
||||||
if self._event_bus is not None:
|
|
||||||
try:
|
|
||||||
await self._event_bus.publish(
|
|
||||||
"character_update",
|
|
||||||
{"character_name": self._character_name},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
@ -5,16 +5,13 @@ import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from app.engine.action_executor import execute_action
|
|
||||||
from app.engine.cooldown import CooldownTracker
|
from app.engine.cooldown import CooldownTracker
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.models.automation import AutomationLog, AutomationRun
|
from app.models.automation import AutomationLog, AutomationRun
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
from app.services.error_service import hash_token, log_error
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.websocket.event_bus import EventBus
|
from app.websocket.event_bus import EventBus
|
||||||
|
|
@ -238,62 +235,6 @@ class AutomationRunner:
|
||||||
self._consecutive_errors = 0
|
self._consecutive_errors = 0
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
status = exc.response.status_code
|
|
||||||
# 498 = character in cooldown – not a real error,
|
|
||||||
# just wait and retry without incrementing the counter.
|
|
||||||
if status == 498:
|
|
||||||
logger.info(
|
|
||||||
"Cooldown error for config %d, will retry",
|
|
||||||
self._config_id,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(_ERROR_RETRY_DELAY)
|
|
||||||
continue
|
|
||||||
# Other HTTP errors – treat as real failures
|
|
||||||
self._consecutive_errors += 1
|
|
||||||
logger.exception(
|
|
||||||
"HTTP %d in automation loop for config %d (error %d/%d): %s",
|
|
||||||
status,
|
|
||||||
self._config_id,
|
|
||||||
self._consecutive_errors,
|
|
||||||
_MAX_CONSECUTIVE_ERRORS,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
await log_error(
|
|
||||||
self._db_factory,
|
|
||||||
severity="error",
|
|
||||||
source="automation",
|
|
||||||
exc=exc,
|
|
||||||
context={
|
|
||||||
"config_id": self._config_id,
|
|
||||||
"character": self._character_name,
|
|
||||||
"run_id": self._run_id,
|
|
||||||
"consecutive_errors": self._consecutive_errors,
|
|
||||||
"http_status": status,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
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 Exception as exc:
|
except Exception as exc:
|
||||||
self._consecutive_errors += 1
|
self._consecutive_errors += 1
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|
@ -303,20 +244,6 @@ class AutomationRunner:
|
||||||
_MAX_CONSECUTIVE_ERRORS,
|
_MAX_CONSECUTIVE_ERRORS,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
token_hash = hash_token(self._client._token) if self._client._token else None
|
|
||||||
await log_error(
|
|
||||||
self._db_factory,
|
|
||||||
severity="error",
|
|
||||||
source="automation",
|
|
||||||
exc=exc,
|
|
||||||
context={
|
|
||||||
"config_id": self._config_id,
|
|
||||||
"character": self._character_name,
|
|
||||||
"run_id": self._run_id,
|
|
||||||
"consecutive_errors": self._consecutive_errors,
|
|
||||||
},
|
|
||||||
user_token_hash=token_hash,
|
|
||||||
)
|
|
||||||
await self._log_action(
|
await self._log_action(
|
||||||
ActionPlan(ActionType.IDLE, reason=str(exc)),
|
ActionPlan(ActionType.IDLE, reason=str(exc)),
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -409,7 +336,96 @@ class AutomationRunner:
|
||||||
|
|
||||||
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
|
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
|
||||||
"""Dispatch an action plan to the appropriate client method."""
|
"""Dispatch an action plan to the appropriate client method."""
|
||||||
return await execute_action(self._client, self._character_name, plan)
|
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:
|
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
|
||||||
"""Extract cooldown information from an action response and update the tracker."""
|
"""Extract cooldown information from an action response and update the tracker."""
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.schemas.game import CharacterSchema
|
from app.schemas.game import CharacterSchema
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
|
||||||
from app.schemas.game import ItemSchema
|
|
||||||
|
|
||||||
|
|
||||||
class ActionType(str, Enum):
|
class ActionType(str, Enum):
|
||||||
"""All possible actions the automation runner can execute."""
|
"""All possible actions the automation runner can execute."""
|
||||||
|
|
@ -28,19 +21,12 @@ class ActionType(str, Enum):
|
||||||
CRAFT = "craft"
|
CRAFT = "craft"
|
||||||
RECYCLE = "recycle"
|
RECYCLE = "recycle"
|
||||||
GE_BUY = "ge_buy"
|
GE_BUY = "ge_buy"
|
||||||
GE_CREATE_BUY = "ge_create_buy"
|
|
||||||
GE_SELL = "ge_sell"
|
GE_SELL = "ge_sell"
|
||||||
GE_FILL = "ge_fill"
|
|
||||||
GE_CANCEL = "ge_cancel"
|
GE_CANCEL = "ge_cancel"
|
||||||
TASK_NEW = "task_new"
|
TASK_NEW = "task_new"
|
||||||
TASK_TRADE = "task_trade"
|
TASK_TRADE = "task_trade"
|
||||||
TASK_COMPLETE = "task_complete"
|
TASK_COMPLETE = "task_complete"
|
||||||
TASK_EXCHANGE = "task_exchange"
|
TASK_EXCHANGE = "task_exchange"
|
||||||
TASK_CANCEL = "task_cancel"
|
|
||||||
DEPOSIT_GOLD = "deposit_gold"
|
|
||||||
WITHDRAW_GOLD = "withdraw_gold"
|
|
||||||
NPC_BUY = "npc_buy"
|
|
||||||
NPC_SELL = "npc_sell"
|
|
||||||
IDLE = "idle"
|
IDLE = "idle"
|
||||||
COMPLETE = "complete"
|
COMPLETE = "complete"
|
||||||
|
|
||||||
|
|
@ -63,18 +49,9 @@ class BaseStrategy(ABC):
|
||||||
Subclasses must implement :meth:`next_action` and :meth:`get_state`.
|
Subclasses must implement :meth:`next_action` and :meth:`get_state`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
||||||
self,
|
|
||||||
config: dict,
|
|
||||||
pathfinder: Pathfinder,
|
|
||||||
equipment_optimizer: EquipmentOptimizer | None = None,
|
|
||||||
available_items: list[ItemSchema] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.config = config
|
self.config = config
|
||||||
self.pathfinder = pathfinder
|
self.pathfinder = pathfinder
|
||||||
self._equipment_optimizer = equipment_optimizer
|
|
||||||
self._available_items = available_items or []
|
|
||||||
self._auto_equip_checked = False
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||||
|
|
@ -120,27 +97,3 @@ class BaseStrategy(ABC):
|
||||||
def _is_at(character: CharacterSchema, x: int, y: int) -> bool:
|
def _is_at(character: CharacterSchema, x: int, y: int) -> bool:
|
||||||
"""Check whether the character is standing at the given tile."""
|
"""Check whether the character is standing at the given tile."""
|
||||||
return character.x == x and character.y == y
|
return character.x == x and character.y == y
|
||||||
|
|
||||||
def _check_auto_equip(self, character: CharacterSchema) -> ActionPlan | None:
|
|
||||||
"""Return an EQUIP action if better gear is available, else None.
|
|
||||||
|
|
||||||
Only runs once per strategy lifetime to avoid re-checking every tick.
|
|
||||||
"""
|
|
||||||
if self._auto_equip_checked:
|
|
||||||
return None
|
|
||||||
self._auto_equip_checked = True
|
|
||||||
|
|
||||||
if self._equipment_optimizer is None or not self._available_items:
|
|
||||||
return None
|
|
||||||
|
|
||||||
analysis = self._equipment_optimizer.suggest_equipment(
|
|
||||||
character, self._available_items
|
|
||||||
)
|
|
||||||
if analysis.suggestions:
|
|
||||||
best = analysis.suggestions[0]
|
|
||||||
return ActionPlan(
|
|
||||||
ActionType.EQUIP,
|
|
||||||
params={"code": best.suggested_item_code, "slot": best.slot},
|
|
||||||
reason=f"Auto-equip: {best.reason}",
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema
|
from app.schemas.game import CharacterSchema
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
|
||||||
from app.engine.decision.monster_selector import MonsterSelector
|
|
||||||
from app.schemas.game import ItemSchema, MonsterSchema
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -51,34 +43,18 @@ class CombatStrategy(BaseStrategy):
|
||||||
- deposit_loot: bool (default True)
|
- deposit_loot: bool (default True)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
||||||
self,
|
super().__init__(config, pathfinder)
|
||||||
config: dict,
|
|
||||||
pathfinder: Pathfinder,
|
|
||||||
monster_selector: MonsterSelector | None = None,
|
|
||||||
monsters_data: list[MonsterSchema] | None = None,
|
|
||||||
equipment_optimizer: EquipmentOptimizer | None = None,
|
|
||||||
available_items: list[ItemSchema] | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
config, pathfinder,
|
|
||||||
equipment_optimizer=equipment_optimizer,
|
|
||||||
available_items=available_items,
|
|
||||||
)
|
|
||||||
self._state = _CombatState.MOVE_TO_MONSTER
|
self._state = _CombatState.MOVE_TO_MONSTER
|
||||||
|
|
||||||
# Parsed config with defaults
|
# Parsed config with defaults
|
||||||
self._monster_code: str = config.get("monster_code", "")
|
self._monster_code: str = config["monster_code"]
|
||||||
self._heal_threshold: int = config.get("auto_heal_threshold", 50)
|
self._heal_threshold: int = config.get("auto_heal_threshold", 50)
|
||||||
self._heal_method: str = config.get("heal_method", "rest")
|
self._heal_method: str = config.get("heal_method", "rest")
|
||||||
self._consumable_code: str | None = config.get("consumable_code")
|
self._consumable_code: str | None = config.get("consumable_code")
|
||||||
self._min_inv_slots: int = config.get("min_inventory_slots", 3)
|
self._min_inv_slots: int = config.get("min_inventory_slots", 3)
|
||||||
self._deposit_loot: bool = config.get("deposit_loot", True)
|
self._deposit_loot: bool = config.get("deposit_loot", True)
|
||||||
|
|
||||||
# Decision modules
|
|
||||||
self._monster_selector = monster_selector
|
|
||||||
self._monsters_data = monsters_data or []
|
|
||||||
|
|
||||||
# Cached locations (resolved lazily)
|
# Cached locations (resolved lazily)
|
||||||
self._monster_pos: tuple[int, int] | None = None
|
self._monster_pos: tuple[int, int] | None = None
|
||||||
self._bank_pos: tuple[int, int] | None = None
|
self._bank_pos: tuple[int, int] | None = None
|
||||||
|
|
@ -87,18 +63,6 @@ class CombatStrategy(BaseStrategy):
|
||||||
return self._state.value
|
return self._state.value
|
||||||
|
|
||||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||||
# Auto-select monster if code is empty or "auto"
|
|
||||||
if (not self._monster_code or self._monster_code == "auto") and self._monster_selector and self._monsters_data:
|
|
||||||
selected = self._monster_selector.select_optimal(character, self._monsters_data)
|
|
||||||
if selected:
|
|
||||||
self._monster_code = selected.code
|
|
||||||
logger.info("Auto-selected monster %s for character %s", selected.code, character.name)
|
|
||||||
|
|
||||||
# Check auto-equip on first tick
|
|
||||||
equip_action = self._check_auto_equip(character)
|
|
||||||
if equip_action is not None:
|
|
||||||
return equip_action
|
|
||||||
|
|
||||||
# Lazily resolve monster and bank positions
|
# Lazily resolve monster and bank positions
|
||||||
self._resolve_locations(character)
|
self._resolve_locations(character)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema, ItemSchema
|
from app.schemas.game import CharacterSchema, ItemSchema
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.schemas.game import ResourceSchema
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,7 +62,6 @@ class CraftingStrategy(BaseStrategy):
|
||||||
config: dict,
|
config: dict,
|
||||||
pathfinder: Pathfinder,
|
pathfinder: Pathfinder,
|
||||||
items_data: list[ItemSchema] | None = None,
|
items_data: list[ItemSchema] | None = None,
|
||||||
resources_data: list[ResourceSchema] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(config, pathfinder)
|
super().__init__(config, pathfinder)
|
||||||
self._state = _CraftState.CHECK_MATERIALS
|
self._state = _CraftState.CHECK_MATERIALS
|
||||||
|
|
@ -88,9 +81,6 @@ class CraftingStrategy(BaseStrategy):
|
||||||
self._craft_level: int = 0
|
self._craft_level: int = 0
|
||||||
self._recipe_resolved: bool = False
|
self._recipe_resolved: bool = False
|
||||||
|
|
||||||
# Game data for gathering resolution
|
|
||||||
self._resources_data: list[ResourceSchema] = resources_data or []
|
|
||||||
|
|
||||||
# If items data is provided, resolve the recipe immediately
|
# If items data is provided, resolve the recipe immediately
|
||||||
if items_data:
|
if items_data:
|
||||||
self._resolve_recipe(items_data)
|
self._resolve_recipe(items_data)
|
||||||
|
|
@ -196,18 +186,11 @@ class CraftingStrategy(BaseStrategy):
|
||||||
# Withdraw the first missing material
|
# Withdraw the first missing material
|
||||||
code, needed_qty = next(iter(missing.items()))
|
code, needed_qty = next(iter(missing.items()))
|
||||||
|
|
||||||
# If gather_materials is enabled and we can determine a resource for this material,
|
# If we should gather and we can't withdraw, switch to gather mode
|
||||||
# try gathering instead of just hoping the bank has it
|
|
||||||
if self._gather_materials:
|
if self._gather_materials:
|
||||||
resource_code = self._find_resource_for_material(code)
|
# We'll try to withdraw; if it fails the runner will handle the error
|
||||||
if resource_code:
|
# and we can switch to gathering mode. For now, attempt the withdraw.
|
||||||
self._gather_resource_code = resource_code
|
pass
|
||||||
self._gather_pos = self.pathfinder.find_nearest(
|
|
||||||
character.x, character.y, "resource", resource_code
|
|
||||||
)
|
|
||||||
if self._gather_pos:
|
|
||||||
self._state = _CraftState.GATHER_MATERIALS
|
|
||||||
return self._handle_gather_materials(character)
|
|
||||||
|
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
ActionType.WITHDRAW_ITEM,
|
ActionType.WITHDRAW_ITEM,
|
||||||
|
|
@ -400,14 +383,6 @@ class CraftingStrategy(BaseStrategy):
|
||||||
|
|
||||||
return missing
|
return missing
|
||||||
|
|
||||||
def _find_resource_for_material(self, material_code: str) -> str | None:
|
|
||||||
"""Look up which resource drops the needed material."""
|
|
||||||
for resource in self._resources_data:
|
|
||||||
for drop in resource.drops:
|
|
||||||
if drop.code == material_code:
|
|
||||||
return resource.code
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _resolve_locations(self, character: CharacterSchema) -> None:
|
def _resolve_locations(self, character: CharacterSchema) -> None:
|
||||||
"""Lazily resolve and cache workshop and bank tile positions."""
|
"""Lazily resolve and cache workshop and bank tile positions."""
|
||||||
if self._workshop_pos is None and self._craft_skill:
|
if self._workshop_pos is None and self._craft_skill:
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema
|
from app.schemas.game import CharacterSchema
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.engine.decision.resource_selector import ResourceSelector
|
|
||||||
from app.schemas.game import ResourceSchema
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,25 +36,15 @@ class GatheringStrategy(BaseStrategy):
|
||||||
- max_loops: int (default 0 = infinite)
|
- max_loops: int (default 0 = infinite)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
||||||
self,
|
|
||||||
config: dict,
|
|
||||||
pathfinder: Pathfinder,
|
|
||||||
resource_selector: ResourceSelector | None = None,
|
|
||||||
resources_data: list[ResourceSchema] | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(config, pathfinder)
|
super().__init__(config, pathfinder)
|
||||||
self._state = _GatherState.MOVE_TO_RESOURCE
|
self._state = _GatherState.MOVE_TO_RESOURCE
|
||||||
|
|
||||||
# Parsed config with defaults
|
# Parsed config with defaults
|
||||||
self._resource_code: str = config.get("resource_code", "")
|
self._resource_code: str = config["resource_code"]
|
||||||
self._deposit_on_full: bool = config.get("deposit_on_full", True)
|
self._deposit_on_full: bool = config.get("deposit_on_full", True)
|
||||||
self._max_loops: int = config.get("max_loops", 0)
|
self._max_loops: int = config.get("max_loops", 0)
|
||||||
|
|
||||||
# Decision modules
|
|
||||||
self._resource_selector = resource_selector
|
|
||||||
self._resources_data = resources_data or []
|
|
||||||
|
|
||||||
# Runtime counters
|
# Runtime counters
|
||||||
self._loop_count: int = 0
|
self._loop_count: int = 0
|
||||||
|
|
||||||
|
|
@ -73,17 +56,6 @@ class GatheringStrategy(BaseStrategy):
|
||||||
return self._state.value
|
return self._state.value
|
||||||
|
|
||||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||||
# Auto-select resource if code is empty or "auto"
|
|
||||||
if (not self._resource_code or self._resource_code == "auto") and self._resource_selector and self._resources_data:
|
|
||||||
# Determine the skill from the resource_code config or default to mining
|
|
||||||
skill = config.get("skill", "") if (config := self.config) else ""
|
|
||||||
if not skill:
|
|
||||||
skill = "mining"
|
|
||||||
selection = self._resource_selector.select_optimal(character, self._resources_data, skill)
|
|
||||||
if selection:
|
|
||||||
self._resource_code = selection.resource.code
|
|
||||||
logger.info("Auto-selected resource %s for character %s", selection.resource.code, character.name)
|
|
||||||
|
|
||||||
# Check loop limit
|
# Check loop limit
|
||||||
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema, ResourceSchema
|
from app.schemas.game import CharacterSchema, ResourceSchema
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
|
|
||||||
from app.engine.decision.monster_selector import MonsterSelector
|
|
||||||
from app.engine.decision.resource_selector import ResourceSelector
|
|
||||||
from app.schemas.game import ItemSchema, MonsterSchema
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# All skills in the game with their gathering/crafting type
|
# All skills in the game with their gathering/crafting type
|
||||||
|
|
@ -53,17 +44,8 @@ class LevelingStrategy(BaseStrategy):
|
||||||
config: dict,
|
config: dict,
|
||||||
pathfinder: Pathfinder,
|
pathfinder: Pathfinder,
|
||||||
resources_data: list[ResourceSchema] | None = None,
|
resources_data: list[ResourceSchema] | None = None,
|
||||||
monsters_data: list[MonsterSchema] | None = None,
|
|
||||||
resource_selector: ResourceSelector | None = None,
|
|
||||||
monster_selector: MonsterSelector | None = None,
|
|
||||||
equipment_optimizer: EquipmentOptimizer | None = None,
|
|
||||||
available_items: list[ItemSchema] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(config, pathfinder)
|
||||||
config, pathfinder,
|
|
||||||
equipment_optimizer=equipment_optimizer,
|
|
||||||
available_items=available_items,
|
|
||||||
)
|
|
||||||
self._state = _LevelingState.EVALUATE
|
self._state = _LevelingState.EVALUATE
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
|
|
@ -73,11 +55,6 @@ class LevelingStrategy(BaseStrategy):
|
||||||
|
|
||||||
# Resolved from game data
|
# Resolved from game data
|
||||||
self._resources_data: list[ResourceSchema] = resources_data or []
|
self._resources_data: list[ResourceSchema] = resources_data or []
|
||||||
self._monsters_data: list[MonsterSchema] = monsters_data or []
|
|
||||||
|
|
||||||
# Decision modules
|
|
||||||
self._resource_selector = resource_selector
|
|
||||||
self._monster_selector = monster_selector
|
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
self._chosen_skill: str = ""
|
self._chosen_skill: str = ""
|
||||||
|
|
@ -99,11 +76,6 @@ class LevelingStrategy(BaseStrategy):
|
||||||
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
async def next_action(self, character: CharacterSchema) -> ActionPlan:
|
||||||
self._resolve_bank(character)
|
self._resolve_bank(character)
|
||||||
|
|
||||||
# Check auto-equip before combat
|
|
||||||
equip_action = self._check_auto_equip(character)
|
|
||||||
if equip_action is not None:
|
|
||||||
return equip_action
|
|
||||||
|
|
||||||
match self._state:
|
match self._state:
|
||||||
case _LevelingState.EVALUATE:
|
case _LevelingState.EVALUATE:
|
||||||
return self._handle_evaluate(character)
|
return self._handle_evaluate(character)
|
||||||
|
|
@ -313,17 +285,6 @@ class LevelingStrategy(BaseStrategy):
|
||||||
self._target_pos = None
|
self._target_pos = None
|
||||||
return self._handle_evaluate(character)
|
return self._handle_evaluate(character)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Location resolution
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _resolve_bank(self, character: CharacterSchema) -> None:
|
|
||||||
"""Lazily resolve and cache the nearest bank tile position."""
|
|
||||||
if self._bank_pos is None:
|
|
||||||
self._bank_pos = self.pathfinder.find_nearest_by_type(
|
|
||||||
character.x, character.y, "bank"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Skill analysis helpers
|
# Skill analysis helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -361,35 +322,27 @@ class LevelingStrategy(BaseStrategy):
|
||||||
skill_level: int,
|
skill_level: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Choose the best resource to gather for a given skill and level."""
|
"""Choose the best resource to gather for a given skill and level."""
|
||||||
# Try the ResourceSelector decision module first
|
# Filter resources matching the skill
|
||||||
if self._resource_selector and self._resources_data:
|
|
||||||
selection = self._resource_selector.select_optimal(
|
|
||||||
character, self._resources_data, skill
|
|
||||||
)
|
|
||||||
if selection:
|
|
||||||
self._chosen_resource_code = selection.resource.code
|
|
||||||
self._target_pos = self.pathfinder.find_nearest(
|
|
||||||
character.x, character.y, "resource", selection.resource.code
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fallback: inline logic using resources_data
|
|
||||||
matching = [r for r in self._resources_data if r.skill == skill]
|
matching = [r for r in self._resources_data if r.skill == skill]
|
||||||
if not matching:
|
if not matching:
|
||||||
|
# Fallback: use pathfinder to find any resource of this skill
|
||||||
self._target_pos = self.pathfinder.find_nearest_by_type(
|
self._target_pos = self.pathfinder.find_nearest_by_type(
|
||||||
character.x, character.y, "resource"
|
character.x, character.y, "resource"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Find the best resource within +-3 levels
|
||||||
candidates = []
|
candidates = []
|
||||||
for r in matching:
|
for r in matching:
|
||||||
diff = r.level - skill_level
|
diff = r.level - skill_level
|
||||||
if diff <= 3:
|
if diff <= 3: # Can gather up to 3 levels above
|
||||||
candidates.append(r)
|
candidates.append(r)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
|
# No resources within range, pick the lowest level one
|
||||||
candidates = matching
|
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)
|
best = max(candidates, key=lambda r: r.level if r.level <= skill_level + 3 else -r.level)
|
||||||
self._chosen_resource_code = best.code
|
self._chosen_resource_code = best.code
|
||||||
|
|
||||||
|
|
@ -399,17 +352,7 @@ class LevelingStrategy(BaseStrategy):
|
||||||
|
|
||||||
def _choose_combat_target(self, character: CharacterSchema) -> None:
|
def _choose_combat_target(self, character: CharacterSchema) -> None:
|
||||||
"""Choose a monster appropriate for the character's combat level."""
|
"""Choose a monster appropriate for the character's combat level."""
|
||||||
# Try the MonsterSelector decision module first
|
# Find a monster near the character's level
|
||||||
if self._monster_selector and self._monsters_data:
|
|
||||||
selected = self._monster_selector.select_optimal(character, self._monsters_data)
|
|
||||||
if selected:
|
|
||||||
self._chosen_monster_code = selected.code
|
|
||||||
self._target_pos = self.pathfinder.find_nearest(
|
|
||||||
character.x, character.y, "monster", selected.code
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fallback: find any nearby monster
|
|
||||||
self._chosen_monster_code = ""
|
self._chosen_monster_code = ""
|
||||||
self._target_pos = self.pathfinder.find_nearest_by_type(
|
self._target_pos = self.pathfinder.find_nearest_by_type(
|
||||||
character.x, character.y, "monster"
|
character.x, character.y, "monster"
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
||||||
from app.schemas.game import CharacterSchema
|
from app.schemas.game import CharacterSchema
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -28,6 +22,10 @@ class _TradingState(str, Enum):
|
||||||
DEPOSIT_ITEMS = "deposit_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):
|
class _TradingMode(str, Enum):
|
||||||
SELL_LOOT = "sell_loot"
|
SELL_LOOT = "sell_loot"
|
||||||
BUY_MATERIALS = "buy_materials"
|
BUY_MATERIALS = "buy_materials"
|
||||||
|
|
@ -51,12 +49,7 @@ class TradingStrategy(BaseStrategy):
|
||||||
- max_price: int (default 0) -- maximum acceptable price (0 = no limit)
|
- max_price: int (default 0) -- maximum acceptable price (0 = no limit)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
|
||||||
self,
|
|
||||||
config: dict,
|
|
||||||
pathfinder: Pathfinder,
|
|
||||||
client: ArtifactsClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(config, pathfinder)
|
super().__init__(config, pathfinder)
|
||||||
|
|
||||||
# Parse config
|
# Parse config
|
||||||
|
|
@ -72,9 +65,6 @@ class TradingStrategy(BaseStrategy):
|
||||||
self._min_price: int = config.get("min_price", 0)
|
self._min_price: int = config.get("min_price", 0)
|
||||||
self._max_price: int = config.get("max_price", 0)
|
self._max_price: int = config.get("max_price", 0)
|
||||||
|
|
||||||
# Client for GE order polling
|
|
||||||
self._client = client
|
|
||||||
|
|
||||||
# Determine initial state based on mode
|
# Determine initial state based on mode
|
||||||
if self._mode == _TradingMode.SELL_LOOT:
|
if self._mode == _TradingMode.SELL_LOOT:
|
||||||
self._state = _TradingState.MOVE_TO_BANK
|
self._state = _TradingState.MOVE_TO_BANK
|
||||||
|
|
@ -88,7 +78,6 @@ class TradingStrategy(BaseStrategy):
|
||||||
# Runtime state
|
# Runtime state
|
||||||
self._items_withdrawn: int = 0
|
self._items_withdrawn: int = 0
|
||||||
self._orders_created: bool = False
|
self._orders_created: bool = False
|
||||||
self._active_order_id: str | None = None
|
|
||||||
self._wait_cycles: int = 0
|
self._wait_cycles: int = 0
|
||||||
|
|
||||||
# Cached positions
|
# Cached positions
|
||||||
|
|
@ -238,7 +227,7 @@ class TradingStrategy(BaseStrategy):
|
||||||
self._state = _TradingState.WAIT_FOR_ORDER
|
self._state = _TradingState.WAIT_FOR_ORDER
|
||||||
|
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
ActionType.GE_CREATE_BUY,
|
ActionType.GE_BUY,
|
||||||
params={
|
params={
|
||||||
"code": self._item_code,
|
"code": self._item_code,
|
||||||
"quantity": self._quantity,
|
"quantity": self._quantity,
|
||||||
|
|
@ -250,38 +239,26 @@ class TradingStrategy(BaseStrategy):
|
||||||
def _handle_wait_for_order(self, character: CharacterSchema) -> ActionPlan:
|
def _handle_wait_for_order(self, character: CharacterSchema) -> ActionPlan:
|
||||||
self._wait_cycles += 1
|
self._wait_cycles += 1
|
||||||
|
|
||||||
# Poll every 3 cycles to avoid API spam
|
# Wait for a reasonable time, then check
|
||||||
if self._wait_cycles % 3 != 0:
|
if self._wait_cycles < 3:
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
ActionType.IDLE,
|
ActionType.IDLE,
|
||||||
reason=f"Waiting for GE order to fill (cycle {self._wait_cycles})",
|
reason=f"Waiting for GE order to fill (cycle {self._wait_cycles})",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the order is still active
|
# After waiting, check orders
|
||||||
self._state = _TradingState.CHECK_ORDERS
|
self._state = _TradingState.CHECK_ORDERS
|
||||||
return self._handle_check_orders(character)
|
return self._handle_check_orders(character)
|
||||||
|
|
||||||
def _handle_check_orders(self, character: CharacterSchema) -> ActionPlan:
|
def _handle_check_orders(self, character: CharacterSchema) -> ActionPlan:
|
||||||
# If we have a client and an order ID, poll the actual order status
|
# For now, just complete after creating orders
|
||||||
# This is an async check, but since next_action is async we handle it
|
# In a full implementation, we'd check the GE order status
|
||||||
# by transitioning: the runner will call next_action again next tick
|
|
||||||
if self._active_order_id and self._client:
|
|
||||||
# We'll check on the next tick since we can't await here easily
|
|
||||||
# For now, just keep waiting unless we've waited a long time
|
|
||||||
if self._wait_cycles < 30:
|
|
||||||
self._state = _TradingState.WAIT_FOR_ORDER
|
|
||||||
return ActionPlan(
|
|
||||||
ActionType.IDLE,
|
|
||||||
reason=f"Checking order {self._active_order_id} status (cycle {self._wait_cycles})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# After enough waiting or no client, assume order is done
|
|
||||||
if self._mode == _TradingMode.FLIP and self._orders_created:
|
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
|
self._state = _TradingState.CREATE_SELL_ORDER
|
||||||
self._orders_created = False # Reset for sell phase
|
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
ActionType.IDLE,
|
ActionType.IDLE,
|
||||||
reason="Buy order assumed filled, preparing sell order",
|
reason="Checking order status for flip trade",
|
||||||
)
|
)
|
||||||
|
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
from app.engine.workflow.conditions import TransitionEvaluator, TransitionType
|
|
||||||
from app.engine.workflow.runner import WorkflowRunner
|
|
||||||
|
|
||||||
__all__ = ["TransitionEvaluator", "TransitionType", "WorkflowRunner"]
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.schemas.game import CharacterSchema
|
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionType(str, Enum):
|
|
||||||
STRATEGY_COMPLETE = "strategy_complete"
|
|
||||||
LOOPS_COMPLETED = "loops_completed"
|
|
||||||
INVENTORY_FULL = "inventory_full"
|
|
||||||
INVENTORY_ITEM_COUNT = "inventory_item_count"
|
|
||||||
BANK_ITEM_COUNT = "bank_item_count"
|
|
||||||
SKILL_LEVEL = "skill_level"
|
|
||||||
GOLD_AMOUNT = "gold_amount"
|
|
||||||
ACTIONS_COUNT = "actions_count"
|
|
||||||
TIMER = "timer"
|
|
||||||
|
|
||||||
|
|
||||||
def _compare(actual: int | float, operator: str, target: int | float) -> bool:
|
|
||||||
"""Compare a value using a string operator."""
|
|
||||||
match operator:
|
|
||||||
case ">=":
|
|
||||||
return actual >= target
|
|
||||||
case "<=":
|
|
||||||
return actual <= target
|
|
||||||
case "==":
|
|
||||||
return actual == target
|
|
||||||
case ">":
|
|
||||||
return actual > target
|
|
||||||
case "<":
|
|
||||||
return actual < target
|
|
||||||
case _:
|
|
||||||
return actual >= target
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionEvaluator:
|
|
||||||
"""Evaluates transition conditions for workflow steps."""
|
|
||||||
|
|
||||||
def __init__(self, client: ArtifactsClient) -> None:
|
|
||||||
self._client = client
|
|
||||||
self._bank_cache: list[dict[str, Any]] | None = None
|
|
||||||
self._bank_cache_tick: int = 0
|
|
||||||
self._tick_counter: int = 0
|
|
||||||
|
|
||||||
async def should_transition(
|
|
||||||
self,
|
|
||||||
condition: dict,
|
|
||||||
character: CharacterSchema,
|
|
||||||
*,
|
|
||||||
actions_count: int = 0,
|
|
||||||
step_start_time: float = 0.0,
|
|
||||||
strategy_completed: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Check whether the transition condition is met.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
condition:
|
|
||||||
The transition condition dict with keys: type, operator, value,
|
|
||||||
item_code, skill, seconds.
|
|
||||||
character:
|
|
||||||
Current character state.
|
|
||||||
actions_count:
|
|
||||||
Number of actions executed in the current step.
|
|
||||||
step_start_time:
|
|
||||||
Timestamp when the current step started.
|
|
||||||
strategy_completed:
|
|
||||||
True if the underlying strategy returned COMPLETE.
|
|
||||||
"""
|
|
||||||
self._tick_counter += 1
|
|
||||||
cond_type = condition.get("type", "")
|
|
||||||
operator = condition.get("operator", ">=")
|
|
||||||
target_value = condition.get("value", 0)
|
|
||||||
|
|
||||||
try:
|
|
||||||
match cond_type:
|
|
||||||
case TransitionType.STRATEGY_COMPLETE:
|
|
||||||
return strategy_completed
|
|
||||||
|
|
||||||
case TransitionType.LOOPS_COMPLETED:
|
|
||||||
# This is handled externally by the workflow runner
|
|
||||||
return False
|
|
||||||
|
|
||||||
case TransitionType.INVENTORY_FULL:
|
|
||||||
free_slots = character.inventory_max_items - len(character.inventory)
|
|
||||||
return free_slots == 0
|
|
||||||
|
|
||||||
case TransitionType.INVENTORY_ITEM_COUNT:
|
|
||||||
item_code = condition.get("item_code", "")
|
|
||||||
count = sum(
|
|
||||||
s.quantity
|
|
||||||
for s in character.inventory
|
|
||||||
if s.code == item_code
|
|
||||||
)
|
|
||||||
return _compare(count, operator, target_value)
|
|
||||||
|
|
||||||
case TransitionType.BANK_ITEM_COUNT:
|
|
||||||
item_code = condition.get("item_code", "")
|
|
||||||
bank_count = await self._get_bank_item_count(item_code)
|
|
||||||
return _compare(bank_count, operator, target_value)
|
|
||||||
|
|
||||||
case TransitionType.SKILL_LEVEL:
|
|
||||||
skill = condition.get("skill", "")
|
|
||||||
level = getattr(character, f"{skill}_level", 0)
|
|
||||||
return _compare(level, operator, target_value)
|
|
||||||
|
|
||||||
case TransitionType.GOLD_AMOUNT:
|
|
||||||
return _compare(character.gold, operator, target_value)
|
|
||||||
|
|
||||||
case TransitionType.ACTIONS_COUNT:
|
|
||||||
return _compare(actions_count, operator, target_value)
|
|
||||||
|
|
||||||
case TransitionType.TIMER:
|
|
||||||
seconds = condition.get("seconds", 0)
|
|
||||||
if step_start_time <= 0:
|
|
||||||
return False
|
|
||||||
elapsed = time.time() - step_start_time
|
|
||||||
return elapsed >= seconds
|
|
||||||
|
|
||||||
case _:
|
|
||||||
logger.warning("Unknown transition type: %s", cond_type)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Error evaluating transition condition: %s", condition)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _get_bank_item_count(self, item_code: str) -> int:
|
|
||||||
"""Get item count from bank, with rate-limited caching (every 10 ticks)."""
|
|
||||||
if (
|
|
||||||
self._bank_cache is None
|
|
||||||
or self._tick_counter - self._bank_cache_tick >= 10
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
self._bank_cache = await self._client.get_bank_items()
|
|
||||||
self._bank_cache_tick = self._tick_counter
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to fetch bank items for transition check")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if self._bank_cache is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
for item in self._bank_cache:
|
|
||||||
if isinstance(item, dict) and item.get("code") == item_code:
|
|
||||||
return item.get("quantity", 0)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
"""Reset caches when advancing to a new step."""
|
|
||||||
self._bank_cache = None
|
|
||||||
self._bank_cache_tick = 0
|
|
||||||
|
|
@ -1,543 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
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.action_executor import execute_action
|
|
||||||
from app.engine.cooldown import CooldownTracker
|
|
||||||
from app.engine.pathfinder import Pathfinder
|
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
|
||||||
from app.engine.workflow.conditions import TransitionEvaluator
|
|
||||||
from app.models.automation import AutomationLog
|
|
||||||
from app.models.workflow import WorkflowRun
|
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.websocket.event_bus import EventBus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_ERROR_RETRY_DELAY: float = 2.0
|
|
||||||
_MAX_CONSECUTIVE_ERRORS: int = 10
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowRunner:
|
|
||||||
"""Runs a multi-step workflow pipeline for a single character.
|
|
||||||
|
|
||||||
Each step contains a strategy that is driven in a loop identical to
|
|
||||||
:class:`AutomationRunner`. After each tick the runner evaluates the
|
|
||||||
step's transition condition; when met it advances to the next step.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
workflow_id: int,
|
|
||||||
character_name: str,
|
|
||||||
steps: list[dict],
|
|
||||||
loop: bool,
|
|
||||||
max_loops: int,
|
|
||||||
strategy_factory: Any, # callable(strategy_type, config) -> BaseStrategy
|
|
||||||
client: ArtifactsClient,
|
|
||||||
cooldown_tracker: CooldownTracker,
|
|
||||||
db_factory: async_sessionmaker[AsyncSession],
|
|
||||||
run_id: int,
|
|
||||||
event_bus: EventBus | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._workflow_id = workflow_id
|
|
||||||
self._character_name = character_name
|
|
||||||
self._steps = steps
|
|
||||||
self._loop = loop
|
|
||||||
self._max_loops = max_loops
|
|
||||||
self._strategy_factory = strategy_factory
|
|
||||||
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
|
|
||||||
|
|
||||||
# Runtime state
|
|
||||||
self._current_step_index: int = 0
|
|
||||||
self._loop_count: int = 0
|
|
||||||
self._total_actions: int = 0
|
|
||||||
self._step_actions: int = 0
|
|
||||||
self._step_start_time: float = 0.0
|
|
||||||
self._step_history: list[dict] = []
|
|
||||||
self._consecutive_errors: int = 0
|
|
||||||
|
|
||||||
# Current strategy
|
|
||||||
self._strategy: BaseStrategy | None = None
|
|
||||||
self._transition_evaluator = TransitionEvaluator(client)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public properties
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workflow_id(self) -> int:
|
|
||||||
return self._workflow_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def character_name(self) -> str:
|
|
||||||
return self._character_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def run_id(self) -> int:
|
|
||||||
return self._run_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_step_index(self) -> int:
|
|
||||||
return self._current_step_index
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_step_id(self) -> str:
|
|
||||||
if 0 <= self._current_step_index < len(self._steps):
|
|
||||||
return self._steps[self._current_step_index].get("id", "")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loop_count(self) -> int:
|
|
||||||
return self._loop_count
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total_actions_count(self) -> int:
|
|
||||||
return self._total_actions
|
|
||||||
|
|
||||||
@property
|
|
||||||
def step_actions_count(self) -> int:
|
|
||||||
return self._step_actions
|
|
||||||
|
|
||||||
@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:
|
|
||||||
if self._strategy is not None:
|
|
||||||
return self._strategy.get_state()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Event bus helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _publish(self, event_type: str, data: dict) -> None:
|
|
||||||
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:
|
|
||||||
await self._publish(
|
|
||||||
"workflow_status_changed",
|
|
||||||
{
|
|
||||||
"workflow_id": self._workflow_id,
|
|
||||||
"character_name": self._character_name,
|
|
||||||
"status": status,
|
|
||||||
"run_id": self._run_id,
|
|
||||||
"current_step_index": self._current_step_index,
|
|
||||||
"loop_count": self._loop_count,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _publish_action(
|
|
||||||
self,
|
|
||||||
action_type: str,
|
|
||||||
success: bool,
|
|
||||||
details: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
await self._publish(
|
|
||||||
"workflow_action",
|
|
||||||
{
|
|
||||||
"workflow_id": self._workflow_id,
|
|
||||||
"character_name": self._character_name,
|
|
||||||
"action_type": action_type,
|
|
||||||
"success": success,
|
|
||||||
"details": details or {},
|
|
||||||
"total_actions_count": self._total_actions,
|
|
||||||
"step_index": self._current_step_index,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
self._running = True
|
|
||||||
self._paused = False
|
|
||||||
|
|
||||||
# Initialize strategy for the first step
|
|
||||||
self._init_step(self._current_step_index)
|
|
||||||
|
|
||||||
self._task = asyncio.create_task(
|
|
||||||
self._run_loop(),
|
|
||||||
name=f"workflow-{self._workflow_id}-{self._character_name}",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Started workflow runner workflow=%d character=%s run=%d",
|
|
||||||
self._workflow_id,
|
|
||||||
self._character_name,
|
|
||||||
self._run_id,
|
|
||||||
)
|
|
||||||
await self._publish_status("running")
|
|
||||||
|
|
||||||
async def stop(self, error_message: str | None = None) -> None:
|
|
||||||
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
|
|
||||||
|
|
||||||
final_status = "error" if error_message else "stopped"
|
|
||||||
await self._finalize_run(status=final_status, error_message=error_message)
|
|
||||||
logger.info(
|
|
||||||
"Stopped workflow runner workflow=%d (actions=%d)",
|
|
||||||
self._workflow_id,
|
|
||||||
self._total_actions,
|
|
||||||
)
|
|
||||||
await self._publish_status(final_status)
|
|
||||||
|
|
||||||
async def pause(self) -> None:
|
|
||||||
self._paused = True
|
|
||||||
await self._update_run_status("paused")
|
|
||||||
await self._publish_status("paused")
|
|
||||||
|
|
||||||
async def resume(self) -> None:
|
|
||||||
self._paused = False
|
|
||||||
await self._update_run_status("running")
|
|
||||||
await self._publish_status("running")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Main loop
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
|
||||||
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 workflow loop workflow=%d (error %d/%d): %s",
|
|
||||||
self._workflow_id,
|
|
||||||
self._consecutive_errors,
|
|
||||||
_MAX_CONSECUTIVE_ERRORS,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
await self._log_action(
|
|
||||||
ActionPlan(ActionType.IDLE, reason=str(exc)),
|
|
||||||
success=False,
|
|
||||||
)
|
|
||||||
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
|
||||||
logger.error(
|
|
||||||
"Too many consecutive errors for workflow %d, stopping",
|
|
||||||
self._workflow_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("Workflow loop for %d was cancelled", self._workflow_id)
|
|
||||||
|
|
||||||
async def _tick(self) -> None:
|
|
||||||
"""Execute a single iteration of the workflow loop."""
|
|
||||||
if self._strategy is None:
|
|
||||||
logger.error("No strategy for workflow %d step %d", self._workflow_id, self._current_step_index)
|
|
||||||
self._running = False
|
|
||||||
return
|
|
||||||
|
|
||||||
# 1. Wait for cooldown
|
|
||||||
await self._cooldown.wait(self._character_name)
|
|
||||||
|
|
||||||
# 2. Fetch character
|
|
||||||
character = await self._client.get_character(self._character_name)
|
|
||||||
|
|
||||||
# 3. Ask strategy for next action
|
|
||||||
plan = await self._strategy.next_action(character)
|
|
||||||
|
|
||||||
strategy_completed = plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
# 4. Check transition condition BEFORE executing the action
|
|
||||||
step = self._steps[self._current_step_index]
|
|
||||||
transition = step.get("transition")
|
|
||||||
|
|
||||||
if transition is not None:
|
|
||||||
should_advance = await self._transition_evaluator.should_transition(
|
|
||||||
transition,
|
|
||||||
character,
|
|
||||||
actions_count=self._step_actions,
|
|
||||||
step_start_time=self._step_start_time,
|
|
||||||
strategy_completed=strategy_completed,
|
|
||||||
)
|
|
||||||
if should_advance:
|
|
||||||
await self._advance_step()
|
|
||||||
return
|
|
||||||
|
|
||||||
# 5. If strategy completed and no transition, treat it as step done
|
|
||||||
if strategy_completed:
|
|
||||||
if transition is None:
|
|
||||||
# No explicit transition means strategy_complete is the implicit trigger
|
|
||||||
await self._advance_step()
|
|
||||||
return
|
|
||||||
# Strategy completed but transition not met yet -- idle
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
return
|
|
||||||
|
|
||||||
if plan.action_type == ActionType.IDLE:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 6. Execute the action
|
|
||||||
result = await self._execute_action(plan)
|
|
||||||
|
|
||||||
# 7. Update cooldown
|
|
||||||
self._update_cooldown_from_result(result)
|
|
||||||
|
|
||||||
# 8. Record
|
|
||||||
self._total_actions += 1
|
|
||||||
self._step_actions += 1
|
|
||||||
await self._log_action(plan, success=True)
|
|
||||||
|
|
||||||
# 9. Publish
|
|
||||||
await self._publish_action(
|
|
||||||
plan.action_type.value,
|
|
||||||
success=True,
|
|
||||||
details={
|
|
||||||
"params": plan.params,
|
|
||||||
"reason": plan.reason,
|
|
||||||
"strategy_state": self._strategy.get_state() if self._strategy else "",
|
|
||||||
"step_index": self._current_step_index,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self._publish(
|
|
||||||
"character_update",
|
|
||||||
{"character_name": self._character_name},
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Step management
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _init_step(self, index: int) -> None:
|
|
||||||
"""Initialize a strategy for the step at the given index."""
|
|
||||||
if index < 0 or index >= len(self._steps):
|
|
||||||
self._strategy = None
|
|
||||||
return
|
|
||||||
|
|
||||||
step = self._steps[index]
|
|
||||||
self._current_step_index = index
|
|
||||||
self._step_actions = 0
|
|
||||||
self._step_start_time = time.time()
|
|
||||||
self._transition_evaluator.reset()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._strategy = self._strategy_factory(
|
|
||||||
step["strategy_type"],
|
|
||||||
step.get("config", {}),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to create strategy for workflow %d step %d",
|
|
||||||
self._workflow_id,
|
|
||||||
index,
|
|
||||||
)
|
|
||||||
self._strategy = None
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Workflow %d initialized step %d/%d: %s (%s)",
|
|
||||||
self._workflow_id,
|
|
||||||
index + 1,
|
|
||||||
len(self._steps),
|
|
||||||
step.get("name", ""),
|
|
||||||
step.get("strategy_type", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _advance_step(self) -> None:
|
|
||||||
"""Advance to the next step or finish the workflow."""
|
|
||||||
# Record completed step
|
|
||||||
step = self._steps[self._current_step_index]
|
|
||||||
self._step_history.append({
|
|
||||||
"step_id": step.get("id", ""),
|
|
||||||
"step_name": step.get("name", ""),
|
|
||||||
"actions_count": self._step_actions,
|
|
||||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Workflow %d step %d completed (%s, %d actions)",
|
|
||||||
self._workflow_id,
|
|
||||||
self._current_step_index,
|
|
||||||
step.get("name", ""),
|
|
||||||
self._step_actions,
|
|
||||||
)
|
|
||||||
|
|
||||||
next_index = self._current_step_index + 1
|
|
||||||
|
|
||||||
if next_index >= len(self._steps):
|
|
||||||
# Reached end of steps
|
|
||||||
if self._loop:
|
|
||||||
self._loop_count += 1
|
|
||||||
if self._max_loops > 0 and self._loop_count >= self._max_loops:
|
|
||||||
# Hit loop limit
|
|
||||||
await self._finalize_run(status="completed")
|
|
||||||
self._running = False
|
|
||||||
await self._publish_status("completed")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Loop back to step 0
|
|
||||||
logger.info(
|
|
||||||
"Workflow %d looping (loop %d)",
|
|
||||||
self._workflow_id,
|
|
||||||
self._loop_count,
|
|
||||||
)
|
|
||||||
self._init_step(0)
|
|
||||||
else:
|
|
||||||
# No loop, workflow complete
|
|
||||||
await self._finalize_run(status="completed")
|
|
||||||
self._running = False
|
|
||||||
await self._publish_status("completed")
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# Advance to next step
|
|
||||||
self._init_step(next_index)
|
|
||||||
|
|
||||||
# Update run record
|
|
||||||
await self._update_run_progress()
|
|
||||||
await self._publish_status("running")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Action execution (mirrors AutomationRunner._execute_action)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
|
|
||||||
return await execute_action(self._client, self._character_name, plan)
|
|
||||||
|
|
||||||
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
|
|
||||||
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:
|
|
||||||
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() if self._strategy else "",
|
|
||||||
"workflow_id": self._workflow_id,
|
|
||||||
"step_index": self._current_step_index,
|
|
||||||
},
|
|
||||||
success=success,
|
|
||||||
)
|
|
||||||
db.add(log)
|
|
||||||
await db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to log workflow action for run %d", self._run_id)
|
|
||||||
|
|
||||||
async def _update_run_status(self, status: str) -> None:
|
|
||||||
try:
|
|
||||||
async with self._db_factory() as db:
|
|
||||||
stmt = select(WorkflowRun).where(WorkflowRun.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 workflow run %d status", self._run_id)
|
|
||||||
|
|
||||||
async def _update_run_progress(self) -> None:
|
|
||||||
try:
|
|
||||||
async with self._db_factory() as db:
|
|
||||||
stmt = select(WorkflowRun).where(WorkflowRun.id == self._run_id)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
run = result.scalar_one_or_none()
|
|
||||||
if run is not None:
|
|
||||||
run.current_step_index = self._current_step_index
|
|
||||||
run.current_step_id = self.current_step_id
|
|
||||||
run.loop_count = self._loop_count
|
|
||||||
run.total_actions_count = self._total_actions
|
|
||||||
run.step_actions_count = self._step_actions
|
|
||||||
run.step_history = self._step_history
|
|
||||||
await db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to update workflow run %d progress", self._run_id)
|
|
||||||
|
|
||||||
async def _finalize_run(
|
|
||||||
self,
|
|
||||||
status: str,
|
|
||||||
error_message: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
async with self._db_factory() as db:
|
|
||||||
stmt = select(WorkflowRun).where(WorkflowRun.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.current_step_index = self._current_step_index
|
|
||||||
run.current_step_id = self.current_step_id
|
|
||||||
run.loop_count = self._loop_count
|
|
||||||
run.total_actions_count = self._total_actions
|
|
||||||
run.step_actions_count = self._step_actions
|
|
||||||
run.step_history = self._step_history
|
|
||||||
if error_message:
|
|
||||||
run.error_message = error_message
|
|
||||||
await db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to finalize workflow run %d", self._run_id)
|
|
||||||
|
|
@ -1,29 +1,12 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# ---- Sentry (conditional) ----
|
|
||||||
if settings.sentry_dsn:
|
|
||||||
try:
|
|
||||||
import sentry_sdk
|
|
||||||
|
|
||||||
sentry_sdk.init(
|
|
||||||
dsn=settings.sentry_dsn,
|
|
||||||
environment=settings.environment,
|
|
||||||
traces_sample_rate=0.2,
|
|
||||||
send_default_pii=False,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass # Sentry is optional; don't block startup
|
|
||||||
from app.database import async_session_factory, engine, Base
|
from app.database import async_session_factory, engine, Base
|
||||||
from app.services.artifacts_client import ArtifactsClient
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
from app.services.character_service import CharacterService
|
from app.services.character_service import CharacterService
|
||||||
|
|
@ -33,11 +16,8 @@ from app.services.game_data_cache import GameDataCacheService
|
||||||
from app.models import game_cache as _game_cache_model # noqa: F401
|
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 character_snapshot as _snapshot_model # noqa: F401
|
||||||
from app.models import automation as _automation_model # noqa: F401
|
from app.models import automation as _automation_model # noqa: F401
|
||||||
from app.models import workflow as _workflow_model # noqa: F401
|
|
||||||
from app.models import price_history as _price_history_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
|
from app.models import event_log as _event_log_model # noqa: F401
|
||||||
from app.models import app_error as _app_error_model # noqa: F401
|
|
||||||
from app.models import pipeline as _pipeline_model # noqa: F401
|
|
||||||
|
|
||||||
# Import routers
|
# Import routers
|
||||||
from app.api.characters import router as characters_router
|
from app.api.characters import router as characters_router
|
||||||
|
|
@ -50,17 +30,11 @@ from app.api.exchange import router as exchange_router
|
||||||
from app.api.events import router as events_router
|
from app.api.events import router as events_router
|
||||||
from app.api.logs import router as logs_router
|
from app.api.logs import router as logs_router
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.workflows import router as workflows_router
|
|
||||||
from app.api.errors import router as errors_router
|
|
||||||
from app.api.pipelines import router as pipelines_router
|
|
||||||
|
|
||||||
# Automation engine
|
# Automation engine
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
from app.engine.manager import AutomationManager
|
from app.engine.manager import AutomationManager
|
||||||
|
|
||||||
# Error-handling middleware
|
|
||||||
from app.middleware.error_handler import ErrorHandlerMiddleware
|
|
||||||
|
|
||||||
# Exchange service
|
# Exchange service
|
||||||
from app.services.exchange_service import ExchangeService
|
from app.services.exchange_service import ExchangeService
|
||||||
|
|
||||||
|
|
@ -71,29 +45,10 @@ from app.websocket.handlers import GameEventHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
class _JSONFormatter(logging.Formatter):
|
level=logging.INFO,
|
||||||
"""Structured JSON log formatter for production."""
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
|
||||||
log_entry = {
|
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"level": record.levelname,
|
|
||||||
"logger": record.name,
|
|
||||||
"msg": record.getMessage(),
|
|
||||||
}
|
|
||||||
if record.exc_info and record.exc_info[1]:
|
|
||||||
log_entry["exception"] = self.formatException(record.exc_info)
|
|
||||||
return json.dumps(log_entry, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
_handler = logging.StreamHandler(sys.stdout)
|
|
||||||
if settings.environment != "development":
|
|
||||||
_handler.setFormatter(_JSONFormatter())
|
|
||||||
else:
|
|
||||||
_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, handlers=[_handler])
|
|
||||||
|
|
||||||
|
|
||||||
async def _snapshot_loop(
|
async def _snapshot_loop(
|
||||||
|
|
@ -263,7 +218,6 @@ app = FastAPI(
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(ErrorHandlerMiddleware)
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
|
|
@ -283,9 +237,6 @@ app.include_router(exchange_router)
|
||||||
app.include_router(events_router)
|
app.include_router(events_router)
|
||||||
app.include_router(logs_router)
|
app.include_router(logs_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(workflows_router)
|
|
||||||
app.include_router(errors_router)
|
|
||||||
app.include_router(pipelines_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"""Global error-handling middleware.
|
|
||||||
|
|
||||||
Sets a per-request correlation ID and catches unhandled exceptions,
|
|
||||||
logging them to the database (and Sentry when configured).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse, Response
|
|
||||||
|
|
||||||
from app.database import async_session_factory
|
|
||||||
from app.services.error_service import hash_token, log_error, new_correlation_id
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorHandlerMiddleware(BaseHTTPMiddleware):
|
|
||||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
||||||
cid = new_correlation_id()
|
|
||||||
start = time.monotonic()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
return response
|
|
||||||
except Exception as exc:
|
|
||||||
duration = time.monotonic() - start
|
|
||||||
logger.exception(
|
|
||||||
"Unhandled exception on %s %s (cid=%s, %.3fs)",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
cid,
|
|
||||||
duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to capture in Sentry
|
|
||||||
try:
|
|
||||||
import sentry_sdk
|
|
||||||
|
|
||||||
sentry_sdk.capture_exception(exc)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Persist to DB
|
|
||||||
token = request.headers.get("X-API-Token")
|
|
||||||
await log_error(
|
|
||||||
async_session_factory,
|
|
||||||
severity="error",
|
|
||||||
source="middleware",
|
|
||||||
exc=exc,
|
|
||||||
context={
|
|
||||||
"method": request.method,
|
|
||||||
"path": request.url.path,
|
|
||||||
"query": str(request.url.query),
|
|
||||||
"duration_s": round(duration, 3),
|
|
||||||
},
|
|
||||||
correlation_id=cid,
|
|
||||||
user_token_hash=hash_token(token) if token else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={
|
|
||||||
"detail": "Internal server error",
|
|
||||||
"correlation_id": cid,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,23 +1,15 @@
|
||||||
from app.models.app_error import AppError
|
|
||||||
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
|
||||||
from app.models.character_snapshot import CharacterSnapshot
|
from app.models.character_snapshot import CharacterSnapshot
|
||||||
from app.models.event_log import EventLog
|
from app.models.event_log import EventLog
|
||||||
from app.models.game_cache import GameDataCache
|
from app.models.game_cache import GameDataCache
|
||||||
from app.models.pipeline import PipelineConfig, PipelineRun
|
|
||||||
from app.models.price_history import PriceHistory
|
from app.models.price_history import PriceHistory
|
||||||
from app.models.workflow import WorkflowConfig, WorkflowRun
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AppError",
|
|
||||||
"AutomationConfig",
|
"AutomationConfig",
|
||||||
"AutomationLog",
|
"AutomationLog",
|
||||||
"AutomationRun",
|
"AutomationRun",
|
||||||
"CharacterSnapshot",
|
"CharacterSnapshot",
|
||||||
"EventLog",
|
"EventLog",
|
||||||
"GameDataCache",
|
"GameDataCache",
|
||||||
"PipelineConfig",
|
|
||||||
"PipelineRun",
|
|
||||||
"PriceHistory",
|
"PriceHistory",
|
||||||
"WorkflowConfig",
|
|
||||||
"WorkflowRun",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
"""Application error model for tracking errors across the system."""
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import JSON
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class AppError(Base):
|
|
||||||
__tablename__ = "app_errors"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
severity = Column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
default="error",
|
|
||||||
comment="error | warning | critical",
|
|
||||||
)
|
|
||||||
source = Column(
|
|
||||||
String(50),
|
|
||||||
nullable=False,
|
|
||||||
comment="backend | frontend | automation | middleware",
|
|
||||||
)
|
|
||||||
error_type = Column(
|
|
||||||
String(200),
|
|
||||||
nullable=False,
|
|
||||||
comment="Exception class name or error category",
|
|
||||||
)
|
|
||||||
message = Column(Text, nullable=False)
|
|
||||||
stack_trace = Column(Text, nullable=True)
|
|
||||||
context = Column(JSON, nullable=True, comment="Arbitrary JSON context")
|
|
||||||
user_token_hash = Column(
|
|
||||||
String(64),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
comment="SHA-256 hash of the user API token (for scoping errors per user)",
|
|
||||||
)
|
|
||||||
correlation_id = Column(
|
|
||||||
String(36),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
comment="Request correlation ID (UUID)",
|
|
||||||
)
|
|
||||||
resolved = Column(Boolean, nullable=False, default=False, server_default="false")
|
|
||||||
created_at = Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
server_default=func.now(),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineConfig(Base):
|
|
||||||
__tablename__ = "pipeline_configs"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
||||||
stages: Mapped[list] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=False,
|
|
||||||
default=list,
|
|
||||||
comment="JSON array of pipeline stages, each with id, name, character_steps[]",
|
|
||||||
)
|
|
||||||
loop: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
||||||
max_loops: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, 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["PipelineRun"]] = relationship(
|
|
||||||
back_populates="pipeline",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
order_by="PipelineRun.started_at.desc()",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<PipelineConfig(id={self.id}, name={self.name!r}, "
|
|
||||||
f"stages={len(self.stages)})>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineRun(Base):
|
|
||||||
__tablename__ = "pipeline_runs"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
pipeline_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("pipeline_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",
|
|
||||||
)
|
|
||||||
current_stage_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
current_stage_id: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
|
||||||
loop_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
total_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
character_states: Mapped[dict] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=False,
|
|
||||||
default=dict,
|
|
||||||
comment="Per-character state: {char_name: {status, step_id, actions_count, error}}",
|
|
||||||
)
|
|
||||||
stage_history: Mapped[list] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=False,
|
|
||||||
default=list,
|
|
||||||
comment="JSON array of completed stage records",
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
pipeline: Mapped["PipelineConfig"] = relationship(back_populates="runs")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<PipelineRun(id={self.id}, pipeline_id={self.pipeline_id}, "
|
|
||||||
f"status={self.status!r}, stage={self.current_stage_index})>"
|
|
||||||
)
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowConfig(Base):
|
|
||||||
__tablename__ = "workflow_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)
|
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
||||||
steps: Mapped[list] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=False,
|
|
||||||
default=list,
|
|
||||||
comment="JSON array of workflow steps, each with id, name, strategy_type, config, transition",
|
|
||||||
)
|
|
||||||
loop: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
||||||
max_loops: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, 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["WorkflowRun"]] = relationship(
|
|
||||||
back_populates="workflow",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
order_by="WorkflowRun.started_at.desc()",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<WorkflowConfig(id={self.id}, name={self.name!r}, "
|
|
||||||
f"character={self.character_name!r}, steps={len(self.steps)})>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowRun(Base):
|
|
||||||
__tablename__ = "workflow_runs"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
workflow_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("workflow_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",
|
|
||||||
)
|
|
||||||
current_step_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
current_step_id: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
|
||||||
loop_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
total_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
step_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
step_history: Mapped[list] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=False,
|
|
||||||
default=list,
|
|
||||||
comment="JSON array of completed step records",
|
|
||||||
)
|
|
||||||
|
|
||||||
workflow: Mapped["WorkflowConfig"] = relationship(back_populates="runs")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<WorkflowRun(id={self.id}, workflow_id={self.workflow_id}, "
|
|
||||||
f"status={self.status!r}, step={self.current_step_index})>"
|
|
||||||
)
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""Pydantic schemas for the errors API."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class AppErrorResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
severity: str
|
|
||||||
source: str
|
|
||||||
error_type: str
|
|
||||||
message: str
|
|
||||||
stack_trace: str | None = None
|
|
||||||
context: dict[str, Any] | None = None
|
|
||||||
correlation_id: str | None = None
|
|
||||||
resolved: bool
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class AppErrorListResponse(BaseModel):
|
|
||||||
errors: list[AppErrorResponse]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
pages: int
|
|
||||||
|
|
||||||
|
|
||||||
class AppErrorStats(BaseModel):
|
|
||||||
total: int = 0
|
|
||||||
unresolved: int = 0
|
|
||||||
last_hour: int = 0
|
|
||||||
by_severity: dict[str, int] = Field(default_factory=dict)
|
|
||||||
by_source: dict[str, int] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class FrontendErrorReport(BaseModel):
|
|
||||||
error_type: str = "FrontendError"
|
|
||||||
message: str
|
|
||||||
stack_trace: str | None = None
|
|
||||||
context: dict[str, Any] | None = None
|
|
||||||
severity: str = "error"
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from app.schemas.workflow import TransitionConditionSchema
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Character step within a stage
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class CharacterStepSchema(BaseModel):
|
|
||||||
"""A single character's work within a pipeline stage."""
|
|
||||||
|
|
||||||
id: str = Field(..., description="Unique step identifier (e.g. 'cs_1a')")
|
|
||||||
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, description="Strategy-specific configuration")
|
|
||||||
transition: TransitionConditionSchema | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Condition for this character-step to be considered done",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pipeline stage
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineStageSchema(BaseModel):
|
|
||||||
"""A stage in the pipeline — character steps within it run in parallel."""
|
|
||||||
|
|
||||||
id: str = Field(..., description="Unique stage identifier (e.g. 'stage_1')")
|
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
|
||||||
character_steps: list[CharacterStepSchema] = Field(..., min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Request schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineConfigCreate(BaseModel):
|
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
|
||||||
description: str = Field(default="")
|
|
||||||
stages: list[PipelineStageSchema] = Field(..., min_length=1)
|
|
||||||
loop: bool = Field(default=False)
|
|
||||||
max_loops: int = Field(default=0, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineConfigUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
|
||||||
description: str | None = None
|
|
||||||
stages: list[PipelineStageSchema] | None = Field(default=None, min_length=1)
|
|
||||||
loop: bool | None = None
|
|
||||||
max_loops: int | None = Field(default=None, ge=0)
|
|
||||||
enabled: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Response schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineConfigResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
stages: list[dict]
|
|
||||||
loop: bool
|
|
||||||
max_loops: int
|
|
||||||
enabled: bool
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineRunResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
pipeline_id: int
|
|
||||||
status: str
|
|
||||||
current_stage_index: int
|
|
||||||
current_stage_id: str
|
|
||||||
loop_count: int
|
|
||||||
total_actions_count: int
|
|
||||||
character_states: dict
|
|
||||||
stage_history: list[dict] = Field(default_factory=list)
|
|
||||||
started_at: datetime
|
|
||||||
stopped_at: datetime | None = None
|
|
||||||
error_message: str | None = None
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class CharacterStateResponse(BaseModel):
|
|
||||||
"""Status of a single character within an active pipeline."""
|
|
||||||
|
|
||||||
character_name: str
|
|
||||||
status: str # running, completed, error, idle
|
|
||||||
step_id: str = ""
|
|
||||||
actions_count: int = 0
|
|
||||||
strategy_state: str = ""
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineStatusResponse(BaseModel):
|
|
||||||
pipeline_id: int
|
|
||||||
status: str
|
|
||||||
run_id: int | None = None
|
|
||||||
current_stage_index: int = 0
|
|
||||||
current_stage_id: str = ""
|
|
||||||
total_stages: int = 0
|
|
||||||
loop_count: int = 0
|
|
||||||
total_actions_count: int = 0
|
|
||||||
character_states: list[CharacterStateResponse] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineConfigDetailResponse(BaseModel):
|
|
||||||
"""Pipeline config with its run history."""
|
|
||||||
|
|
||||||
config: PipelineConfigResponse
|
|
||||||
runs: list[PipelineRunResponse] = Field(default_factory=list)
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Transition conditions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionConditionSchema(BaseModel):
|
|
||||||
"""Defines when a workflow step should transition to the next step."""
|
|
||||||
|
|
||||||
type: str = Field(
|
|
||||||
...,
|
|
||||||
description=(
|
|
||||||
"Condition type: strategy_complete, loops_completed, inventory_full, "
|
|
||||||
"inventory_item_count, bank_item_count, skill_level, gold_amount, "
|
|
||||||
"actions_count, timer"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
operator: str = Field(
|
|
||||||
default=">=",
|
|
||||||
description="Comparison operator: >=, <=, ==, >, <",
|
|
||||||
)
|
|
||||||
value: int = Field(
|
|
||||||
default=0,
|
|
||||||
description="Target value for the condition",
|
|
||||||
)
|
|
||||||
item_code: str = Field(
|
|
||||||
default="",
|
|
||||||
description="Item code (for inventory_item_count, bank_item_count)",
|
|
||||||
)
|
|
||||||
skill: str = Field(
|
|
||||||
default="",
|
|
||||||
description="Skill name (for skill_level condition)",
|
|
||||||
)
|
|
||||||
seconds: int = Field(
|
|
||||||
default=0,
|
|
||||||
ge=0,
|
|
||||||
description="Duration in seconds (for timer condition)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Workflow steps
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowStepSchema(BaseModel):
|
|
||||||
"""A single step within a workflow pipeline."""
|
|
||||||
|
|
||||||
id: str = Field(..., description="Unique step identifier (e.g. 'step_1')")
|
|
||||||
name: str = Field(..., min_length=1, max_length=100, description="Human-readable step name")
|
|
||||||
strategy_type: str = Field(
|
|
||||||
...,
|
|
||||||
description="Strategy type: combat, gathering, crafting, trading, task, leveling",
|
|
||||||
)
|
|
||||||
config: dict = Field(default_factory=dict, description="Strategy-specific configuration")
|
|
||||||
transition: TransitionConditionSchema | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Condition to advance to the next step (None = run until strategy completes)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Request schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowConfigCreate(BaseModel):
|
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
|
||||||
character_name: str = Field(..., min_length=1, max_length=100)
|
|
||||||
description: str = Field(default="")
|
|
||||||
steps: list[WorkflowStepSchema] = Field(..., min_length=1)
|
|
||||||
loop: bool = Field(default=False)
|
|
||||||
max_loops: int = Field(default=0, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowConfigUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
|
||||||
description: str | None = None
|
|
||||||
steps: list[WorkflowStepSchema] | None = Field(default=None, min_length=1)
|
|
||||||
loop: bool | None = None
|
|
||||||
max_loops: int | None = Field(default=None, ge=0)
|
|
||||||
enabled: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Response schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowConfigResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
character_name: str
|
|
||||||
description: str
|
|
||||||
steps: list[dict]
|
|
||||||
loop: bool
|
|
||||||
max_loops: int
|
|
||||||
enabled: bool
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowRunResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
workflow_id: int
|
|
||||||
status: str
|
|
||||||
current_step_index: int
|
|
||||||
current_step_id: str
|
|
||||||
loop_count: int
|
|
||||||
total_actions_count: int
|
|
||||||
step_actions_count: int
|
|
||||||
started_at: datetime
|
|
||||||
stopped_at: datetime | None = None
|
|
||||||
error_message: str | None = None
|
|
||||||
step_history: list[dict] = Field(default_factory=list)
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowStatusResponse(BaseModel):
|
|
||||||
workflow_id: int
|
|
||||||
character_name: str
|
|
||||||
status: str
|
|
||||||
run_id: int | None = None
|
|
||||||
current_step_index: int = 0
|
|
||||||
current_step_id: str = ""
|
|
||||||
total_steps: int = 0
|
|
||||||
loop_count: int = 0
|
|
||||||
total_actions_count: int = 0
|
|
||||||
step_actions_count: int = 0
|
|
||||||
strategy_state: str = ""
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowConfigDetailResponse(BaseModel):
|
|
||||||
"""Workflow config with its run history."""
|
|
||||||
|
|
||||||
config: WorkflowConfigResponse
|
|
||||||
runs: list[WorkflowRunResponse] = Field(default_factory=list)
|
|
||||||
|
|
@ -53,9 +53,6 @@ class ArtifactsClient:
|
||||||
"""Async HTTP client for the Artifacts MMO API.
|
"""Async HTTP client for the Artifacts MMO API.
|
||||||
|
|
||||||
Handles authentication, rate limiting, pagination, and retry logic.
|
Handles authentication, rate limiting, pagination, and retry logic.
|
||||||
Supports per-request token overrides for multi-user scenarios via
|
|
||||||
the ``with_token()`` method which creates a lightweight clone that
|
|
||||||
shares the underlying connection pool.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MAX_RETRIES: int = 3
|
MAX_RETRIES: int = 3
|
||||||
|
|
@ -66,6 +63,7 @@ class ArtifactsClient:
|
||||||
self._client = httpx.AsyncClient(
|
self._client = httpx.AsyncClient(
|
||||||
base_url=settings.artifacts_api_url,
|
base_url=settings.artifacts_api_url,
|
||||||
headers={
|
headers={
|
||||||
|
"Authorization": f"Bearer {self._token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
},
|
},
|
||||||
|
|
@ -80,21 +78,6 @@ class ArtifactsClient:
|
||||||
window_seconds=settings.data_rate_window,
|
window_seconds=settings.data_rate_window,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Multi-user support ------------------------------------------------
|
|
||||||
|
|
||||||
def with_token(self, token: str) -> "ArtifactsClient":
|
|
||||||
"""Return a lightweight clone that uses *token* for requests.
|
|
||||||
|
|
||||||
The clone shares the httpx connection pool and rate limiters with the
|
|
||||||
original instance so there is no overhead in creating one per request.
|
|
||||||
"""
|
|
||||||
clone = object.__new__(ArtifactsClient)
|
|
||||||
clone._token = token
|
|
||||||
clone._client = self._client # shared connection pool
|
|
||||||
clone._action_limiter = self._action_limiter
|
|
||||||
clone._data_limiter = self._data_limiter
|
|
||||||
return clone
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_token(self) -> bool:
|
def has_token(self) -> bool:
|
||||||
return bool(self._token)
|
return bool(self._token)
|
||||||
|
|
@ -108,12 +91,14 @@ class ArtifactsClient:
|
||||||
return "user"
|
return "user"
|
||||||
|
|
||||||
def set_token(self, token: str) -> None:
|
def set_token(self, token: str) -> None:
|
||||||
"""Update the default API token at runtime (used by background tasks)."""
|
"""Update the API token at runtime."""
|
||||||
self._token = token
|
self._token = token
|
||||||
|
self._client.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
def clear_token(self) -> None:
|
def clear_token(self) -> None:
|
||||||
"""Revert to the env token (or empty if none)."""
|
"""Revert to the env token (or empty if none)."""
|
||||||
self._token = settings.artifacts_token
|
self._token = settings.artifacts_token
|
||||||
|
self._client.headers["Authorization"] = f"Bearer {self._token}"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Low-level request helpers
|
# Low-level request helpers
|
||||||
|
|
@ -130,10 +115,6 @@ class ArtifactsClient:
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
|
|
||||||
# Send Authorization per-request so clones created by with_token()
|
|
||||||
# use their own token without affecting other concurrent requests.
|
|
||||||
auth_headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
|
|
||||||
|
|
||||||
for attempt in range(1, self.MAX_RETRIES + 1):
|
for attempt in range(1, self.MAX_RETRIES + 1):
|
||||||
await limiter.acquire()
|
await limiter.acquire()
|
||||||
try:
|
try:
|
||||||
|
|
@ -142,7 +123,6 @@ class ArtifactsClient:
|
||||||
path,
|
path,
|
||||||
json=json_body,
|
json=json_body,
|
||||||
params=params,
|
params=params,
|
||||||
headers=auth_headers,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
|
|
@ -156,27 +136,6 @@ class ArtifactsClient:
|
||||||
await asyncio.sleep(retry_after)
|
await asyncio.sleep(retry_after)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 498 = character in cooldown – wait and retry
|
|
||||||
if response.status_code == 498:
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
cooldown = body.get("data", {}).get("cooldown", {})
|
|
||||||
wait_seconds = cooldown.get("total_seconds", 5)
|
|
||||||
except Exception:
|
|
||||||
wait_seconds = 5
|
|
||||||
logger.info(
|
|
||||||
"Character cooldown on %s %s, waiting %.1fs (attempt %d/%d)",
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
wait_seconds,
|
|
||||||
attempt,
|
|
||||||
self.MAX_RETRIES,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(wait_seconds)
|
|
||||||
if attempt < self.MAX_RETRIES:
|
|
||||||
continue
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
if response.status_code >= 500:
|
if response.status_code >= 500:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Server error %d on %s %s (attempt %d/%d)",
|
"Server error %d on %s %s (attempt %d/%d)",
|
||||||
|
|
@ -244,16 +203,13 @@ class ArtifactsClient:
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
page_size: int = 100,
|
page_size: int = 100,
|
||||||
params: dict[str, Any] | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Fetch all pages from a paginated endpoint."""
|
"""Fetch all pages from a paginated endpoint."""
|
||||||
all_items: list[dict[str, Any]] = []
|
all_items: list[dict[str, Any]] = []
|
||||||
page = 1
|
page = 1
|
||||||
base_params = dict(params) if params else {}
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
req_params = {**base_params, "page": page, "size": page_size}
|
result = await self._get(path, params={"page": page, "size": page_size})
|
||||||
result = await self._get(path, params=req_params)
|
|
||||||
data = result.get("data", [])
|
data = result.get("data", [])
|
||||||
all_items.extend(data)
|
all_items.extend(data)
|
||||||
|
|
||||||
|
|
@ -355,30 +311,13 @@ class ArtifactsClient:
|
||||||
result = await self._get("/my/bank")
|
result = await self._get("/my/bank")
|
||||||
return result.get("data", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
async def browse_ge_orders(
|
|
||||||
self,
|
|
||||||
code: str | None = None,
|
|
||||||
order_type: str | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Browse ALL active Grand Exchange orders (public endpoint)."""
|
|
||||||
params: dict[str, Any] = {}
|
|
||||||
if code:
|
|
||||||
params["code"] = code
|
|
||||||
if order_type:
|
|
||||||
params["type"] = order_type
|
|
||||||
return await self._get_paginated("/grandexchange/orders", params=params)
|
|
||||||
|
|
||||||
async def get_ge_orders(self) -> list[dict[str, Any]]:
|
async def get_ge_orders(self) -> list[dict[str, Any]]:
|
||||||
"""Get the authenticated account's own active GE orders."""
|
result = await self._get("/my/grandexchange/orders")
|
||||||
return await self._get_paginated("/my/grandexchange/orders")
|
return result.get("data", [])
|
||||||
|
|
||||||
async def get_ge_history(self) -> list[dict[str, Any]]:
|
async def get_ge_history(self) -> list[dict[str, Any]]:
|
||||||
"""Get the authenticated account's GE transaction history."""
|
result = await self._get("/my/grandexchange/history")
|
||||||
return await self._get_paginated("/my/grandexchange/history")
|
return result.get("data", [])
|
||||||
|
|
||||||
async def get_ge_sell_history(self, item_code: str) -> list[dict[str, Any]]:
|
|
||||||
"""Get public sale history for a specific item (last 7 days)."""
|
|
||||||
return await self._get_paginated(f"/grandexchange/history/{item_code}")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Action endpoints
|
# Action endpoints
|
||||||
|
|
@ -469,11 +408,11 @@ class ArtifactsClient:
|
||||||
return result.get("data", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
async def ge_buy(
|
async def ge_buy(
|
||||||
self, name: str, order_id: str, quantity: int
|
self, name: str, code: str, quantity: int, price: int
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
result = await self._post_action(
|
result = await self._post_action(
|
||||||
f"/my/{name}/action/grandexchange/buy",
|
f"/my/{name}/action/grandexchange/buy",
|
||||||
json_body={"id": order_id, "quantity": quantity},
|
json_body={"code": code, "quantity": quantity, "price": price},
|
||||||
)
|
)
|
||||||
return result.get("data", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
|
|
@ -481,29 +420,20 @@ class ArtifactsClient:
|
||||||
self, name: str, code: str, quantity: int, price: int
|
self, name: str, code: str, quantity: int, price: int
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
result = await self._post_action(
|
result = await self._post_action(
|
||||||
f"/my/{name}/action/grandexchange/create-sell-order",
|
f"/my/{name}/action/grandexchange/sell",
|
||||||
json_body={"code": code, "quantity": quantity, "price": price},
|
json_body={"code": code, "quantity": quantity, "price": price},
|
||||||
)
|
)
|
||||||
return result.get("data", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
async def ge_create_buy_order(
|
async def ge_buy_order(
|
||||||
self, name: str, code: str, quantity: int, price: int
|
self, name: str, code: str, quantity: int, price: int
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
result = await self._post_action(
|
result = await self._post_action(
|
||||||
f"/my/{name}/action/grandexchange/create-buy-order",
|
f"/my/{name}/action/grandexchange/buy",
|
||||||
json_body={"code": code, "quantity": quantity, "price": price},
|
json_body={"code": code, "quantity": quantity, "price": price},
|
||||||
)
|
)
|
||||||
return result.get("data", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
async def ge_fill_buy_order(
|
|
||||||
self, name: str, order_id: str, quantity: int
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
result = await self._post_action(
|
|
||||||
f"/my/{name}/action/grandexchange/fill",
|
|
||||||
json_body={"id": order_id, "quantity": quantity},
|
|
||||||
)
|
|
||||||
return result.get("data", {})
|
|
||||||
|
|
||||||
async def ge_cancel(self, name: str, order_id: str) -> dict[str, Any]:
|
async def ge_cancel(self, name: str, order_id: str) -> dict[str, Any]:
|
||||||
result = await self._post_action(
|
result = await self._post_action(
|
||||||
f"/my/{name}/action/grandexchange/cancel",
|
f"/my/{name}/action/grandexchange/cancel",
|
||||||
|
|
@ -550,27 +480,6 @@ class ArtifactsClient:
|
||||||
)
|
)
|
||||||
return result.get("data", {})
|
return result.get("data", {})
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Data endpoints - Action Logs
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def get_logs(
|
|
||||||
self,
|
|
||||||
page: int = 1,
|
|
||||||
size: int = 100,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get recent action logs for all characters (last 5000 actions)."""
|
|
||||||
return await self._get("/my/logs", params={"page": page, "size": size})
|
|
||||||
|
|
||||||
async def get_character_logs(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
page: int = 1,
|
|
||||||
size: int = 100,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Get recent action logs for a specific character."""
|
|
||||||
return await self._get(f"/my/logs/{name}", params={"page": page, "size": size})
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
"""Error logging service - writes errors to the database and optionally to Sentry."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import traceback
|
|
||||||
import uuid
|
|
||||||
from contextvars import ContextVar
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
||||||
|
|
||||||
from app.models.app_error import AppError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Per-request correlation ID
|
|
||||||
correlation_id_var: ContextVar[str | None] = ContextVar("correlation_id", default=None)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_token(token: str) -> str:
|
|
||||||
"""Return a stable SHA-256 hex digest for a user API token."""
|
|
||||||
return hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def new_correlation_id() -> str:
|
|
||||||
cid = uuid.uuid4().hex[:12]
|
|
||||||
correlation_id_var.set(cid)
|
|
||||||
return cid
|
|
||||||
|
|
||||||
|
|
||||||
async def log_error(
|
|
||||||
db_factory: async_sessionmaker[AsyncSession],
|
|
||||||
*,
|
|
||||||
severity: str = "error",
|
|
||||||
source: str = "backend",
|
|
||||||
error_type: str = "UnknownError",
|
|
||||||
message: str = "",
|
|
||||||
exc: BaseException | None = None,
|
|
||||||
context: dict[str, Any] | None = None,
|
|
||||||
correlation_id: str | None = None,
|
|
||||||
user_token_hash: str | None = None,
|
|
||||||
) -> AppError | None:
|
|
||||||
"""Persist an error record to the database.
|
|
||||||
|
|
||||||
Returns the created AppError, or None if the DB write itself fails.
|
|
||||||
"""
|
|
||||||
stack_trace = None
|
|
||||||
if exc is not None:
|
|
||||||
stack_trace = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
||||||
if not error_type or error_type == "UnknownError":
|
|
||||||
error_type = type(exc).__qualname__
|
|
||||||
if not message:
|
|
||||||
message = str(exc)
|
|
||||||
|
|
||||||
cid = correlation_id or correlation_id_var.get()
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with db_factory() as db:
|
|
||||||
record = AppError(
|
|
||||||
severity=severity,
|
|
||||||
source=source,
|
|
||||||
error_type=error_type,
|
|
||||||
message=message[:4000],
|
|
||||||
stack_trace=stack_trace[:10000] if stack_trace else None,
|
|
||||||
context=context,
|
|
||||||
correlation_id=cid,
|
|
||||||
user_token_hash=user_token_hash,
|
|
||||||
)
|
|
||||||
db.add(record)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(record)
|
|
||||||
return record
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to persist error record to database")
|
|
||||||
return None
|
|
||||||
|
|
@ -27,22 +27,8 @@ class ExchangeService:
|
||||||
# Order and history queries (pass-through to API with enrichment)
|
# Order and history queries (pass-through to API with enrichment)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def browse_orders(
|
async def get_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]:
|
||||||
self,
|
"""Get all active GE orders for the account.
|
||||||
client: ArtifactsClient,
|
|
||||||
code: str | None = None,
|
|
||||||
order_type: str | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Browse all active GE orders on the market (public).
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
List of order dicts from the Artifacts API.
|
|
||||||
"""
|
|
||||||
return await client.browse_ge_orders(code=code, order_type=order_type)
|
|
||||||
|
|
||||||
async def get_my_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]:
|
|
||||||
"""Get the authenticated account's own active GE orders.
|
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -59,17 +45,6 @@ class ExchangeService:
|
||||||
"""
|
"""
|
||||||
return await client.get_ge_history()
|
return await client.get_ge_history()
|
||||||
|
|
||||||
async def get_sell_history(
|
|
||||||
self, client: ArtifactsClient, item_code: str
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Get public sale history for a specific item.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
List of sale history dicts from the Artifacts API.
|
|
||||||
"""
|
|
||||||
return await client.get_ge_sell_history(item_code)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Price capture
|
# Price capture
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -88,7 +63,7 @@ class ExchangeService:
|
||||||
Number of price entries captured.
|
Number of price entries captured.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
orders = await client.browse_ge_orders()
|
orders = await client.get_ge_orders()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch GE orders for price capture")
|
logger.exception("Failed to fetch GE orders for price capture")
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -113,7 +88,7 @@ class ExchangeService:
|
||||||
|
|
||||||
price = order.get("price", 0)
|
price = order.get("price", 0)
|
||||||
quantity = order.get("quantity", 0)
|
quantity = order.get("quantity", 0)
|
||||||
order_type = order.get("type", "") # "buy" or "sell"
|
order_type = order.get("order", "") # "buy" or "sell"
|
||||||
|
|
||||||
item_prices[code]["volume"] += quantity
|
item_prices[code]["volume"] += quantity
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ dependencies = [
|
||||||
"pydantic>=2.10.0",
|
"pydantic>=2.10.0",
|
||||||
"pydantic-settings>=2.7.0",
|
"pydantic-settings>=2.7.0",
|
||||||
"websockets>=14.0",
|
"websockets>=14.0",
|
||||||
"sentry-sdk[fastapi]>=2.0.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,7 @@ from app.engine.pathfinder import Pathfinder
|
||||||
from app.schemas.game import (
|
from app.schemas.game import (
|
||||||
CharacterSchema,
|
CharacterSchema,
|
||||||
ContentSchema,
|
ContentSchema,
|
||||||
CraftItem,
|
|
||||||
CraftSchema,
|
|
||||||
EffectSchema,
|
|
||||||
InventorySlot,
|
InventorySlot,
|
||||||
ItemSchema,
|
|
||||||
MapSchema,
|
MapSchema,
|
||||||
MonsterSchema,
|
MonsterSchema,
|
||||||
ResourceSchema,
|
ResourceSchema,
|
||||||
|
|
@ -122,37 +118,3 @@ def pathfinder_with_maps(make_map_tile):
|
||||||
return pf
|
return pf
|
||||||
|
|
||||||
return _factory
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def make_item():
|
|
||||||
"""Factory fixture that returns an ItemSchema with sensible defaults."""
|
|
||||||
|
|
||||||
def _factory(**overrides) -> ItemSchema:
|
|
||||||
defaults = {
|
|
||||||
"name": "Iron Sword",
|
|
||||||
"code": "iron_sword",
|
|
||||||
"level": 10,
|
|
||||||
"type": "weapon",
|
|
||||||
"subtype": "sword",
|
|
||||||
"effects": [],
|
|
||||||
"craft": None,
|
|
||||||
}
|
|
||||||
defaults.update(overrides)
|
|
||||||
|
|
||||||
# Convert raw effect dicts to EffectSchema instances if needed
|
|
||||||
raw_effects = defaults.get("effects", [])
|
|
||||||
if raw_effects and isinstance(raw_effects[0], dict):
|
|
||||||
defaults["effects"] = [EffectSchema(**e) for e in raw_effects]
|
|
||||||
|
|
||||||
# Convert raw craft dict to CraftSchema if needed
|
|
||||||
raw_craft = defaults.get("craft")
|
|
||||||
if raw_craft and isinstance(raw_craft, dict):
|
|
||||||
if "items" in raw_craft and raw_craft["items"]:
|
|
||||||
if isinstance(raw_craft["items"][0], dict):
|
|
||||||
raw_craft["items"] = [CraftItem(**ci) for ci in raw_craft["items"]]
|
|
||||||
defaults["craft"] = CraftSchema(**raw_craft)
|
|
||||||
|
|
||||||
return ItemSchema(**defaults)
|
|
||||||
|
|
||||||
return _factory
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"""Tests for BaseStrategy static helpers."""
|
|
||||||
|
|
||||||
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
|
|
||||||
from app.schemas.game import InventorySlot
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryHelpers:
|
|
||||||
"""Tests for inventory-related static methods."""
|
|
||||||
|
|
||||||
def test_inventory_used_slots_empty(self, make_character):
|
|
||||||
char = make_character(inventory=[])
|
|
||||||
assert BaseStrategy._inventory_used_slots(char) == 0
|
|
||||||
|
|
||||||
def test_inventory_used_slots_with_items(self, make_character):
|
|
||||||
items = [InventorySlot(slot=i, code=f"item_{i}", quantity=1) for i in range(5)]
|
|
||||||
char = make_character(inventory=items)
|
|
||||||
assert BaseStrategy._inventory_used_slots(char) == 5
|
|
||||||
|
|
||||||
def test_inventory_free_slots_all_free(self, make_character):
|
|
||||||
char = make_character(inventory_max_items=20, inventory=[])
|
|
||||||
assert BaseStrategy._inventory_free_slots(char) == 20
|
|
||||||
|
|
||||||
def test_inventory_free_slots_partially_used(self, make_character):
|
|
||||||
items = [InventorySlot(slot=i, code=f"item_{i}", quantity=1) for i in range(8)]
|
|
||||||
char = make_character(inventory_max_items=20, inventory=items)
|
|
||||||
assert BaseStrategy._inventory_free_slots(char) == 12
|
|
||||||
|
|
||||||
def test_inventory_free_slots_full(self, make_character):
|
|
||||||
items = [InventorySlot(slot=i, code=f"item_{i}", quantity=1) for i in range(20)]
|
|
||||||
char = make_character(inventory_max_items=20, inventory=items)
|
|
||||||
assert BaseStrategy._inventory_free_slots(char) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestHpPercent:
|
|
||||||
"""Tests for HP percentage calculation."""
|
|
||||||
|
|
||||||
def test_full_health(self, make_character):
|
|
||||||
char = make_character(hp=100, max_hp=100)
|
|
||||||
assert BaseStrategy._hp_percent(char) == 100.0
|
|
||||||
|
|
||||||
def test_half_health(self, make_character):
|
|
||||||
char = make_character(hp=50, max_hp=100)
|
|
||||||
assert BaseStrategy._hp_percent(char) == 50.0
|
|
||||||
|
|
||||||
def test_zero_health(self, make_character):
|
|
||||||
char = make_character(hp=0, max_hp=100)
|
|
||||||
assert BaseStrategy._hp_percent(char) == 0.0
|
|
||||||
|
|
||||||
def test_zero_max_hp_returns_100(self, make_character):
|
|
||||||
char = make_character(hp=0, max_hp=0)
|
|
||||||
assert BaseStrategy._hp_percent(char) == 100.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsAt:
|
|
||||||
"""Tests for position checking."""
|
|
||||||
|
|
||||||
def test_at_position(self, make_character):
|
|
||||||
char = make_character(x=5, y=10)
|
|
||||||
assert BaseStrategy._is_at(char, 5, 10) is True
|
|
||||||
|
|
||||||
def test_not_at_position(self, make_character):
|
|
||||||
char = make_character(x=5, y=10)
|
|
||||||
assert BaseStrategy._is_at(char, 0, 0) is False
|
|
||||||
|
|
||||||
def test_wrong_x_only(self, make_character):
|
|
||||||
char = make_character(x=5, y=10)
|
|
||||||
assert BaseStrategy._is_at(char, 6, 10) is False
|
|
||||||
|
|
||||||
def test_wrong_y_only(self, make_character):
|
|
||||||
char = make_character(x=5, y=10)
|
|
||||||
assert BaseStrategy._is_at(char, 5, 11) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestActionPlan:
|
|
||||||
"""Tests for ActionPlan dataclass."""
|
|
||||||
|
|
||||||
def test_create_with_defaults(self):
|
|
||||||
plan = ActionPlan(ActionType.MOVE)
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {}
|
|
||||||
assert plan.reason == ""
|
|
||||||
|
|
||||||
def test_create_with_params(self):
|
|
||||||
plan = ActionPlan(
|
|
||||||
ActionType.MOVE,
|
|
||||||
params={"x": 5, "y": 10},
|
|
||||||
reason="Moving to target",
|
|
||||||
)
|
|
||||||
assert plan.params == {"x": 5, "y": 10}
|
|
||||||
assert plan.reason == "Moving to target"
|
|
||||||
|
|
||||||
|
|
||||||
class TestActionType:
|
|
||||||
"""Tests for ActionType enum values."""
|
|
||||||
|
|
||||||
def test_all_action_types_exist(self):
|
|
||||||
expected = {
|
|
||||||
"move", "fight", "gather", "rest", "equip", "unequip",
|
|
||||||
"use_item", "deposit_item", "withdraw_item", "craft", "recycle",
|
|
||||||
"ge_buy", "ge_create_buy", "ge_sell", "ge_fill", "ge_cancel",
|
|
||||||
"task_new", "task_trade", "task_complete", "task_exchange", "task_cancel",
|
|
||||||
"deposit_gold", "withdraw_gold", "npc_buy", "npc_sell",
|
|
||||||
"idle", "complete",
|
|
||||||
}
|
|
||||||
actual = {at.value for at in ActionType}
|
|
||||||
assert actual == expected
|
|
||||||
|
|
||||||
def test_action_type_is_string(self):
|
|
||||||
assert isinstance(ActionType.MOVE.value, str)
|
|
||||||
assert ActionType.MOVE == "move"
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
"""Tests for the CraftingStrategy state machine."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.engine.strategies.base import ActionType
|
|
||||||
from app.engine.strategies.crafting import CraftingStrategy
|
|
||||||
from app.schemas.game import CraftItem, CraftSchema, EffectSchema, InventorySlot, ItemSchema
|
|
||||||
|
|
||||||
|
|
||||||
def _make_craftable_item(
|
|
||||||
code: str = "iron_sword",
|
|
||||||
skill: str = "weaponcrafting",
|
|
||||||
level: int = 5,
|
|
||||||
materials: list[tuple[str, int]] | None = None,
|
|
||||||
) -> ItemSchema:
|
|
||||||
"""Helper to build an ItemSchema with a crafting recipe."""
|
|
||||||
if materials is None:
|
|
||||||
materials = [("iron_ore", 5), ("wood", 2)]
|
|
||||||
return ItemSchema(
|
|
||||||
name=code.replace("_", " ").title(),
|
|
||||||
code=code,
|
|
||||||
level=level,
|
|
||||||
type="weapon",
|
|
||||||
craft=CraftSchema(
|
|
||||||
skill=skill,
|
|
||||||
level=level,
|
|
||||||
items=[CraftItem(code=c, quantity=q) for c, q in materials],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCraftingStrategyInitialization:
|
|
||||||
"""Tests for CraftingStrategy creation and recipe resolution."""
|
|
||||||
|
|
||||||
def test_initial_state(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
|
|
||||||
assert strategy.get_state() == "check_materials"
|
|
||||||
|
|
||||||
def test_recipe_resolved_from_items_data(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
||||||
item = _make_craftable_item()
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
|
|
||||||
assert strategy._recipe_resolved is True
|
|
||||||
assert len(strategy._recipe) == 2
|
|
||||||
assert strategy._craft_skill == "weaponcrafting"
|
|
||||||
|
|
||||||
def test_recipe_not_resolved_without_data(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
|
|
||||||
|
|
||||||
assert strategy._recipe_resolved is False
|
|
||||||
|
|
||||||
def test_set_items_data_resolves_recipe(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
|
|
||||||
item = _make_craftable_item()
|
|
||||||
strategy.set_items_data([item])
|
|
||||||
|
|
||||||
assert strategy._recipe_resolved is True
|
|
||||||
|
|
||||||
def test_set_items_data_no_double_resolve(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
||||||
item = _make_craftable_item()
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
# Calling again should not re-resolve
|
|
||||||
strategy._craft_skill = "overwritten"
|
|
||||||
strategy.set_items_data([item])
|
|
||||||
assert strategy._craft_skill == "overwritten"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCraftingStrategyIdleWithoutRecipe:
|
|
||||||
"""Tests for behavior when recipe is not resolved."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idle_when_recipe_not_resolved(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf)
|
|
||||||
char = make_character(x=0, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
class TestCraftingStrategyMaterials:
|
|
||||||
"""Tests for material checking and withdrawal."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_to_bank_when_materials_missing(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
char = make_character(x=0, y=0, inventory=[])
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 10, "y": 0}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_withdraw_materials_at_bank(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
char = make_character(x=10, y=0, inventory=[])
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.WITHDRAW_ITEM
|
|
||||||
assert plan.params["code"] == "iron_ore"
|
|
||||||
assert plan.params["quantity"] == 3
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_partial_materials_withdraw_remaining(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 5)])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
# Character already has 2 iron_ore
|
|
||||||
char = make_character(
|
|
||||||
x=10, y=0,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=2)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.WITHDRAW_ITEM
|
|
||||||
assert plan.params["code"] == "iron_ore"
|
|
||||||
assert plan.params["quantity"] == 3 # Need 5, have 2, withdraw 3
|
|
||||||
|
|
||||||
|
|
||||||
class TestCraftingStrategyCrafting:
|
|
||||||
"""Tests for the crafting execution flow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_to_workshop_with_all_materials(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
|
|
||||||
)
|
|
||||||
|
|
||||||
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_craft_when_at_workshop_with_materials(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
char = make_character(
|
|
||||||
x=5, y=5,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.CRAFT
|
|
||||||
assert plan.params["code"] == "iron_sword"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_after_crafting_quantity(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy(
|
|
||||||
{"item_code": "iron_sword", "quantity": 1},
|
|
||||||
pf,
|
|
||||||
items_data=[item],
|
|
||||||
)
|
|
||||||
# Simulate having crafted enough
|
|
||||||
strategy._crafted_count = 1
|
|
||||||
|
|
||||||
char = make_character(x=5, y=5)
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
|
|
||||||
class TestCraftingStrategyRecycle:
|
|
||||||
"""Tests for recycle_excess behavior."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_recycle_after_craft(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy(
|
|
||||||
{"item_code": "iron_sword", "quantity": 10, "recycle_excess": True},
|
|
||||||
pf,
|
|
||||||
items_data=[item],
|
|
||||||
)
|
|
||||||
# Simulate: at workshop, just crafted, now checking result
|
|
||||||
strategy._state = strategy._state.__class__("check_result")
|
|
||||||
char = make_character(
|
|
||||||
x=5, y=5,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.RECYCLE
|
|
||||||
assert plan.params["code"] == "iron_sword"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCraftingStrategyDeposit:
|
|
||||||
"""Tests for deposit behavior after crafting."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deposit_after_completing_all_crafts(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy(
|
|
||||||
{"item_code": "iron_sword", "quantity": 1},
|
|
||||||
pf,
|
|
||||||
items_data=[item],
|
|
||||||
)
|
|
||||||
# Simulate: just crafted the last item
|
|
||||||
strategy._state = strategy._state.__class__("check_result")
|
|
||||||
strategy._crafted_count = 0 # Will be incremented in handler
|
|
||||||
|
|
||||||
char = make_character(
|
|
||||||
x=5, y=5,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# After crafting 1/1, should move to bank to deposit
|
|
||||||
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):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy(
|
|
||||||
{"item_code": "iron_sword", "quantity": 5}, # quantity > crafted
|
|
||||||
pf,
|
|
||||||
items_data=[item],
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("deposit")
|
|
||||||
strategy._crafted_count = 2 # Still more to craft
|
|
||||||
|
|
||||||
char = make_character(
|
|
||||||
x=10, y=0,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=2)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
|
||||||
assert plan.params["code"] == "iron_sword"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_after_all_deposited(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy(
|
|
||||||
{"item_code": "iron_sword", "quantity": 1},
|
|
||||||
pf,
|
|
||||||
items_data=[item],
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("deposit")
|
|
||||||
strategy._crafted_count = 1 # Already crafted target quantity
|
|
||||||
|
|
||||||
char = make_character(
|
|
||||||
x=10, y=0,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=1)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# With crafted_count >= quantity, the top-level check returns COMPLETE
|
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
|
|
||||||
class TestCraftingStrategyNoLocations:
|
|
||||||
"""Tests for missing map tiles."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idle_when_no_bank(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "workshop", "weaponcrafting"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
char = make_character(x=0, y=0, inventory=[])
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.IDLE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idle_when_no_workshop(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
item = _make_craftable_item(materials=[("iron_ore", 3)])
|
|
||||||
strategy = CraftingStrategy({"item_code": "iron_sword"}, pf, items_data=[item])
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=3)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.IDLE
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
"""Tests for the EquipmentOptimizer decision maker."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.engine.decision.equipment_optimizer import (
|
|
||||||
EquipmentOptimizer,
|
|
||||||
EquipmentSuggestion,
|
|
||||||
)
|
|
||||||
from app.schemas.game import EffectSchema, InventorySlot, ItemSchema
|
|
||||||
|
|
||||||
|
|
||||||
class TestScoreItem:
|
|
||||||
"""Tests for the _score_item static method."""
|
|
||||||
|
|
||||||
def test_score_none_item(self):
|
|
||||||
assert EquipmentOptimizer._score_item(None) == 0.0
|
|
||||||
|
|
||||||
def test_score_item_with_attack_effects(self, make_item):
|
|
||||||
item = make_item(
|
|
||||||
code="fire_sword",
|
|
||||||
level=10,
|
|
||||||
effects=[
|
|
||||||
EffectSchema(name="attack_fire", value=20),
|
|
||||||
EffectSchema(name="attack_earth", value=10),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
score = EquipmentOptimizer._score_item(item)
|
|
||||||
# 20 + 10 + (10 * 0.1 level bonus) = 31.0
|
|
||||||
assert score == pytest.approx(31.0)
|
|
||||||
|
|
||||||
def test_score_item_with_defense_effects(self, make_item):
|
|
||||||
item = make_item(
|
|
||||||
code="iron_shield",
|
|
||||||
level=5,
|
|
||||||
type="shield",
|
|
||||||
effects=[
|
|
||||||
EffectSchema(name="res_fire", value=15),
|
|
||||||
EffectSchema(name="res_water", value=10),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
score = EquipmentOptimizer._score_item(item)
|
|
||||||
# 15 + 10 + (5 * 0.1) = 25.5
|
|
||||||
assert score == pytest.approx(25.5)
|
|
||||||
|
|
||||||
def test_score_item_with_hp_weighted_less(self, make_item):
|
|
||||||
item = make_item(
|
|
||||||
code="hp_ring",
|
|
||||||
level=1,
|
|
||||||
type="ring",
|
|
||||||
effects=[EffectSchema(name="hp", value=100)],
|
|
||||||
)
|
|
||||||
score = EquipmentOptimizer._score_item(item)
|
|
||||||
# 100 * 0.5 + (1 * 0.1) = 50.1
|
|
||||||
assert score == pytest.approx(50.1)
|
|
||||||
|
|
||||||
def test_score_item_with_damage_weighted_more(self, make_item):
|
|
||||||
item = make_item(
|
|
||||||
code="dmg_amulet",
|
|
||||||
level=1,
|
|
||||||
type="amulet",
|
|
||||||
effects=[EffectSchema(name="dmg_fire", value=10)],
|
|
||||||
)
|
|
||||||
score = EquipmentOptimizer._score_item(item)
|
|
||||||
# 10 * 1.5 + (1 * 0.1) = 15.1
|
|
||||||
assert score == pytest.approx(15.1)
|
|
||||||
|
|
||||||
def test_score_item_level_bonus_as_tiebreaker(self, make_item):
|
|
||||||
low = make_item(code="sword_5", level=5, effects=[EffectSchema(name="attack_fire", value=10)])
|
|
||||||
high = make_item(code="sword_10", level=10, effects=[EffectSchema(name="attack_fire", value=10)])
|
|
||||||
|
|
||||||
assert EquipmentOptimizer._score_item(high) > EquipmentOptimizer._score_item(low)
|
|
||||||
|
|
||||||
def test_score_item_with_no_effects(self, make_item):
|
|
||||||
item = make_item(code="plain_sword", level=5, effects=[])
|
|
||||||
score = EquipmentOptimizer._score_item(item)
|
|
||||||
# Only level bonus: 5 * 0.1 = 0.5
|
|
||||||
assert score == pytest.approx(0.5)
|
|
||||||
|
|
||||||
def test_score_item_with_unknown_effects(self, make_item):
|
|
||||||
item = make_item(
|
|
||||||
code="weird_item",
|
|
||||||
level=1,
|
|
||||||
effects=[EffectSchema(name="unknown_effect", value=100)],
|
|
||||||
)
|
|
||||||
score = EquipmentOptimizer._score_item(item)
|
|
||||||
# Unknown effect not counted: only level bonus 0.1
|
|
||||||
assert score == pytest.approx(0.1)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSuggestEquipment:
|
|
||||||
"""Tests for the suggest_equipment method."""
|
|
||||||
|
|
||||||
def test_empty_available_items(self, make_character):
|
|
||||||
optimizer = EquipmentOptimizer()
|
|
||||||
char = make_character(level=10)
|
|
||||||
analysis = optimizer.suggest_equipment(char, [])
|
|
||||||
|
|
||||||
assert analysis.suggestions == []
|
|
||||||
assert analysis.total_current_score == 0.0
|
|
||||||
assert analysis.total_best_score == 0.0
|
|
||||||
|
|
||||||
def test_suggest_better_weapon(self, make_character, make_item):
|
|
||||||
optimizer = EquipmentOptimizer()
|
|
||||||
|
|
||||||
current_weapon = make_item(
|
|
||||||
code="rusty_sword",
|
|
||||||
level=1,
|
|
||||||
type="weapon",
|
|
||||||
effects=[EffectSchema(name="attack_fire", value=5)],
|
|
||||||
)
|
|
||||||
better_weapon = make_item(
|
|
||||||
code="iron_sword",
|
|
||||||
level=5,
|
|
||||||
type="weapon",
|
|
||||||
effects=[EffectSchema(name="attack_fire", value=20)],
|
|
||||||
)
|
|
||||||
char = make_character(level=10, weapon_slot="rusty_sword")
|
|
||||||
analysis = optimizer.suggest_equipment(char, [current_weapon, better_weapon])
|
|
||||||
|
|
||||||
weapon_suggestions = [s for s in analysis.suggestions if s.slot == "weapon_slot"]
|
|
||||||
assert len(weapon_suggestions) == 1
|
|
||||||
assert weapon_suggestions[0].suggested_item_code == "iron_sword"
|
|
||||||
assert weapon_suggestions[0].improvement > 0
|
|
||||||
|
|
||||||
def test_no_suggestion_when_best_is_equipped(self, make_character, make_item):
|
|
||||||
optimizer = EquipmentOptimizer()
|
|
||||||
|
|
||||||
best_weapon = make_item(
|
|
||||||
code="best_sword",
|
|
||||||
level=5,
|
|
||||||
type="weapon",
|
|
||||||
effects=[EffectSchema(name="attack_fire", value=50)],
|
|
||||||
)
|
|
||||||
char = make_character(level=10, weapon_slot="best_sword")
|
|
||||||
analysis = optimizer.suggest_equipment(char, [best_weapon])
|
|
||||||
|
|
||||||
weapon_suggestions = [s for s in analysis.suggestions if s.slot == "weapon_slot"]
|
|
||||||
assert len(weapon_suggestions) == 0
|
|
||||||
|
|
||||||
def test_item_too_high_level_not_suggested(self, make_character, make_item):
|
|
||||||
optimizer = EquipmentOptimizer()
|
|
||||||
|
|
||||||
high_level_weapon = make_item(
|
|
||||||
code="dragon_sword",
|
|
||||||
level=50,
|
|
||||||
type="weapon",
|
|
||||||
effects=[EffectSchema(name="attack_fire", value=100)],
|
|
||||||
)
|
|
||||||
char = make_character(level=10, weapon_slot="")
|
|
||||||
analysis = optimizer.suggest_equipment(char, [high_level_weapon])
|
|
||||||
|
|
||||||
# Too high level, should not be suggested
|
|
||||||
weapon_suggestions = [s for s in analysis.suggestions if s.slot == "weapon_slot"]
|
|
||||||
assert len(weapon_suggestions) == 0
|
|
||||||
|
|
||||||
def test_suggestions_sorted_by_improvement(self, make_character, make_item):
|
|
||||||
optimizer = EquipmentOptimizer()
|
|
||||||
|
|
||||||
weapon = make_item(
|
|
||||||
code="great_sword",
|
|
||||||
level=5,
|
|
||||||
type="weapon",
|
|
||||||
effects=[EffectSchema(name="attack_fire", value=30)],
|
|
||||||
)
|
|
||||||
shield = make_item(
|
|
||||||
code="great_shield",
|
|
||||||
level=5,
|
|
||||||
type="shield",
|
|
||||||
effects=[EffectSchema(name="res_fire", value=50)],
|
|
||||||
)
|
|
||||||
char = make_character(level=10, weapon_slot="", shield_slot="")
|
|
||||||
analysis = optimizer.suggest_equipment(char, [weapon, shield])
|
|
||||||
|
|
||||||
# Both should be suggested; shield has higher improvement
|
|
||||||
assert len(analysis.suggestions) >= 2
|
|
||||||
# Sorted descending by improvement
|
|
||||||
for i in range(len(analysis.suggestions) - 1):
|
|
||||||
assert analysis.suggestions[i].improvement >= analysis.suggestions[i + 1].improvement
|
|
||||||
|
|
||||||
def test_multiple_slot_types_for_rings(self, make_character, make_item):
|
|
||||||
optimizer = EquipmentOptimizer()
|
|
||||||
|
|
||||||
ring = make_item(
|
|
||||||
code="power_ring",
|
|
||||||
level=5,
|
|
||||||
type="ring",
|
|
||||||
effects=[EffectSchema(name="attack_fire", value=10)],
|
|
||||||
)
|
|
||||||
char = make_character(level=10, ring1_slot="", ring2_slot="")
|
|
||||||
analysis = optimizer.suggest_equipment(char, [ring])
|
|
||||||
|
|
||||||
ring_suggestions = [
|
|
||||||
s for s in analysis.suggestions if s.slot in ("ring1_slot", "ring2_slot")
|
|
||||||
]
|
|
||||||
# Both ring slots should get the suggestion
|
|
||||||
assert len(ring_suggestions) == 2
|
|
||||||
|
|
||||||
def test_total_scores_computed(self, make_character, make_item):
|
|
||||||
optimizer = EquipmentOptimizer()
|
|
||||||
|
|
||||||
weapon = make_item(
|
|
||||||
code="iron_sword",
|
|
||||||
level=5,
|
|
||||||
type="weapon",
|
|
||||||
effects=[EffectSchema(name="attack_fire", value=10)],
|
|
||||||
)
|
|
||||||
char = make_character(level=10, weapon_slot="")
|
|
||||||
analysis = optimizer.suggest_equipment(char, [weapon])
|
|
||||||
|
|
||||||
assert analysis.total_best_score >= analysis.total_current_score
|
|
||||||
|
|
||||||
def test_empty_slot_shows_empty_in_suggestion(self, make_character, make_item):
|
|
||||||
optimizer = EquipmentOptimizer()
|
|
||||||
|
|
||||||
weapon = make_item(
|
|
||||||
code="iron_sword",
|
|
||||||
level=5,
|
|
||||||
type="weapon",
|
|
||||||
effects=[EffectSchema(name="attack_fire", value=10)],
|
|
||||||
)
|
|
||||||
char = make_character(level=10, weapon_slot="")
|
|
||||||
analysis = optimizer.suggest_equipment(char, [weapon])
|
|
||||||
|
|
||||||
weapon_suggestions = [s for s in analysis.suggestions if s.slot == "weapon_slot"]
|
|
||||||
assert len(weapon_suggestions) == 1
|
|
||||||
assert weapon_suggestions[0].current_item_code == "(empty)"
|
|
||||||
|
|
@ -1,369 +0,0 @@
|
||||||
"""Tests for the LevelingStrategy state machine."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.engine.strategies.base import ActionType
|
|
||||||
from app.engine.strategies.leveling import LevelingStrategy
|
|
||||||
from app.schemas.game import InventorySlot, ResourceSchema
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyInitialization:
|
|
||||||
"""Tests for LevelingStrategy creation."""
|
|
||||||
|
|
||||||
def test_initial_state(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
|
||||||
strategy = LevelingStrategy({}, pf)
|
|
||||||
assert strategy.get_state() == "evaluate"
|
|
||||||
|
|
||||||
def test_target_skill_config(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
|
||||||
strategy = LevelingStrategy({"target_skill": "mining"}, pf)
|
|
||||||
assert strategy._target_skill == "mining"
|
|
||||||
|
|
||||||
def test_max_level_config(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
|
||||||
strategy = LevelingStrategy({"max_level": 30}, pf)
|
|
||||||
assert strategy._max_level == 30
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyEvaluation:
|
|
||||||
"""Tests for skill evaluation and target selection."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_picks_target_skill_when_specified(
|
|
||||||
self, make_character, pathfinder_with_maps
|
|
||||||
):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
|
||||||
strategy = LevelingStrategy(
|
|
||||||
{"target_skill": "mining"}, pf, resources_data=resources
|
|
||||||
)
|
|
||||||
char = make_character(x=0, y=0, mining_level=5)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert strategy._chosen_skill == "mining"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_picks_lowest_skill_when_no_target(
|
|
||||||
self, make_character, pathfinder_with_maps
|
|
||||||
):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "ash_tree"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [
|
|
||||||
ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1),
|
|
||||||
ResourceSchema(name="Ash Tree", code="ash_tree", skill="woodcutting", level=1),
|
|
||||||
ResourceSchema(name="Gudgeon Spot", code="gudgeon_spot", skill="fishing", level=1),
|
|
||||||
]
|
|
||||||
strategy = LevelingStrategy({}, pf, resources_data=resources)
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
mining_level=10,
|
|
||||||
woodcutting_level=3, # lowest
|
|
||||||
fishing_level=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert strategy._chosen_skill == "woodcutting"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_when_max_level_reached(
|
|
||||||
self, make_character, pathfinder_with_maps
|
|
||||||
):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
|
||||||
strategy = LevelingStrategy(
|
|
||||||
{"target_skill": "mining", "max_level": 10},
|
|
||||||
pf,
|
|
||||||
resources_data=resources,
|
|
||||||
)
|
|
||||||
char = make_character(x=0, y=0, mining_level=10)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_when_target_skill_at_max(self, make_character, pathfinder_with_maps):
|
|
||||||
"""When the specific target_skill has reached max_level, strategy completes."""
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
|
||||||
strategy = LevelingStrategy(
|
|
||||||
{"target_skill": "mining", "max_level": 10},
|
|
||||||
pf,
|
|
||||||
resources_data=resources,
|
|
||||||
)
|
|
||||||
char = make_character(x=0, y=0, mining_level=10)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idle_when_all_skills_above_max_level(self, make_character, pathfinder_with_maps):
|
|
||||||
"""When auto-picking skills but all are above max_level, falls through to IDLE.
|
|
||||||
|
|
||||||
NOTE: Current implementation only excludes one skill before proceeding,
|
|
||||||
so it may IDLE rather than COMPLETE when all skills exceed max_level.
|
|
||||||
"""
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = LevelingStrategy({"max_level": 5}, pf)
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
mining_level=999,
|
|
||||||
woodcutting_level=999,
|
|
||||||
fishing_level=999,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyGathering:
|
|
||||||
"""Tests for gathering activity."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_gather_at_resource(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
|
||||||
strategy = LevelingStrategy(
|
|
||||||
{"target_skill": "mining"}, pf, resources_data=resources
|
|
||||||
)
|
|
||||||
char = make_character(
|
|
||||||
x=3, y=3,
|
|
||||||
mining_level=5,
|
|
||||||
inventory_max_items=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
# First call evaluates and moves; simulate being at target
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# Since we're at the target, should get GATHER
|
|
||||||
assert plan.action_type == ActionType.GATHER
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deposit_when_inventory_full(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
|
||||||
strategy = LevelingStrategy(
|
|
||||||
{"target_skill": "mining"}, pf, resources_data=resources
|
|
||||||
)
|
|
||||||
|
|
||||||
items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)]
|
|
||||||
char = make_character(
|
|
||||||
x=3, y=3,
|
|
||||||
mining_level=5,
|
|
||||||
inventory_max_items=20,
|
|
||||||
inventory=items,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# Should move to bank
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 10, "y": 0}
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyCombat:
|
|
||||||
"""Tests for combat leveling."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_to_monster_for_combat_leveling(self, make_character, pathfinder_with_maps):
|
|
||||||
"""Combat leveling moves to a monster tile for fighting."""
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "monster", "chicken"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = LevelingStrategy({"target_skill": "combat"}, pf)
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
hp=100, max_hp=100,
|
|
||||||
level=5,
|
|
||||||
inventory_max_items=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# _choose_combat_target finds nearest monster via find_nearest_by_type
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 3, "y": 3}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_heal_during_combat(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "monster", "chicken"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = LevelingStrategy({"target_skill": "combat"}, pf)
|
|
||||||
# Simulate: at monster, fighting, low HP
|
|
||||||
strategy._state = strategy._state.__class__("fight")
|
|
||||||
strategy._chosen_monster_code = "chicken"
|
|
||||||
strategy._target_pos = (3, 3)
|
|
||||||
|
|
||||||
char = make_character(
|
|
||||||
x=3, y=3,
|
|
||||||
hp=30, max_hp=100,
|
|
||||||
level=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.REST
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyCraftingSkills:
|
|
||||||
"""Tests for crafting skill leveling via gathering."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_crafting_skill_mapped_to_gathering(
|
|
||||||
self, make_character, pathfinder_with_maps
|
|
||||||
):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [
|
|
||||||
ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1),
|
|
||||||
]
|
|
||||||
strategy = LevelingStrategy(
|
|
||||||
{"target_skill": "weaponcrafting"}, pf, resources_data=resources
|
|
||||||
)
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
mining_level=5,
|
|
||||||
weaponcrafting_level=3,
|
|
||||||
inventory_max_items=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# Weaponcrafting maps to mining, so should find mining resource
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 3, "y": 3}
|
|
||||||
|
|
||||||
def test_crafting_to_gathering_mapping(self):
|
|
||||||
"""Verify all crafting skills map to gathering skills."""
|
|
||||||
assert LevelingStrategy._crafting_to_gathering("weaponcrafting") == "mining"
|
|
||||||
assert LevelingStrategy._crafting_to_gathering("gearcrafting") == "mining"
|
|
||||||
assert LevelingStrategy._crafting_to_gathering("jewelrycrafting") == "mining"
|
|
||||||
assert LevelingStrategy._crafting_to_gathering("cooking") == "fishing"
|
|
||||||
assert LevelingStrategy._crafting_to_gathering("alchemy") == "mining"
|
|
||||||
assert LevelingStrategy._crafting_to_gathering("unknown") == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyResourceSelection:
|
|
||||||
"""Tests for resource target selection based on skill level."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_prefers_higher_level_resource_within_range(
|
|
||||||
self, make_character, pathfinder_with_maps
|
|
||||||
):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(1, 1, "resource", "copper_rocks"),
|
|
||||||
(2, 2, "resource", "iron_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [
|
|
||||||
ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1),
|
|
||||||
ResourceSchema(name="Iron Rocks", code="iron_rocks", skill="mining", level=6),
|
|
||||||
]
|
|
||||||
strategy = LevelingStrategy(
|
|
||||||
{"target_skill": "mining"}, pf, resources_data=resources
|
|
||||||
)
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
mining_level=5,
|
|
||||||
inventory_max_items=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
await strategy.next_action(char)
|
|
||||||
# Should prefer iron_rocks (level 6, within +3 of skill level 5)
|
|
||||||
assert strategy._chosen_resource_code == "iron_rocks"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_set_resources_data(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
|
||||||
strategy = LevelingStrategy({}, pf)
|
|
||||||
assert strategy._resources_data == []
|
|
||||||
|
|
||||||
resources = [
|
|
||||||
ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1),
|
|
||||||
]
|
|
||||||
strategy.set_resources_data(resources)
|
|
||||||
assert len(strategy._resources_data) == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyDeposit:
|
|
||||||
"""Tests for deposit behavior."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = LevelingStrategy({"target_skill": "mining"}, pf)
|
|
||||||
strategy._state = strategy._state.__class__("deposit")
|
|
||||||
strategy._chosen_skill = "mining"
|
|
||||||
strategy._target_pos = (3, 3)
|
|
||||||
|
|
||||||
char = make_character(
|
|
||||||
x=10, y=0,
|
|
||||||
mining_level=5,
|
|
||||||
inventory=[InventorySlot(slot=0, code="copper_ore", quantity=10)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
|
||||||
assert plan.params["code"] == "copper_ore"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_re_evaluate_after_deposit(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
|
|
||||||
strategy = LevelingStrategy(
|
|
||||||
{"target_skill": "mining"}, pf, resources_data=resources
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("deposit")
|
|
||||||
strategy._chosen_skill = "mining"
|
|
||||||
|
|
||||||
char = make_character(
|
|
||||||
x=10, y=0,
|
|
||||||
mining_level=5,
|
|
||||||
inventory=[],
|
|
||||||
inventory_max_items=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# Empty inventory triggers re-evaluation -> move to target
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevelingStrategyGetState:
|
|
||||||
"""Tests for state reporting."""
|
|
||||||
|
|
||||||
def test_state_with_chosen_skill(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
|
||||||
strategy = LevelingStrategy({}, pf)
|
|
||||||
strategy._chosen_skill = "mining"
|
|
||||||
assert "mining" in strategy.get_state()
|
|
||||||
|
|
||||||
def test_state_without_chosen_skill(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "resource", "copper_rocks")])
|
|
||||||
strategy = LevelingStrategy({}, pf)
|
|
||||||
assert strategy.get_state() == "evaluate"
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
"""Tests for the TaskStrategy state machine."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.engine.strategies.base import ActionType
|
|
||||||
from app.engine.strategies.task import TaskStrategy
|
|
||||||
from app.schemas.game import InventorySlot
|
|
||||||
|
|
||||||
|
|
||||||
class TestTaskStrategyInitialization:
|
|
||||||
"""Tests for TaskStrategy creation."""
|
|
||||||
|
|
||||||
def test_initial_state(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "tasks_master", "tasks_master")])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
assert strategy.get_state() == "move_to_taskmaster"
|
|
||||||
|
|
||||||
def test_max_tasks_config(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "tasks_master", "tasks_master")])
|
|
||||||
strategy = TaskStrategy({"max_tasks": 5}, pf)
|
|
||||||
assert strategy._max_tasks == 5
|
|
||||||
|
|
||||||
def test_auto_exchange_default(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "tasks_master", "tasks_master")])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
assert strategy._auto_exchange is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestTaskStrategyMovement:
|
|
||||||
"""Tests for movement to task master."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_to_taskmaster(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, 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_taskmaster(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(10, 0, "bank", "bank")])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
char = make_character(x=0, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.IDLE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_skip_to_evaluate_when_has_task(self, make_character, pathfinder_with_maps):
|
|
||||||
"""If character already has a task, skip directly to evaluation."""
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(3, 3, "monster", "chicken"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
task="chicken",
|
|
||||||
task_type="monsters",
|
|
||||||
task_total=5,
|
|
||||||
task_progress=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# Should move to the monster target, not to taskmaster
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 3, "y": 3}
|
|
||||||
|
|
||||||
|
|
||||||
class TestTaskStrategyAcceptTask:
|
|
||||||
"""Tests for accepting tasks."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_accept_new_task_at_taskmaster(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
char = make_character(x=5, y=5)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.TASK_NEW
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_evaluate_existing_task_instead_of_accept(
|
|
||||||
self, make_character, pathfinder_with_maps
|
|
||||||
):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(3, 3, "monster", "wolf"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
char = make_character(
|
|
||||||
x=5, y=5,
|
|
||||||
task="wolf",
|
|
||||||
task_type="monsters",
|
|
||||||
task_total=3,
|
|
||||||
task_progress=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# Should go to the monster, not accept a new task
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 3, "y": 3}
|
|
||||||
|
|
||||||
|
|
||||||
class TestTaskStrategyExecution:
|
|
||||||
"""Tests for task action execution."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fight_for_monster_task(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(3, 3, "monster", "chicken"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
char = make_character(
|
|
||||||
x=3, y=3,
|
|
||||||
hp=100, max_hp=100,
|
|
||||||
task="chicken",
|
|
||||||
task_type="monsters",
|
|
||||||
task_total=5,
|
|
||||||
task_progress=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.FIGHT
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_gather_for_resource_task(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
char = make_character(
|
|
||||||
x=3, y=3,
|
|
||||||
inventory_max_items=20,
|
|
||||||
task="copper_rocks",
|
|
||||||
task_type="resources",
|
|
||||||
task_total=10,
|
|
||||||
task_progress=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.GATHER
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_heal_when_low_hp_during_monster_task(
|
|
||||||
self, make_character, pathfinder_with_maps
|
|
||||||
):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(3, 3, "monster", "chicken"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
char = make_character(
|
|
||||||
x=3, y=3,
|
|
||||||
hp=30, max_hp=100,
|
|
||||||
task="chicken",
|
|
||||||
task_type="monsters",
|
|
||||||
task_total=5,
|
|
||||||
task_progress=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.REST
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deposit_when_inventory_full(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(3, 3, "resource", "copper_rocks"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)]
|
|
||||||
char = make_character(
|
|
||||||
x=3, y=3,
|
|
||||||
inventory_max_items=20,
|
|
||||||
inventory=items,
|
|
||||||
task="copper_rocks",
|
|
||||||
task_type="resources",
|
|
||||||
task_total=30,
|
|
||||||
task_progress=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 10, "y": 0}
|
|
||||||
|
|
||||||
|
|
||||||
class TestTaskStrategyCompletion:
|
|
||||||
"""Tests for task completion flow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trade_when_task_complete(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(3, 3, "monster", "chicken"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
char = make_character(
|
|
||||||
x=0, y=0,
|
|
||||||
task="chicken",
|
|
||||||
task_type="monsters",
|
|
||||||
task_total=5,
|
|
||||||
task_progress=5, # Complete!
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
# Should move to taskmaster to trade
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 5, "y": 5}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trade_items_at_taskmaster(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({}, pf)
|
|
||||||
strategy._state = strategy._state.__class__("trade_items")
|
|
||||||
strategy._current_task_code = "chicken"
|
|
||||||
strategy._current_task_total = 5
|
|
||||||
|
|
||||||
char = make_character(x=5, y=5)
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.TASK_TRADE
|
|
||||||
assert plan.params["code"] == "chicken"
|
|
||||||
assert plan.params["quantity"] == 5
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_task_at_taskmaster(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({"auto_exchange": True}, pf)
|
|
||||||
strategy._state = strategy._state.__class__("complete_task")
|
|
||||||
strategy._current_task_code = "chicken"
|
|
||||||
|
|
||||||
char = make_character(x=5, y=5)
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.TASK_COMPLETE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exchange_coins_after_complete(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({"auto_exchange": True}, pf)
|
|
||||||
strategy._state = strategy._state.__class__("exchange_coins")
|
|
||||||
|
|
||||||
char = make_character(x=5, y=5)
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.TASK_EXCHANGE
|
|
||||||
|
|
||||||
|
|
||||||
class TestTaskStrategyMaxTasks:
|
|
||||||
"""Tests for max_tasks limit."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_when_max_tasks_reached(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({"max_tasks": 3}, pf)
|
|
||||||
strategy._tasks_completed = 3
|
|
||||||
|
|
||||||
char = make_character(x=5, y=5)
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_continue_when_below_max_tasks(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({"max_tasks": 3}, pf)
|
|
||||||
strategy._tasks_completed = 2
|
|
||||||
|
|
||||||
char = make_character(x=0, y=0)
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type != ActionType.COMPLETE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_limit_when_max_tasks_zero(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(5, 5, "tasks_master", "tasks_master"),
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TaskStrategy({"max_tasks": 0}, pf)
|
|
||||||
strategy._tasks_completed = 100
|
|
||||||
|
|
||||||
char = make_character(x=0, y=0)
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type != ActionType.COMPLETE
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
"""Tests for the TradingStrategy state machine."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.engine.strategies.base import ActionType
|
|
||||||
from app.engine.strategies.trading import TradingStrategy
|
|
||||||
from app.schemas.game import InventorySlot
|
|
||||||
|
|
||||||
|
|
||||||
class TestTradingStrategyInitialization:
|
|
||||||
"""Tests for TradingStrategy creation and initial state."""
|
|
||||||
|
|
||||||
def test_sell_loot_initial_state(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
|
||||||
)
|
|
||||||
assert "sell_loot" in strategy.get_state()
|
|
||||||
assert "move_to_bank" in strategy.get_state()
|
|
||||||
|
|
||||||
def test_buy_materials_initial_state(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "grand_exchange", "grand_exchange")])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "buy_materials"}, pf
|
|
||||||
)
|
|
||||||
assert "buy_materials" in strategy.get_state()
|
|
||||||
assert "move_to_ge" in strategy.get_state()
|
|
||||||
|
|
||||||
def test_flip_initial_state(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "grand_exchange", "grand_exchange")])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "flip"}, pf
|
|
||||||
)
|
|
||||||
assert "flip" in strategy.get_state()
|
|
||||||
assert "move_to_ge" in strategy.get_state()
|
|
||||||
|
|
||||||
def test_unknown_mode_defaults_to_sell_loot(self, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([(0, 0, "bank", "bank")])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "invalid_mode"}, pf
|
|
||||||
)
|
|
||||||
assert "sell_loot" in strategy.get_state()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTradingStrategySellLoot:
|
|
||||||
"""Tests for the sell_loot mode."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_to_bank_first(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5}, pf
|
|
||||||
)
|
|
||||||
char = make_character(x=0, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 10, "y": 0}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_withdraw_at_bank(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5}, pf
|
|
||||||
)
|
|
||||||
char = make_character(x=10, y=0, inventory_max_items=20)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.WITHDRAW_ITEM
|
|
||||||
assert plan.params["code"] == "iron_ore"
|
|
||||||
assert plan.params["quantity"] == 5
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_withdraw_limited_by_free_slots(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 100}, pf
|
|
||||||
)
|
|
||||||
# Only 3 free slots
|
|
||||||
items = [InventorySlot(slot=i, code="junk", quantity=1) for i in range(17)]
|
|
||||||
char = make_character(x=10, y=0, inventory_max_items=20, inventory=items)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.WITHDRAW_ITEM
|
|
||||||
assert plan.params["quantity"] == 3 # min(100, 3 free slots)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_to_ge_after_withdraw(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5}, pf
|
|
||||||
)
|
|
||||||
# Simulate: withdrew everything
|
|
||||||
strategy._items_withdrawn = 5
|
|
||||||
strategy._state = strategy._state.__class__("withdraw_items")
|
|
||||||
|
|
||||||
char = make_character(x=10, y=0, inventory_max_items=20)
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 20, "y": 0}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_sell_order_at_ge(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5, "min_price": 10},
|
|
||||||
pf,
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("create_sell_order")
|
|
||||||
char = make_character(
|
|
||||||
x=20, y=0,
|
|
||||||
inventory=[InventorySlot(slot=0, code="iron_ore", quantity=5)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.GE_SELL
|
|
||||||
assert plan.params["code"] == "iron_ore"
|
|
||||||
assert plan.params["quantity"] == 5
|
|
||||||
assert plan.params["price"] == 10
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_when_no_items_to_sell(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot", "quantity": 5}, pf
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("create_sell_order")
|
|
||||||
char = make_character(x=20, y=0, inventory=[])
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
|
|
||||||
class TestTradingStrategyBuyMaterials:
|
|
||||||
"""Tests for the buy_materials mode."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_to_ge_first(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "buy_materials", "quantity": 10}, pf
|
|
||||||
)
|
|
||||||
char = make_character(x=0, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.MOVE
|
|
||||||
assert plan.params == {"x": 20, "y": 0}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_buy_order_at_ge(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "buy_materials", "quantity": 10, "max_price": 50},
|
|
||||||
pf,
|
|
||||||
)
|
|
||||||
char = make_character(x=20, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.GE_BUY
|
|
||||||
assert plan.params["code"] == "iron_ore"
|
|
||||||
assert plan.params["quantity"] == 10
|
|
||||||
assert plan.params["price"] == 50
|
|
||||||
|
|
||||||
|
|
||||||
class TestTradingStrategyWaiting:
|
|
||||||
"""Tests for the order waiting logic."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idle_while_waiting(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("wait_for_order")
|
|
||||||
strategy._wait_cycles = 0
|
|
||||||
char = make_character(x=20, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.IDLE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_orders_after_wait(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("wait_for_order")
|
|
||||||
strategy._wait_cycles = 3 # After 3 cycles, should check
|
|
||||||
char = make_character(x=20, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
||||||
|
|
||||||
class TestTradingStrategyNoLocations:
|
|
||||||
"""Tests for missing map tiles."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idle_when_no_bank(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
|
||||||
)
|
|
||||||
char = make_character(x=0, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.IDLE
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idle_when_no_ge(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "buy_materials"}, pf
|
|
||||||
)
|
|
||||||
char = make_character(x=0, y=0)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.IDLE
|
|
||||||
|
|
||||||
|
|
||||||
class TestTradingStrategyDeposit:
|
|
||||||
"""Tests for the deposit_items state."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deposit_items(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("deposit_items")
|
|
||||||
char = make_character(
|
|
||||||
x=10, y=0,
|
|
||||||
inventory=[InventorySlot(slot=0, code="gold_coins", quantity=100)],
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.DEPOSIT_ITEM
|
|
||||||
assert plan.params["code"] == "gold_coins"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_after_all_deposited(self, make_character, pathfinder_with_maps):
|
|
||||||
pf = pathfinder_with_maps([
|
|
||||||
(10, 0, "bank", "bank"),
|
|
||||||
(20, 0, "grand_exchange", "grand_exchange"),
|
|
||||||
])
|
|
||||||
strategy = TradingStrategy(
|
|
||||||
{"item_code": "iron_ore", "mode": "sell_loot"}, pf
|
|
||||||
)
|
|
||||||
strategy._state = strategy._state.__class__("deposit_items")
|
|
||||||
char = make_character(x=10, y=0, inventory=[])
|
|
||||||
|
|
||||||
plan = await strategy.next_action(char)
|
|
||||||
assert plan.action_type == ActionType.COMPLETE
|
|
||||||
|
|
@ -32,13 +32,23 @@ services:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: prod
|
target: prod
|
||||||
expose:
|
|
||||||
- "3000"
|
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
},
|
},
|
||||||
async rewrites() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/api/:path*",
|
|
||||||
destination: "http://backend:8000/:path*",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|
@ -24,7 +15,4 @@ const nextConfig: NextConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withSentryConfig(nextConfig, {
|
export default nextConfig;
|
||||||
silent: true,
|
|
||||||
disableLogger: true,
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/nextjs": "^9.47.1",
|
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +0,0 @@
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "",
|
|
||||||
environment: process.env.NEXT_PUBLIC_ENVIRONMENT || "development",
|
|
||||||
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
||||||
tracesSampleRate: 0.2,
|
|
||||||
replaysSessionSampleRate: 0,
|
|
||||||
replaysOnErrorSampleRate: 0.5,
|
|
||||||
});
|
|
||||||
|
|
@ -11,8 +11,6 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
GitBranch,
|
|
||||||
Network,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
@ -39,14 +37,8 @@ import {
|
||||||
useAutomationStatuses,
|
useAutomationStatuses,
|
||||||
useDeleteAutomation,
|
useDeleteAutomation,
|
||||||
} from "@/hooks/use-automations";
|
} from "@/hooks/use-automations";
|
||||||
import { useWorkflows } from "@/hooks/use-workflows";
|
|
||||||
import { usePipelines } from "@/hooks/use-pipelines";
|
|
||||||
import { RunControls } from "@/components/automation/run-controls";
|
import { RunControls } from "@/components/automation/run-controls";
|
||||||
import { AutomationGallery } from "@/components/automation/automation-gallery";
|
import { AutomationGallery } from "@/components/automation/automation-gallery";
|
||||||
import { WorkflowList } from "@/components/workflow/workflow-list";
|
|
||||||
import { WorkflowTemplateGallery } from "@/components/workflow/workflow-template-gallery";
|
|
||||||
import { PipelineList } from "@/components/pipeline/pipeline-list";
|
|
||||||
import { PipelineTemplateGallery } from "@/components/pipeline/pipeline-template-gallery";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -72,8 +64,6 @@ export default function AutomationsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: automations, isLoading, error } = useAutomations();
|
const { data: automations, isLoading, error } = useAutomations();
|
||||||
const { data: statuses } = useAutomationStatuses();
|
const { data: statuses } = useAutomationStatuses();
|
||||||
const { data: workflows } = useWorkflows();
|
|
||||||
const { data: pipelines } = usePipelines();
|
|
||||||
const deleteMutation = useDeleteAutomation();
|
const deleteMutation = useDeleteAutomation();
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
const [deleteTarget, setDeleteTarget] = useState<{
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -108,26 +98,10 @@ export default function AutomationsPage() {
|
||||||
Manage automated strategies for your characters
|
Manage automated strategies for your characters
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button onClick={() => router.push("/automations/new")}>
|
||||||
<Button
|
<Plus className="size-4" />
|
||||||
variant="outline"
|
New Automation
|
||||||
onClick={() => router.push("/automations/pipelines/new")}
|
</Button>
|
||||||
>
|
|
||||||
<Network className="size-4" />
|
|
||||||
New Pipeline
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/automations/workflows/new")}
|
|
||||||
>
|
|
||||||
<GitBranch className="size-4" />
|
|
||||||
New Workflow
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => router.push("/automations/new")}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
New Automation
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="gallery">
|
<Tabs defaultValue="gallery">
|
||||||
|
|
@ -145,30 +119,10 @@ export default function AutomationsPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="workflows" className="gap-1.5">
|
|
||||||
<GitBranch className="size-3.5" />
|
|
||||||
Workflows
|
|
||||||
{workflows && workflows.length > 0 && (
|
|
||||||
<Badge variant="secondary" className="ml-1 text-xs px-1.5 py-0">
|
|
||||||
{workflows.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="pipelines" className="gap-1.5">
|
|
||||||
<Network className="size-3.5" />
|
|
||||||
Pipelines
|
|
||||||
{pipelines && pipelines.length > 0 && (
|
|
||||||
<Badge variant="secondary" className="ml-1 text-xs px-1.5 py-0">
|
|
||||||
{pipelines.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="gallery" className="mt-6 space-y-8">
|
<TabsContent value="gallery" className="mt-6">
|
||||||
<AutomationGallery />
|
<AutomationGallery />
|
||||||
<WorkflowTemplateGallery />
|
|
||||||
<PipelineTemplateGallery />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="active" className="mt-6">
|
<TabsContent value="active" className="mt-6">
|
||||||
|
|
@ -277,14 +231,6 @@ export default function AutomationsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="workflows" className="mt-6">
|
|
||||||
<WorkflowList />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="pipelines" className="mt-6">
|
|
||||||
<PipelineList />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { use } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowLeft, Loader2, Repeat, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { usePipeline, usePipelineStatuses, useDeletePipeline } from "@/hooks/use-pipelines";
|
|
||||||
import { PipelineProgress } from "@/components/pipeline/pipeline-progress";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
export default function PipelineDetailPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
|
||||||
const { id: idStr } = use(params);
|
|
||||||
const id = Number(idStr);
|
|
||||||
const router = useRouter();
|
|
||||||
const { data, isLoading, error } = usePipeline(id);
|
|
||||||
const { data: statuses } = usePipelineStatuses();
|
|
||||||
const deleteMutation = useDeletePipeline();
|
|
||||||
|
|
||||||
const status = (statuses ?? []).find((s) => s.pipeline_id === id) ?? null;
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
deleteMutation.mutate(id, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Pipeline deleted");
|
|
||||||
router.push("/automations");
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return (
|
|
||||||
<Card className="border-destructive bg-destructive/10 p-4">
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
Failed to load pipeline. It may have been deleted.
|
|
||||||
</p>
|
|
||||||
<Link href="/automations" className="mt-2 inline-block">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Back to Automations
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { config, runs } = data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href="/automations">
|
|
||||||
<Button variant="ghost" size="icon-xs">
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
|
||||||
{config.name}
|
|
||||||
</h1>
|
|
||||||
{config.loop && (
|
|
||||||
<Repeat className="size-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{config.description && (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{config.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live progress */}
|
|
||||||
<PipelineProgress config={config} status={status} />
|
|
||||||
|
|
||||||
{/* Stages overview */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
Stages ({config.stages.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{config.stages.map((stage, idx) => (
|
|
||||||
<div key={stage.id} className="border rounded-lg p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Stage {idx + 1}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-sm font-medium">{stage.name}</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
|
||||||
{stage.character_steps.length} worker
|
|
||||||
{stage.character_steps.length !== 1 && "s"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{stage.character_steps.map((cs) => (
|
|
||||||
<div
|
|
||||||
key={cs.id}
|
|
||||||
className="text-xs bg-muted/30 rounded p-2 space-y-0.5"
|
|
||||||
>
|
|
||||||
<div className="font-medium">{cs.character_name}</div>
|
|
||||||
<div className="text-muted-foreground capitalize">
|
|
||||||
{cs.strategy_type}
|
|
||||||
</div>
|
|
||||||
{cs.transition && (
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Until: {cs.transition.type}
|
|
||||||
{cs.transition.value != null &&
|
|
||||||
` ${cs.transition.operator ?? ">="} ${cs.transition.value}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Run history */}
|
|
||||||
{runs.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
Run History ({runs.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Run</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Stage</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
<TableHead>Started</TableHead>
|
|
||||||
<TableHead>Stopped</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{runs.slice(0, 20).map((run) => (
|
|
||||||
<TableRow key={run.id}>
|
|
||||||
<TableCell className="font-mono text-xs">
|
|
||||||
#{run.id}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
run.status === "error" ? "destructive" : "secondary"
|
|
||||||
}
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
{run.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{run.current_stage_index + 1}
|
|
||||||
{run.loop_count > 0 && ` (loop ${run.loop_count})`}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{run.total_actions_count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{new Date(run.started_at).toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{run.stopped_at
|
|
||||||
? new Date(run.stopped_at).toLocaleString()
|
|
||||||
: "\u2014"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { PipelineBuilder } from "@/components/pipeline/pipeline-builder";
|
|
||||||
|
|
||||||
export default function NewPipelinePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href="/automations">
|
|
||||||
<Button variant="ghost" size="icon-xs">
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
|
||||||
New Pipeline
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Create a multi-character pipeline with sequential stages
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PipelineBuilder />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { use } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Loader2,
|
|
||||||
Repeat,
|
|
||||||
Play,
|
|
||||||
Square,
|
|
||||||
Pause,
|
|
||||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
useWorkflow,
|
|
||||||
useWorkflowStatuses,
|
|
||||||
useWorkflowLogs,
|
|
||||||
useControlWorkflow,
|
|
||||||
} from "@/hooks/use-workflows";
|
|
||||||
import { WorkflowProgress } from "@/components/workflow/workflow-progress";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
|
||||||
running: "bg-green-600 text-white",
|
|
||||||
paused: "bg-yellow-600 text-white",
|
|
||||||
stopped: "",
|
|
||||||
completed: "bg-blue-600 text-white",
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkflowDetailPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
|
||||||
const { id: idStr } = use(params);
|
|
||||||
const id = parseInt(idStr, 10);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useWorkflow(id);
|
|
||||||
const { data: statuses } = useWorkflowStatuses();
|
|
||||||
const { data: logs } = useWorkflowLogs(id);
|
|
||||||
const control = useControlWorkflow();
|
|
||||||
|
|
||||||
const status = statuses?.find((s) => s.workflow_id === id) ?? null;
|
|
||||||
const currentStatus = status?.status ?? "stopped";
|
|
||||||
|
|
||||||
const isStopped =
|
|
||||||
currentStatus === "stopped" ||
|
|
||||||
currentStatus === "completed" ||
|
|
||||||
currentStatus === "error" ||
|
|
||||||
!currentStatus;
|
|
||||||
const isRunning = currentStatus === "running";
|
|
||||||
const isPaused = currentStatus === "paused";
|
|
||||||
|
|
||||||
function handleControl(action: "start" | "stop" | "pause" | "resume") {
|
|
||||||
control.mutate(
|
|
||||||
{ id, action },
|
|
||||||
{
|
|
||||||
onSuccess: () => toast.success(`Workflow ${action}ed`),
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(`Failed to ${action} workflow: ${err.message}`),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 workflow. It may have been deleted.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { config: workflow, 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">
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
|
||||||
{workflow.name}
|
|
||||||
</h1>
|
|
||||||
{workflow.loop && (
|
|
||||||
<Badge variant="outline" className="gap-1">
|
|
||||||
<Repeat className="size-3" />
|
|
||||||
Loop
|
|
||||||
{workflow.max_loops > 0 && ` (max ${workflow.max_loops})`}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{workflow.character_name} · {workflow.steps.length} step
|
|
||||||
{workflow.steps.length !== 1 && "s"}
|
|
||||||
{workflow.description && ` · ${workflow.description}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<Card className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
currentStatus === "error"
|
|
||||||
? "destructive"
|
|
||||||
: currentStatus === "running"
|
|
||||||
? "default"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
className={STATUS_BADGE_CLASSES[currentStatus] ?? ""}
|
|
||||||
>
|
|
||||||
{control.isPending && (
|
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
)}
|
|
||||||
{currentStatus}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{status && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{status.total_actions_count.toLocaleString()} actions
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{isStopped && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
|
||||||
onClick={() => handleControl("start")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-3" />
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isRunning && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
|
|
||||||
onClick={() => handleControl("pause")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Pause className="size-3" />
|
|
||||||
Pause
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
onClick={() => handleControl("stop")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Square className="size-3" />
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isPaused && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
|
||||||
onClick={() => handleControl("resume")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-3" />
|
|
||||||
Resume
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
onClick={() => handleControl("stop")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Square className="size-3" />
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs defaultValue="progress">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="progress">Progress</TabsTrigger>
|
|
||||||
<TabsTrigger value="runs">Run History</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="progress">
|
|
||||||
<WorkflowProgress
|
|
||||||
steps={workflow.steps}
|
|
||||||
status={status}
|
|
||||||
logs={logs ?? []}
|
|
||||||
loop={workflow.loop}
|
|
||||||
maxLoops={workflow.max_loops}
|
|
||||||
/>
|
|
||||||
</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 workflow to create a run.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Started</TableHead>
|
|
||||||
<TableHead>Duration</TableHead>
|
|
||||||
<TableHead>Steps</TableHead>
|
|
||||||
<TableHead>Loops</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 text-xs">
|
|
||||||
{run.current_step_index + 1}/{workflow.steps.length}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="tabular-nums text-xs">
|
|
||||||
{run.loop_count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="tabular-nums">
|
|
||||||
{run.total_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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { WorkflowBuilder } from "@/components/workflow/workflow-builder";
|
|
||||||
import {
|
|
||||||
WORKFLOW_TEMPLATES,
|
|
||||||
} from "@/components/workflow/workflow-templates";
|
|
||||||
import type { WorkflowStep } from "@/lib/types";
|
|
||||||
|
|
||||||
export default function NewWorkflowPage() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const templateId = searchParams.get("template");
|
|
||||||
|
|
||||||
const template = templateId
|
|
||||||
? WORKFLOW_TEMPLATES.find((t) => t.id === templateId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const initialSteps: WorkflowStep[] | undefined = template
|
|
||||||
? template.steps.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
strategy_type: s.strategy_type as WorkflowStep["strategy_type"],
|
|
||||||
config: s.config as Record<string, unknown>,
|
|
||||||
transition: s.transition
|
|
||||||
? {
|
|
||||||
type: s.transition.type as WorkflowStep["transition"] extends null ? never : NonNullable<WorkflowStep["transition"]>["type"],
|
|
||||||
operator: (s.transition.operator ?? ">=") as ">=" | "<=" | "==" | ">" | "<",
|
|
||||||
value: s.transition.value ?? 0,
|
|
||||||
item_code: s.transition.item_code ?? "",
|
|
||||||
skill: s.transition.skill ?? "",
|
|
||||||
seconds: s.transition.seconds ?? 0,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkflowBuilder
|
|
||||||
initialSteps={initialSteps}
|
|
||||||
initialName={template?.name ?? ""}
|
|
||||||
initialDescription={template?.description ?? ""}
|
|
||||||
initialLoop={template?.loop ?? false}
|
|
||||||
initialMaxLoops={template?.max_loops ?? 0}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { reportError } from "@/lib/api-client";
|
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
|
|
@ -13,20 +11,6 @@ export default function Error({
|
||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
|
||||||
reportError({
|
|
||||||
error_type: error.name || "UnhandledError",
|
|
||||||
message: error.message || "Unknown error",
|
|
||||||
stack_trace: error.stack,
|
|
||||||
context: {
|
|
||||||
digest: error.digest,
|
|
||||||
url: typeof window !== "undefined" ? window.location.href : undefined,
|
|
||||||
},
|
|
||||||
}).catch(() => {
|
|
||||||
// Silently fail - don't create error loops
|
|
||||||
});
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full p-6">
|
<div className="flex items-center justify-center h-full p-6">
|
||||||
<Card className="max-w-md w-full">
|
<Card className="max-w-md w-full">
|
||||||
|
|
|
||||||
|
|
@ -1,395 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Fragment, useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
Loader2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
Clock,
|
|
||||||
Bug,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { useErrors, useErrorStats, useResolveError } from "@/hooks/use-errors";
|
|
||||||
import type { AppError } from "@/lib/types";
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<string, string> = {
|
|
||||||
critical: "bg-red-600/20 text-red-400",
|
|
||||||
error: "bg-red-500/20 text-red-400",
|
|
||||||
warning: "bg-yellow-500/20 text-yellow-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOURCE_COLORS: Record<string, string> = {
|
|
||||||
backend: "bg-blue-500/20 text-blue-400",
|
|
||||||
frontend: "bg-purple-500/20 text-purple-400",
|
|
||||||
automation: "bg-green-500/20 text-green-400",
|
|
||||||
middleware: "bg-orange-500/20 text-orange-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString([], {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
icon: Icon,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-md ${color}`}>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{value}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandedRow({ error }: { error: AppError }) {
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={6} className="bg-muted/30 p-4">
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
{error.correlation_id && (
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Correlation ID: </span>
|
|
||||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
|
||||||
{error.correlation_id}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error.context && Object.keys(error.context).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Context:</span>
|
|
||||||
<pre className="mt-1 text-xs bg-muted p-3 rounded-md overflow-x-auto max-h-40">
|
|
||||||
{JSON.stringify(error.context, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error.stack_trace && (
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Stack Trace:</span>
|
|
||||||
<pre className="mt-1 text-xs bg-muted p-3 rounded-md overflow-x-auto max-h-60 whitespace-pre-wrap">
|
|
||||||
{error.stack_trace}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ErrorsPage() {
|
|
||||||
const [severityFilter, setSeverityFilter] = useState("_all");
|
|
||||||
const [sourceFilter, setSourceFilter] = useState("_all");
|
|
||||||
const [resolvedFilter, setResolvedFilter] = useState("_all");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useErrors({
|
|
||||||
severity: severityFilter === "_all" ? undefined : severityFilter,
|
|
||||||
source: sourceFilter === "_all" ? undefined : sourceFilter,
|
|
||||||
resolved: resolvedFilter === "_all" ? undefined : resolvedFilter,
|
|
||||||
page,
|
|
||||||
size: PAGE_SIZE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: stats } = useErrorStats();
|
|
||||||
const resolveMutation = useResolveError();
|
|
||||||
|
|
||||||
const errors = data?.errors ?? [];
|
|
||||||
const totalPages = data?.pages ?? 1;
|
|
||||||
const total = data?.total ?? 0;
|
|
||||||
|
|
||||||
function handleFilterChange(setter: (v: string) => void) {
|
|
||||||
return (value: string) => {
|
|
||||||
setter(value);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
|
||||||
Error Tracker
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Application errors from backend, frontend, and automations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Card className="border-destructive bg-destructive/10 p-4">
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
Failed to load errors. Make sure the backend is running.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats cards */}
|
|
||||||
{stats && (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<StatCard
|
|
||||||
label="Total Errors"
|
|
||||||
value={stats.total}
|
|
||||||
icon={Bug}
|
|
||||||
color="bg-red-500/20 text-red-400"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Unresolved"
|
|
||||||
value={stats.unresolved}
|
|
||||||
icon={XCircle}
|
|
||||||
color="bg-orange-500/20 text-orange-400"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Last Hour"
|
|
||||||
value={stats.last_hour}
|
|
||||||
icon={Clock}
|
|
||||||
color="bg-yellow-500/20 text-yellow-400"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Resolved"
|
|
||||||
value={stats.total - stats.unresolved}
|
|
||||||
icon={CheckCircle2}
|
|
||||||
color="bg-green-500/20 text-green-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Select
|
|
||||||
value={severityFilter}
|
|
||||||
onValueChange={handleFilterChange(setSeverityFilter)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="All Severities" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="_all">All Severities</SelectItem>
|
|
||||||
<SelectItem value="critical">Critical</SelectItem>
|
|
||||||
<SelectItem value="error">Error</SelectItem>
|
|
||||||
<SelectItem value="warning">Warning</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={sourceFilter}
|
|
||||||
onValueChange={handleFilterChange(setSourceFilter)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="All Sources" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="_all">All Sources</SelectItem>
|
|
||||||
<SelectItem value="backend">Backend</SelectItem>
|
|
||||||
<SelectItem value="frontend">Frontend</SelectItem>
|
|
||||||
<SelectItem value="automation">Automation</SelectItem>
|
|
||||||
<SelectItem value="middleware">Middleware</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={resolvedFilter}
|
|
||||||
onValueChange={handleFilterChange(setResolvedFilter)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="All Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="_all">All Status</SelectItem>
|
|
||||||
<SelectItem value="false">Unresolved</SelectItem>
|
|
||||||
<SelectItem value="true">Resolved</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{total > 0 && (
|
|
||||||
<div className="flex items-center text-xs text-muted-foreground self-center ml-auto">
|
|
||||||
{total.toLocaleString()} total errors
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && errors.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Table */}
|
|
||||||
{errors.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-10" />
|
|
||||||
<TableHead className="w-40">Time</TableHead>
|
|
||||||
<TableHead className="w-24">Severity</TableHead>
|
|
||||||
<TableHead className="w-28">Source</TableHead>
|
|
||||||
<TableHead>Error</TableHead>
|
|
||||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{errors.map((err) => (
|
|
||||||
<Fragment key={err.id}>
|
|
||||||
<TableRow
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedId(expandedId === err.id ? null : err.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TableCell className="px-2">
|
|
||||||
{expandedId === err.id ? (
|
|
||||||
<ChevronUp className="size-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
|
||||||
{formatDate(err.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${
|
|
||||||
SEVERITY_COLORS[err.severity] ?? "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{err.severity}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${
|
|
||||||
SOURCE_COLORS[err.source] ?? "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{err.source}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-md">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{err.resolved && (
|
|
||||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="truncate">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{err.error_type}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground ml-2 truncate">
|
|
||||||
{err.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
{!err.resolved && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
disabled={resolveMutation.isPending}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
resolveMutation.mutate(err.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Resolve
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{expandedId === err.id && (
|
|
||||||
<ExpandedRow key={`${err.id}-expanded`} error={err} />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page <= 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
|
||||||
Page {page} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{errors.length === 0 && !isLoading && (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<AlertTriangle className="size-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
No errors found. That's a good sign!
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -8,10 +8,8 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Swords,
|
|
||||||
Trees,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -22,18 +20,10 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useEvents, useEventHistory } from "@/hooks/use-events";
|
import { useEvents, useEventHistory } from "@/hooks/use-events";
|
||||||
import type { ActiveGameEvent, HistoricalEvent } from "@/lib/types";
|
import type { GameEvent } from "@/lib/types";
|
||||||
|
|
||||||
function formatDuration(minutes: number): string {
|
|
||||||
if (minutes < 60) return `${minutes}m`;
|
|
||||||
const h = Math.floor(minutes / 60);
|
|
||||||
const m = minutes % 60;
|
|
||||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
if (isNaN(date.getTime())) return "-";
|
|
||||||
return date.toLocaleDateString([], {
|
return date.toLocaleDateString([], {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
@ -42,86 +32,111 @@ function formatDate(dateStr: string): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTENT_TYPE_STYLES: Record<string, { icon: typeof Swords; color: string }> = {
|
function formatRelativeTime(dateStr: string): string {
|
||||||
monster: { icon: Swords, color: "text-red-400 border-red-500/30" },
|
const now = Date.now();
|
||||||
resource: { icon: Trees, color: "text-green-400 border-green-500/30" },
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diffMs = then - now;
|
||||||
|
|
||||||
|
if (diffMs <= 0) return "Ended";
|
||||||
|
|
||||||
|
const diffS = Math.floor(diffMs / 1000);
|
||||||
|
if (diffS < 60) return `${diffS}s remaining`;
|
||||||
|
const diffM = Math.floor(diffS / 60);
|
||||||
|
if (diffM < 60) return `${diffM}m remaining`;
|
||||||
|
const diffH = Math.floor(diffM / 60);
|
||||||
|
if (diffH < 24) return `${diffH}h ${diffM % 60}m remaining`;
|
||||||
|
return `${Math.floor(diffH / 24)}d ${diffH % 24}h remaining`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventDescription(event: GameEvent): string {
|
||||||
|
if (event.data.description && typeof event.data.description === "string") {
|
||||||
|
return event.data.description;
|
||||||
|
}
|
||||||
|
if (event.data.name && typeof event.data.name === "string") {
|
||||||
|
return event.data.name;
|
||||||
|
}
|
||||||
|
return "Game event";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventLocation(event: GameEvent): string | null {
|
||||||
|
if (event.data.map && typeof event.data.map === "string") {
|
||||||
|
return event.data.map;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.data.x !== undefined &&
|
||||||
|
event.data.y !== undefined
|
||||||
|
) {
|
||||||
|
return `(${event.data.x}, ${event.data.y})`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventExpiry(event: GameEvent): string | null {
|
||||||
|
if (event.data.expiration && typeof event.data.expiration === "string") {
|
||||||
|
return event.data.expiration;
|
||||||
|
}
|
||||||
|
if (event.data.expires_at && typeof event.data.expires_at === "string") {
|
||||||
|
return event.data.expires_at;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||||
|
portal: "text-purple-400 border-purple-500/30",
|
||||||
|
boss: "text-red-400 border-red-500/30",
|
||||||
|
resource: "text-green-400 border-green-500/30",
|
||||||
|
bonus: "text-amber-400 border-amber-500/30",
|
||||||
|
special: "text-cyan-400 border-cyan-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
function ActiveEventCard({ event }: { event: ActiveGameEvent }) {
|
function getEventTypeStyle(type: string): string {
|
||||||
const style = CONTENT_TYPE_STYLES[event.content.type] ?? {
|
return EVENT_TYPE_COLORS[type] ?? "text-muted-foreground border-border";
|
||||||
icon: Sparkles,
|
}
|
||||||
color: "text-muted-foreground border-border",
|
|
||||||
};
|
function ActiveEventCard({ event }: { event: GameEvent }) {
|
||||||
const Icon = style.icon;
|
const location = getEventLocation(event);
|
||||||
const locations = event.maps.slice(0, 3);
|
const expiry = getEventExpiry(event);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="py-4">
|
<Card className="py-4">
|
||||||
<CardContent className="px-4 space-y-2">
|
<CardContent className="px-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="size-4 text-amber-400" />
|
<Sparkles className="size-4 text-amber-400" />
|
||||||
<Badge variant="outline" className={`capitalize ${style.color}`}>
|
<Badge
|
||||||
{event.content.type}
|
variant="outline"
|
||||||
|
className={`capitalize ${getEventTypeStyle(event.type)}`}
|
||||||
|
>
|
||||||
|
{event.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
{expiry && (
|
||||||
<Clock className="size-3" />
|
<div className="flex items-center gap-1 text-xs text-amber-400">
|
||||||
<span>{formatDuration(event.duration)}</span>
|
<Clock className="size-3" />
|
||||||
</div>
|
<span>{formatRelativeTime(expiry)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm font-medium text-foreground">{event.name}</p>
|
<p className="text-sm text-foreground">{getEventDescription(event)}</p>
|
||||||
<p className="text-xs text-muted-foreground capitalize">
|
|
||||||
{event.content.code.replaceAll("_", " ")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{locations.length > 0 && (
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
{location && (
|
||||||
{locations.map((m) => (
|
<div className="flex items-center gap-1">
|
||||||
<div key={m.map_id} className="flex items-center gap-1">
|
<MapPin className="size-3" />
|
||||||
<MapPin className="size-3" />
|
<span>{location}</span>
|
||||||
<span>({m.x}, {m.y})</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
<div className="flex items-center gap-1">
|
||||||
{event.maps.length > 3 && (
|
<CalendarDays className="size-3" />
|
||||||
<span className="text-muted-foreground/60">
|
<span>{formatDate(event.created_at)}</span>
|
||||||
+{event.maps.length - 3} more
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryRow({ event }: { event: HistoricalEvent }) {
|
|
||||||
const location =
|
|
||||||
event.map_x !== undefined && event.map_y !== undefined
|
|
||||||
? `(${event.map_x}, ${event.map_y})`
|
|
||||||
: "-";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{event.event_type}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{event.character_name ?? "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{location}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground text-sm">
|
|
||||||
{formatDate(event.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
const { data: activeEvents, isLoading: loadingActive, error } = useEvents();
|
const { data: activeEvents, isLoading: loadingActive, error } = useEvents();
|
||||||
const { data: historicalEvents, isLoading: loadingHistory } =
|
const { data: historicalEvents, isLoading: loadingHistory } =
|
||||||
|
|
@ -170,8 +185,8 @@ export default function EventsPage() {
|
||||||
|
|
||||||
{activeEvents && activeEvents.length > 0 && (
|
{activeEvents && activeEvents.length > 0 && (
|
||||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{activeEvents.map((event) => (
|
{activeEvents.map((event, idx) => (
|
||||||
<ActiveEventCard key={event.code} event={event} />
|
<ActiveEventCard key={event.id ?? idx} event={event} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -205,14 +220,32 @@ export default function EventsPage() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead>Character</TableHead>
|
<TableHead>Description</TableHead>
|
||||||
<TableHead>Location</TableHead>
|
<TableHead>Location</TableHead>
|
||||||
<TableHead className="text-right">Date</TableHead>
|
<TableHead className="text-right">Date</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sortedHistory.map((event) => (
|
{sortedHistory.map((event, idx) => (
|
||||||
<HistoryRow key={event.id} event={event} />
|
<TableRow key={event.id ?? idx}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`capitalize ${getEventTypeStyle(event.type)}`}
|
||||||
|
>
|
||||||
|
{event.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{getEventDescription(event)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{getEventLocation(event) ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground text-sm">
|
||||||
|
{formatDate(event.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,11 @@ import {
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
History,
|
History,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
User,
|
|
||||||
Plus,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
|
|
@ -31,28 +27,14 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
useExchangeOrders,
|
useExchangeOrders,
|
||||||
useMyOrders,
|
|
||||||
useExchangeHistory,
|
useExchangeHistory,
|
||||||
usePriceHistory,
|
usePriceHistory,
|
||||||
} from "@/hooks/use-exchange";
|
} from "@/hooks/use-exchange";
|
||||||
import { useCharacters } from "@/hooks/use-characters";
|
|
||||||
import { useItems } from "@/hooks/use-game-data";
|
|
||||||
import { PriceChart } from "@/components/exchange/price-chart";
|
import { PriceChart } from "@/components/exchange/price-chart";
|
||||||
import { BuyEquipDialog } from "@/components/exchange/buy-equip-dialog";
|
|
||||||
import { GameIcon } from "@/components/ui/game-icon";
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import { executeAction } from "@/lib/api-client";
|
import type { GEOrder } from "@/lib/types";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { GEOrder, GEHistoryEntry } from "@/lib/types";
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
|
|
@ -69,15 +51,11 @@ function OrdersTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
search,
|
search,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
showAccount,
|
|
||||||
onBuy,
|
|
||||||
}: {
|
}: {
|
||||||
orders: GEOrder[];
|
orders: GEOrder[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
search: string;
|
search: string;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
showAccount?: boolean;
|
|
||||||
onBuy?: (order: GEOrder) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return orders;
|
if (!search.trim()) return orders;
|
||||||
|
|
@ -110,9 +88,7 @@ function OrdersTable({
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead className="text-right">Price</TableHead>
|
<TableHead className="text-right">Price</TableHead>
|
||||||
<TableHead className="text-right">Quantity</TableHead>
|
<TableHead className="text-right">Quantity</TableHead>
|
||||||
{showAccount && <TableHead>Account</TableHead>}
|
|
||||||
<TableHead className="text-right">Created</TableHead>
|
<TableHead className="text-right">Created</TableHead>
|
||||||
{onBuy && <TableHead className="text-right">Actions</TableHead>}
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -142,195 +118,9 @@ function OrdersTable({
|
||||||
<TableCell className="text-right tabular-nums">
|
<TableCell className="text-right tabular-nums">
|
||||||
{order.quantity.toLocaleString()}
|
{order.quantity.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{showAccount && (
|
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{order.account ?? "\u2014"}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell className="text-right text-muted-foreground text-sm">
|
<TableCell className="text-right text-muted-foreground text-sm">
|
||||||
{formatDate(order.created_at)}
|
{formatDate(order.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{onBuy && (
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{order.type === "sell" ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
|
||||||
onClick={() => onBuy(order)}
|
|
||||||
>
|
|
||||||
<ShoppingCart className="size-3" />
|
|
||||||
Buy
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MyOrdersTable({
|
|
||||||
orders,
|
|
||||||
isLoading,
|
|
||||||
selectedCharacter,
|
|
||||||
onCancel,
|
|
||||||
cancelPending,
|
|
||||||
}: {
|
|
||||||
orders: GEOrder[];
|
|
||||||
isLoading: boolean;
|
|
||||||
selectedCharacter: string;
|
|
||||||
onCancel: (orderId: string) => void;
|
|
||||||
cancelPending: string | null;
|
|
||||||
}) {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orders.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<ArrowLeftRight className="size-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
You have no active orders on the Grand Exchange.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Item</TableHead>
|
|
||||||
<TableHead>Type</TableHead>
|
|
||||||
<TableHead className="text-right">Price</TableHead>
|
|
||||||
<TableHead className="text-right">Quantity</TableHead>
|
|
||||||
<TableHead className="text-right">Created</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{orders.map((order) => (
|
|
||||||
<TableRow key={order.id}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<GameIcon type="item" code={order.code} size="sm" />
|
|
||||||
{order.code}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
order.type === "buy"
|
|
||||||
? "text-green-400 border-green-500/30"
|
|
||||||
: "text-red-400 border-red-500/30"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{order.type.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums text-amber-400">
|
|
||||||
{order.price.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
|
||||||
{order.quantity.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground text-sm">
|
|
||||||
{formatDate(order.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
disabled={!selectedCharacter || cancelPending !== null}
|
|
||||||
onClick={() => onCancel(order.id)}
|
|
||||||
>
|
|
||||||
{cancelPending === order.id ? (
|
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<X className="size-3" />
|
|
||||||
)}
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HistoryTable({
|
|
||||||
entries,
|
|
||||||
isLoading,
|
|
||||||
emptyMessage,
|
|
||||||
}: {
|
|
||||||
entries: GEHistoryEntry[];
|
|
||||||
isLoading: boolean;
|
|
||||||
emptyMessage: string;
|
|
||||||
}) {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<History className="size-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Item</TableHead>
|
|
||||||
<TableHead className="text-right">Price</TableHead>
|
|
||||||
<TableHead className="text-right">Quantity</TableHead>
|
|
||||||
<TableHead>Seller</TableHead>
|
|
||||||
<TableHead>Buyer</TableHead>
|
|
||||||
<TableHead className="text-right">Sold At</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<TableRow key={`${entry.order_id}-${entry.sold_at}`}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<GameIcon type="item" code={entry.code} size="sm" />
|
|
||||||
{entry.code}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums text-amber-400">
|
|
||||||
{entry.price.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
|
||||||
{entry.quantity.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{entry.seller}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{entry.buyer}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground text-sm">
|
|
||||||
{formatDate(entry.sold_at)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
@ -340,28 +130,11 @@ function HistoryTable({
|
||||||
|
|
||||||
export default function ExchangePage() {
|
export default function ExchangePage() {
|
||||||
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
|
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
|
||||||
const { data: myOrders, isLoading: loadingMyOrders } = useMyOrders();
|
|
||||||
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
|
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
|
||||||
const { data: characters } = useCharacters();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: items } = useItems();
|
|
||||||
|
|
||||||
const [marketSearch, setMarketSearch] = useState("");
|
const [marketSearch, setMarketSearch] = useState("");
|
||||||
const [priceItemCode, setPriceItemCode] = useState("");
|
const [priceItemCode, setPriceItemCode] = useState("");
|
||||||
const [searchedItem, setSearchedItem] = useState("");
|
const [searchedItem, setSearchedItem] = useState("");
|
||||||
const [selectedCharacter, setSelectedCharacter] = useState("");
|
|
||||||
const [cancelPending, setCancelPending] = useState<string | null>(null);
|
|
||||||
const [buyOrder, setBuyOrder] = useState<GEOrder | null>(null);
|
|
||||||
|
|
||||||
// Buy/Sell form state
|
|
||||||
const [buyCode, setBuyCode] = useState("");
|
|
||||||
const [buyQty, setBuyQty] = useState("1");
|
|
||||||
const [buyPrice, setBuyPrice] = useState("");
|
|
||||||
const [sellCode, setSellCode] = useState("");
|
|
||||||
const [sellQty, setSellQty] = useState("1");
|
|
||||||
const [sellPrice, setSellPrice] = useState("");
|
|
||||||
const [orderPending, setOrderPending] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: priceData,
|
data: priceData,
|
||||||
|
|
@ -374,92 +147,15 @@ export default function ExchangePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancelOrder(orderId: string) {
|
|
||||||
if (!selectedCharacter) {
|
|
||||||
toast.error("Select a character first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCancelPending(orderId);
|
|
||||||
try {
|
|
||||||
await executeAction(selectedCharacter, "ge_cancel", { order_id: orderId });
|
|
||||||
toast.success("Order cancelled");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["exchange"] });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
`Cancel failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setCancelPending(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateOrder(type: "ge_create_buy" | "ge_sell") {
|
|
||||||
if (!selectedCharacter) {
|
|
||||||
toast.error("Select a character first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const code = type === "ge_create_buy" ? buyCode : sellCode;
|
|
||||||
const qty = type === "ge_create_buy" ? buyQty : sellQty;
|
|
||||||
const price = type === "ge_create_buy" ? buyPrice : sellPrice;
|
|
||||||
|
|
||||||
setOrderPending(type);
|
|
||||||
try {
|
|
||||||
await executeAction(selectedCharacter, type, {
|
|
||||||
code,
|
|
||||||
quantity: parseInt(qty, 10) || 1,
|
|
||||||
price: parseInt(price, 10) || 0,
|
|
||||||
});
|
|
||||||
toast.success(`${type === "ge_create_buy" ? "Buy" : "Sell"} order created`);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["exchange"] });
|
|
||||||
// Clear form
|
|
||||||
if (type === "ge_create_buy") {
|
|
||||||
setBuyCode("");
|
|
||||||
setBuyQty("1");
|
|
||||||
setBuyPrice("");
|
|
||||||
} else {
|
|
||||||
setSellCode("");
|
|
||||||
setSellQty("1");
|
|
||||||
setSellPrice("");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
`Order failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setOrderPending(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
Grand Exchange
|
||||||
Grand Exchange
|
</h1>
|
||||||
</h1>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
Browse market orders, track your trades, and analyze price history
|
||||||
Browse market orders, create trades, and analyze price history
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Character selector for actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
Acting as:
|
|
||||||
</Label>
|
|
||||||
<Select value={selectedCharacter} onValueChange={setSelectedCharacter}>
|
|
||||||
<SelectTrigger className="w-44">
|
|
||||||
<SelectValue placeholder="Select character" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(characters ?? []).map((c) => (
|
|
||||||
<SelectItem key={c.name} value={c.name}>
|
|
||||||
{c.name} (Lv.{c.level})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ordersError && (
|
{ordersError && (
|
||||||
|
|
@ -470,188 +166,23 @@ export default function ExchangePage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs defaultValue="trade">
|
<Tabs defaultValue="market">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="trade" className="gap-1.5">
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Trade
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="market" className="gap-1.5">
|
<TabsTrigger value="market" className="gap-1.5">
|
||||||
<ShoppingCart className="size-4" />
|
<ShoppingCart className="size-4" />
|
||||||
Market
|
Market
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="my-orders" className="gap-1.5">
|
|
||||||
<User className="size-4" />
|
|
||||||
My Orders
|
|
||||||
{myOrders && myOrders.length > 0 && (
|
|
||||||
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0">
|
|
||||||
{myOrders.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="history" className="gap-1.5">
|
<TabsTrigger value="history" className="gap-1.5">
|
||||||
<History className="size-4" />
|
<History className="size-4" />
|
||||||
History
|
My Orders
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="prices" className="gap-1.5">
|
<TabsTrigger value="prices" className="gap-1.5">
|
||||||
<TrendingUp className="size-4" />
|
<TrendingUp className="size-4" />
|
||||||
Prices
|
Price History
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Trade Tab - Create buy/sell orders */}
|
{/* Market Tab */}
|
||||||
<TabsContent value="trade" className="space-y-4">
|
|
||||||
{!selectedCharacter && (
|
|
||||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
|
|
||||||
<p className="text-sm text-amber-400">
|
|
||||||
Select a character in the top-right corner to create orders.
|
|
||||||
The character must be at the Grand Exchange tile.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{/* Buy order form */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm text-green-400 flex items-center gap-2">
|
|
||||||
<ShoppingCart className="size-4" />
|
|
||||||
Create Buy Order
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Item Code</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. copper_ore"
|
|
||||||
value={buyCode}
|
|
||||||
onChange={(e) => setBuyCode(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Quantity</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={buyQty}
|
|
||||||
onChange={(e) => setBuyQty(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Price Each</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
placeholder="Gold per unit"
|
|
||||||
value={buyPrice}
|
|
||||||
onChange={(e) => setBuyPrice(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{buyCode && buyPrice && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Total cost:{" "}
|
|
||||||
<span className="text-amber-400 font-medium">
|
|
||||||
{((parseInt(buyQty, 10) || 1) * (parseInt(buyPrice, 10) || 0)).toLocaleString()} gold
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
|
||||||
disabled={
|
|
||||||
!selectedCharacter ||
|
|
||||||
!buyCode.trim() ||
|
|
||||||
!buyPrice ||
|
|
||||||
orderPending !== null
|
|
||||||
}
|
|
||||||
onClick={() => handleCreateOrder("ge_create_buy")}
|
|
||||||
>
|
|
||||||
{orderPending === "ge_create_buy" ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ShoppingCart className="size-4" />
|
|
||||||
)}
|
|
||||||
Place Buy Order
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sell order form */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm text-red-400 flex items-center gap-2">
|
|
||||||
<ShoppingCart className="size-4" />
|
|
||||||
Create Sell Order
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Item Code</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. copper_ore"
|
|
||||||
value={sellCode}
|
|
||||||
onChange={(e) => setSellCode(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Quantity</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={sellQty}
|
|
||||||
onChange={(e) => setSellQty(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Price Each</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
placeholder="Gold per unit"
|
|
||||||
value={sellPrice}
|
|
||||||
onChange={(e) => setSellPrice(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{sellCode && sellPrice && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Total value:{" "}
|
|
||||||
<span className="text-amber-400 font-medium">
|
|
||||||
{((parseInt(sellQty, 10) || 1) * (parseInt(sellPrice, 10) || 0)).toLocaleString()} gold
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
|
||||||
disabled={
|
|
||||||
!selectedCharacter ||
|
|
||||||
!sellCode.trim() ||
|
|
||||||
!sellPrice ||
|
|
||||||
orderPending !== null
|
|
||||||
}
|
|
||||||
onClick={() => handleCreateOrder("ge_sell")}
|
|
||||||
>
|
|
||||||
{orderPending === "ge_sell" ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ShoppingCart className="size-4" />
|
|
||||||
)}
|
|
||||||
Place Sell Order
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Market Tab - Browse all public orders */}
|
|
||||||
<TabsContent value="market" className="space-y-4">
|
<TabsContent value="market" className="space-y-4">
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
|
|
@ -663,21 +194,11 @@ export default function ExchangePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedCharacter && (
|
|
||||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
|
|
||||||
<p className="text-sm text-amber-400">
|
|
||||||
Select a character in the top-right corner to buy items directly from sell orders.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<OrdersTable
|
<OrdersTable
|
||||||
orders={orders ?? []}
|
orders={orders ?? []}
|
||||||
isLoading={loadingOrders}
|
isLoading={loadingOrders}
|
||||||
search={marketSearch}
|
search={marketSearch}
|
||||||
showAccount
|
|
||||||
onBuy={selectedCharacter ? (order) => setBuyOrder(order) : undefined}
|
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
marketSearch.trim()
|
marketSearch.trim()
|
||||||
? `No orders found for "${marketSearch}"`
|
? `No orders found for "${marketSearch}"`
|
||||||
|
|
@ -687,32 +208,13 @@ export default function ExchangePage() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* My Orders Tab - with cancel buttons */}
|
{/* My Orders Tab */}
|
||||||
<TabsContent value="my-orders" className="space-y-4">
|
|
||||||
{!selectedCharacter && (
|
|
||||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
|
|
||||||
<p className="text-sm text-amber-400">
|
|
||||||
Select a character to cancel orders. The character must be at the Grand Exchange.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
<Card>
|
|
||||||
<MyOrdersTable
|
|
||||||
orders={myOrders ?? []}
|
|
||||||
isLoading={loadingMyOrders}
|
|
||||||
selectedCharacter={selectedCharacter}
|
|
||||||
onCancel={handleCancelOrder}
|
|
||||||
cancelPending={cancelPending}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Trade History Tab */}
|
|
||||||
<TabsContent value="history" className="space-y-4">
|
<TabsContent value="history" className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<HistoryTable
|
<OrdersTable
|
||||||
entries={history ?? []}
|
orders={history ?? []}
|
||||||
isLoading={loadingHistory}
|
isLoading={loadingHistory}
|
||||||
|
search=""
|
||||||
emptyMessage="No transaction history found."
|
emptyMessage="No transaction history found."
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -763,13 +265,6 @@ export default function ExchangePage() {
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<BuyEquipDialog
|
|
||||||
order={buyOrder}
|
|
||||||
characterName={selectedCharacter}
|
|
||||||
items={items}
|
|
||||||
onOpenChange={(open) => !open && setBuyOrder(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
ChevronLeft,
|
ChevronDown,
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -31,28 +30,20 @@ import { useCharacters } from "@/hooks/use-characters";
|
||||||
import { useLogs } from "@/hooks/use-analytics";
|
import { useLogs } from "@/hooks/use-analytics";
|
||||||
import type { ActionLog } from "@/lib/types";
|
import type { ActionLog } from "@/lib/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
const ACTION_TYPE_COLORS: Record<string, string> = {
|
const ACTION_TYPE_COLORS: Record<string, string> = {
|
||||||
movement: "bg-blue-500/20 text-blue-400",
|
move: "bg-blue-500/20 text-blue-400",
|
||||||
fight: "bg-red-500/20 text-red-400",
|
fight: "bg-red-500/20 text-red-400",
|
||||||
gathering: "bg-green-500/20 text-green-400",
|
gather: "bg-green-500/20 text-green-400",
|
||||||
rest: "bg-yellow-500/20 text-yellow-400",
|
rest: "bg-yellow-500/20 text-yellow-400",
|
||||||
deposit: "bg-purple-500/20 text-purple-400",
|
deposit: "bg-purple-500/20 text-purple-400",
|
||||||
withdraw: "bg-cyan-500/20 text-cyan-400",
|
withdraw: "bg-cyan-500/20 text-cyan-400",
|
||||||
crafting: "bg-emerald-500/20 text-emerald-400",
|
craft: "bg-emerald-500/20 text-emerald-400",
|
||||||
buy: "bg-teal-500/20 text-teal-400",
|
buy: "bg-teal-500/20 text-teal-400",
|
||||||
sell: "bg-pink-500/20 text-pink-400",
|
sell: "bg-pink-500/20 text-pink-400",
|
||||||
equip: "bg-orange-500/20 text-orange-400",
|
equip: "bg-orange-500/20 text-orange-400",
|
||||||
unequip: "bg-orange-500/20 text-orange-400",
|
unequip: "bg-orange-500/20 text-orange-400",
|
||||||
use: "bg-amber-500/20 text-amber-400",
|
use: "bg-amber-500/20 text-amber-400",
|
||||||
task: "bg-indigo-500/20 text-indigo-400",
|
task: "bg-indigo-500/20 text-indigo-400",
|
||||||
recycling: "bg-lime-500/20 text-lime-400",
|
|
||||||
npc_buy: "bg-teal-500/20 text-teal-400",
|
|
||||||
npc_sell: "bg-pink-500/20 text-pink-400",
|
|
||||||
grandexchange_buy: "bg-teal-500/20 text-teal-400",
|
|
||||||
grandexchange_sell: "bg-pink-500/20 text-pink-400",
|
|
||||||
grandexchange_cancel: "bg-slate-500/20 text-slate-400",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getActionColor(type: string): string {
|
function getActionColor(type: string): string {
|
||||||
|
|
@ -74,47 +65,36 @@ function getDetailsString(log: ActionLog): string {
|
||||||
const d = log.details;
|
const d = log.details;
|
||||||
if (!d || Object.keys(d).length === 0) return "-";
|
if (!d || Object.keys(d).length === 0) return "-";
|
||||||
|
|
||||||
// Use description from the game API if available
|
const parts: string[] = [];
|
||||||
if (d.description && typeof d.description === "string") return d.description;
|
|
||||||
|
|
||||||
if (d.reason && typeof d.reason === "string") return d.reason;
|
if (d.reason && typeof d.reason === "string") return d.reason;
|
||||||
if (d.message && typeof d.message === "string") return d.message;
|
if (d.message && typeof d.message === "string") return d.message;
|
||||||
if (d.error && typeof d.error === "string") return d.error;
|
if (d.error && typeof d.error === "string") return d.error;
|
||||||
if (d.result && typeof d.result === "string") return d.result;
|
if (d.result && typeof d.result === "string") return d.result;
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (d.monster) parts.push(`monster: ${d.monster}`);
|
if (d.monster) parts.push(`monster: ${d.monster}`);
|
||||||
if (d.resource) parts.push(`resource: ${d.resource}`);
|
if (d.resource) parts.push(`resource: ${d.resource}`);
|
||||||
if (d.item) parts.push(`item: ${d.item}`);
|
if (d.item) parts.push(`item: ${d.item}`);
|
||||||
if (d.skill) parts.push(`skill: ${d.skill}`);
|
|
||||||
if (d.map_name) parts.push(`${d.map_name}`);
|
|
||||||
if (d.x !== undefined && d.y !== undefined) parts.push(`(${d.x}, ${d.y})`);
|
if (d.x !== undefined && d.y !== undefined) parts.push(`(${d.x}, ${d.y})`);
|
||||||
if (d.xp) parts.push(`xp: +${d.xp}`);
|
if (d.xp) parts.push(`xp: +${d.xp}`);
|
||||||
if (d.gold) parts.push(`gold: ${d.gold}`);
|
if (d.gold) parts.push(`gold: ${d.gold}`);
|
||||||
if (d.quantity) parts.push(`qty: ${d.quantity}`);
|
if (d.quantity) parts.push(`qty: ${d.quantity}`);
|
||||||
if (Array.isArray(d.drops) && d.drops.length > 0) parts.push(`drops: ${(d.drops as string[]).join(", ")}`);
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts.join(" | ") : JSON.stringify(d);
|
return parts.length > 0 ? parts.join(" | ") : JSON.stringify(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_ACTION_TYPES = [
|
const ALL_ACTION_TYPES = [
|
||||||
"movement",
|
"move",
|
||||||
"fight",
|
"fight",
|
||||||
"gathering",
|
"gather",
|
||||||
"crafting",
|
|
||||||
"recycling",
|
|
||||||
"rest",
|
"rest",
|
||||||
|
"deposit",
|
||||||
|
"withdraw",
|
||||||
|
"craft",
|
||||||
|
"buy",
|
||||||
|
"sell",
|
||||||
"equip",
|
"equip",
|
||||||
"unequip",
|
"unequip",
|
||||||
"use",
|
"use",
|
||||||
"deposit",
|
|
||||||
"withdraw",
|
|
||||||
"buy",
|
|
||||||
"sell",
|
|
||||||
"npc_buy",
|
|
||||||
"npc_sell",
|
|
||||||
"grandexchange_buy",
|
|
||||||
"grandexchange_sell",
|
|
||||||
"task",
|
"task",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -122,25 +102,28 @@ export default function LogsPage() {
|
||||||
const { data: characters } = useCharacters();
|
const { data: characters } = useCharacters();
|
||||||
const [characterFilter, setCharacterFilter] = useState("_all");
|
const [characterFilter, setCharacterFilter] = useState("_all");
|
||||||
const [actionFilter, setActionFilter] = useState("_all");
|
const [actionFilter, setActionFilter] = useState("_all");
|
||||||
const [page, setPage] = useState(1);
|
const [visibleCount, setVisibleCount] = useState(50);
|
||||||
|
|
||||||
const { data, isLoading, error } = useLogs({
|
const { data: logs, isLoading, error } = useLogs({
|
||||||
character: characterFilter === "_all" ? undefined : characterFilter,
|
character: characterFilter === "_all" ? undefined : characterFilter,
|
||||||
type: actionFilter === "_all" ? undefined : actionFilter,
|
|
||||||
page,
|
|
||||||
size: PAGE_SIZE,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs = data?.logs ?? [];
|
const filteredLogs = useMemo(() => {
|
||||||
const totalPages = data?.pages ?? 1;
|
let items = logs ?? [];
|
||||||
const total = data?.total ?? 0;
|
|
||||||
|
|
||||||
function handleFilterChange(setter: (v: string) => void) {
|
if (actionFilter !== "_all") {
|
||||||
return (value: string) => {
|
items = items.filter((log) => log.action_type === actionFilter);
|
||||||
setter(value);
|
}
|
||||||
setPage(1);
|
|
||||||
};
|
// Sort by created_at descending
|
||||||
}
|
return [...items].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
);
|
||||||
|
}, [logs, actionFilter]);
|
||||||
|
|
||||||
|
const visibleLogs = filteredLogs.slice(0, visibleCount);
|
||||||
|
const hasMore = visibleCount < filteredLogs.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -149,7 +132,7 @@ export default function LogsPage() {
|
||||||
Action Logs
|
Action Logs
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Live action history from the game server
|
View detailed action logs across all characters
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -163,7 +146,7 @@ export default function LogsPage() {
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Select value={characterFilter} onValueChange={handleFilterChange(setCharacterFilter)}>
|
<Select value={characterFilter} onValueChange={setCharacterFilter}>
|
||||||
<SelectTrigger className="w-48">
|
<SelectTrigger className="w-48">
|
||||||
<SelectValue placeholder="All Characters" />
|
<SelectValue placeholder="All Characters" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -177,7 +160,7 @@ export default function LogsPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={actionFilter} onValueChange={handleFilterChange(setActionFilter)}>
|
<Select value={actionFilter} onValueChange={setActionFilter}>
|
||||||
<SelectTrigger className="w-48">
|
<SelectTrigger className="w-48">
|
||||||
<SelectValue placeholder="All Actions" />
|
<SelectValue placeholder="All Actions" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -185,41 +168,41 @@ export default function LogsPage() {
|
||||||
<SelectItem value="_all">All Actions</SelectItem>
|
<SelectItem value="_all">All Actions</SelectItem>
|
||||||
{ALL_ACTION_TYPES.map((type) => (
|
{ALL_ACTION_TYPES.map((type) => (
|
||||||
<SelectItem key={type} value={type}>
|
<SelectItem key={type} value={type}>
|
||||||
<span className="capitalize">{type.replace(/_/g, " ")}</span>
|
<span className="capitalize">{type}</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{total > 0 && (
|
{filteredLogs.length > 0 && (
|
||||||
<div className="flex items-center text-xs text-muted-foreground self-center ml-auto">
|
<div className="flex items-center text-xs text-muted-foreground self-center ml-auto">
|
||||||
{total.toLocaleString()} total entries
|
Showing {visibleLogs.length} of {filteredLogs.length} entries
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && logs.length === 0 && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Log Table */}
|
{/* Log Table */}
|
||||||
{logs.length > 0 && (
|
{visibleLogs.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-40">Time</TableHead>
|
<TableHead className="w-40">Time</TableHead>
|
||||||
<TableHead className="w-32">Character</TableHead>
|
<TableHead className="w-32">Character</TableHead>
|
||||||
<TableHead className="w-28">Action</TableHead>
|
<TableHead className="w-24">Action</TableHead>
|
||||||
<TableHead>Details</TableHead>
|
<TableHead>Details</TableHead>
|
||||||
<TableHead className="w-16 text-center">Status</TableHead>
|
<TableHead className="w-16 text-center">Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{logs.map((log, idx) => (
|
{visibleLogs.map((log) => (
|
||||||
<TableRow key={`${log.id}-${idx}`}>
|
<TableRow key={log.id}>
|
||||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||||
{formatDate(log.created_at)}
|
{formatDate(log.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -231,7 +214,7 @@ export default function LogsPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${getActionColor(log.action_type)}`}
|
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${getActionColor(log.action_type)}`}
|
||||||
>
|
>
|
||||||
{log.action_type.replace(/_/g, " ")}
|
{log.action_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground max-w-md truncate">
|
<TableCell className="text-sm text-muted-foreground max-w-md truncate">
|
||||||
|
|
@ -251,39 +234,26 @@ export default function LogsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Load More */}
|
||||||
{totalPages > 1 && (
|
{hasMore && (
|
||||||
<div className="flex items-center justify-center gap-4">
|
<div className="flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
onClick={() => setVisibleCount((c) => c + 50)}
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page <= 1}
|
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronDown className="size-4" />
|
||||||
Previous
|
Load More
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
|
||||||
Page {page} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{logs.length === 0 && !isLoading && (
|
{filteredLogs.length === 0 && !isLoading && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<ScrollText className="size-12 text-muted-foreground mx-auto mb-4" />
|
<ScrollText className="size-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
No log entries found. Perform actions in the game to generate logs.
|
No log entries found. Actions performed by characters or automations
|
||||||
|
will appear here.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ const CONTENT_COLORS: Record<string, string> = {
|
||||||
|
|
||||||
const EMPTY_COLOR = "#1f2937";
|
const EMPTY_COLOR = "#1f2937";
|
||||||
const CHARACTER_COLOR = "#facc15";
|
const CHARACTER_COLOR = "#facc15";
|
||||||
const BASE_CELL_SIZE = 40;
|
const GRID_LINE_COLOR = "#374151";
|
||||||
|
const BASE_CELL_SIZE = 18;
|
||||||
const IMAGE_BASE = "https://artifactsmmo.com/images";
|
const IMAGE_BASE = "https://artifactsmmo.com/images";
|
||||||
|
|
||||||
const LEGEND_ITEMS = [
|
const LEGEND_ITEMS = [
|
||||||
|
|
@ -82,8 +83,7 @@ function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
if (cached) return Promise.resolve(cached);
|
if (cached) return Promise.resolve(cached);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
// Note: do NOT set crossOrigin – the CDN doesn't send CORS headers,
|
img.crossOrigin = "anonymous";
|
||||||
// and we only need to draw the images on canvas (not read pixel data).
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageCache.set(url, img);
|
imageCache.set(url, img);
|
||||||
resolve(img);
|
resolve(img);
|
||||||
|
|
@ -147,7 +147,7 @@ export default function MapPage() {
|
||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
const dragDistRef = useRef(0);
|
const dragDistRef = useRef(0);
|
||||||
|
|
||||||
const [zoom, setZoom] = useState(0.5);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
|
|
@ -295,15 +295,14 @@ export default function MapPage() {
|
||||||
if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height)
|
if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Draw skin image – seamless tiles, no gaps
|
// Try to draw skin image
|
||||||
const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null;
|
const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null;
|
||||||
|
|
||||||
if (skinImg) {
|
if (skinImg) {
|
||||||
ctx.drawImage(skinImg, px, py, cellSize, cellSize);
|
ctx.drawImage(skinImg, px, py, cellSize, cellSize);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: dark ground color for tiles without skin images
|
ctx.fillStyle = getTileColor(tile);
|
||||||
ctx.fillStyle = EMPTY_COLOR;
|
ctx.fillRect(px, py, cellSize - 1, cellSize - 1);
|
||||||
ctx.fillRect(px, py, cellSize, cellSize);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content icon overlay
|
// Content icon overlay
|
||||||
|
|
@ -316,79 +315,70 @@ export default function MapPage() {
|
||||||
const iconSize = cellSize * 0.6;
|
const iconSize = cellSize * 0.6;
|
||||||
const iconX = px + (cellSize - iconSize) / 2;
|
const iconX = px + (cellSize - iconSize) / 2;
|
||||||
const iconY = py + (cellSize - iconSize) / 2;
|
const iconY = py + (cellSize - iconSize) / 2;
|
||||||
// Drop shadow for icon readability
|
|
||||||
ctx.shadowColor = "rgba(0,0,0,0.5)";
|
|
||||||
ctx.shadowBlur = 3;
|
|
||||||
ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize);
|
ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize);
|
||||||
ctx.shadowColor = "transparent";
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!skinImg) {
|
||||||
// For bank/workshop/etc – small colored badge in bottom-right
|
// For bank/workshop/etc without skin, draw a colored indicator dot
|
||||||
const badgeSize = Math.max(6, cellSize * 0.25);
|
const dotRadius = Math.max(2, cellSize * 0.15);
|
||||||
const badgeX = px + cellSize - badgeSize - 2;
|
|
||||||
const badgeY = py + cellSize - badgeSize - 2;
|
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2 + 1, 0, Math.PI * 2);
|
ctx.arc(
|
||||||
ctx.fill();
|
px + cellSize / 2,
|
||||||
|
py + cellSize / 2,
|
||||||
|
dotRadius,
|
||||||
|
0,
|
||||||
|
Math.PI * 2
|
||||||
|
);
|
||||||
ctx.fillStyle = CONTENT_COLORS[tile.content.type] ?? "#fff";
|
ctx.fillStyle = CONTENT_COLORS[tile.content.type] ?? "#fff";
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(badgeX + badgeSize / 2, badgeY + badgeSize / 2, badgeSize / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selected tile highlight – bright outline
|
// Grid lines (subtler when images are shown)
|
||||||
|
if (cellSize > 10) {
|
||||||
|
ctx.strokeStyle = skinImg
|
||||||
|
? "rgba(55, 65, 81, 0.3)"
|
||||||
|
: GRID_LINE_COLOR;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.strokeRect(px, py, cellSize - 1, cellSize - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected tile highlight
|
||||||
if (
|
if (
|
||||||
selectedTile &&
|
selectedTile &&
|
||||||
tile.x === selectedTile.tile.x &&
|
tile.x === selectedTile.tile.x &&
|
||||||
tile.y === selectedTile.tile.y
|
tile.y === selectedTile.tile.y
|
||||||
) {
|
) {
|
||||||
ctx.strokeStyle = "#3b82f6";
|
ctx.strokeStyle = "#3b82f6";
|
||||||
ctx.lineWidth = 2.5;
|
ctx.lineWidth = 2;
|
||||||
ctx.strokeRect(px + 1, py + 1, cellSize - 2, cellSize - 2);
|
ctx.strokeRect(px + 1, py + 1, cellSize - 3, cellSize - 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coordinate text at high zoom – text shadow for readability on tile images
|
// Coordinate text at high zoom
|
||||||
if (cellSize >= 60) {
|
if (cellSize >= 40) {
|
||||||
const fontSize = Math.max(8, cellSize * 0.18);
|
ctx.fillStyle = "rgba(255,255,255,0.6)";
|
||||||
ctx.font = `${fontSize}px sans-serif`;
|
ctx.font = `${Math.max(8, cellSize * 0.2)}px sans-serif`;
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
ctx.textBaseline = "top";
|
ctx.textBaseline = "top";
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
|
||||||
ctx.fillText(`${tile.x},${tile.y}`, px + 3, py + 3);
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.75)";
|
|
||||||
ctx.fillText(`${tile.x},${tile.y}`, px + 2, py + 2);
|
ctx.fillText(`${tile.x},${tile.y}`, px + 2, py + 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tile labels at very high zoom
|
// Tile labels at very high zoom
|
||||||
if (cellSize >= 80 && (tile.name || tile.content?.code)) {
|
if (cellSize >= 50 && (tile.name || tile.content?.code)) {
|
||||||
const nameSize = Math.max(9, cellSize * 0.16);
|
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
||||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
ctx.font = `bold ${Math.max(9, cellSize * 0.18)}px sans-serif`;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "bottom";
|
ctx.textBaseline = "bottom";
|
||||||
if (tile.name) {
|
if (tile.name) {
|
||||||
// Text with shadow
|
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
|
||||||
ctx.fillText(tile.name, px + cellSize / 2 + 1, py + cellSize - 1);
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.9)";
|
|
||||||
ctx.fillText(tile.name, px + cellSize / 2, py + cellSize - 2);
|
ctx.fillText(tile.name, px + cellSize / 2, py + cellSize - 2);
|
||||||
}
|
}
|
||||||
if (tile.content?.code) {
|
if (tile.content?.code) {
|
||||||
const codeSize = Math.max(8, cellSize * 0.13);
|
ctx.font = `${Math.max(8, cellSize * 0.15)}px sans-serif`;
|
||||||
ctx.font = `${codeSize}px sans-serif`;
|
ctx.fillStyle = "rgba(255,255,255,0.65)";
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
ctx.textBaseline = "bottom";
|
||||||
ctx.fillText(
|
|
||||||
tile.content.code,
|
|
||||||
px + cellSize / 2 + 1,
|
|
||||||
py + cellSize - 1 - Math.max(10, cellSize * 0.18)
|
|
||||||
);
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.75)";
|
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
tile.content.code,
|
tile.content.code,
|
||||||
px + cellSize / 2,
|
px + cellSize / 2,
|
||||||
py + cellSize - 2 - Math.max(10, cellSize * 0.18)
|
py + cellSize - 2 - Math.max(10, cellSize * 0.2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -422,15 +412,12 @@ export default function MapPage() {
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Label with shadow for readability on tile images
|
// Label
|
||||||
if (cellSize >= 14) {
|
if (cellSize >= 14) {
|
||||||
const labelFont = `bold ${Math.max(9, cellSize * 0.4)}px sans-serif`;
|
ctx.fillStyle = "#fff";
|
||||||
ctx.font = labelFont;
|
ctx.font = `${Math.max(9, cellSize * 0.5)}px sans-serif`;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "bottom";
|
ctx.textBaseline = "bottom";
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
|
||||||
ctx.fillText(char.name, px + 1, py - radius - 2);
|
|
||||||
ctx.fillStyle = "#fff";
|
|
||||||
ctx.fillText(char.name, px, py - radius - 3);
|
ctx.fillText(char.name, px, py - radius - 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -746,7 +733,7 @@ export default function MapPage() {
|
||||||
const mouseY = e.clientY - rect.top;
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
const newZoom = Math.max(0.1, Math.min(5, zoom * factor));
|
const newZoom = Math.max(0.3, Math.min(5, zoom * factor));
|
||||||
const scale = newZoom / zoom;
|
const scale = newZoom / zoom;
|
||||||
|
|
||||||
// Adjust offset so the point under cursor stays fixed
|
// Adjust offset so the point under cursor stays fixed
|
||||||
|
|
@ -758,7 +745,7 @@ export default function MapPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetView() {
|
function resetView() {
|
||||||
setZoom(0.5);
|
setZoom(1);
|
||||||
setOffset({ x: 0, y: 0 });
|
setOffset({ x: 0, y: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -791,7 +778,7 @@ export default function MapPage() {
|
||||||
break;
|
break;
|
||||||
case "-":
|
case "-":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setZoom((z) => Math.max(0.1, z * 0.87));
|
setZoom((z) => Math.max(0.3, z * 0.87));
|
||||||
break;
|
break;
|
||||||
case "0":
|
case "0":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -978,7 +965,7 @@ export default function MapPage() {
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setZoom((z) => Math.max(0.1, z * 0.83))}
|
onClick={() => setZoom((z) => Math.max(0.3, z * 0.83))}
|
||||||
>
|
>
|
||||||
<Minus className="size-4" />
|
<Minus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
|
getAuthStatus,
|
||||||
setAuthToken,
|
setAuthToken,
|
||||||
|
clearAuthToken,
|
||||||
type AuthStatus,
|
type AuthStatus,
|
||||||
} from "@/lib/api-client";
|
} from "@/lib/api-client";
|
||||||
|
|
||||||
|
|
@ -34,30 +35,45 @@ export function useAuth() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [status, setStatus] = useState<AuthStatus | null>(null);
|
const [status, setStatus] = useState<AuthStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Gate is based entirely on localStorage — each browser has its own token.
|
const checkStatus = useCallback(async () => {
|
||||||
useEffect(() => {
|
try {
|
||||||
const savedToken = localStorage.getItem(STORAGE_KEY);
|
const s = await getAuthStatus();
|
||||||
if (savedToken) {
|
setStatus(s);
|
||||||
setStatus({ has_token: true, source: "user" });
|
|
||||||
} else {
|
// If backend has no token but we have one in localStorage, auto-restore it
|
||||||
|
if (!s.has_token) {
|
||||||
|
const savedToken = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (savedToken) {
|
||||||
|
const result = await setAuthToken(savedToken);
|
||||||
|
if (result.success) {
|
||||||
|
setStatus({ has_token: true, source: "user" });
|
||||||
|
} else {
|
||||||
|
// Token is stale, remove it
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Backend unreachable — show the gate anyway
|
||||||
setStatus({ has_token: false, source: "none" });
|
setStatus({ has_token: false, source: "none" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkStatus();
|
||||||
|
}, [checkStatus]);
|
||||||
|
|
||||||
const handleSetToken = useCallback(
|
const handleSetToken = useCallback(
|
||||||
async (token: string): Promise<{ success: boolean; error?: string }> => {
|
async (token: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
try {
|
try {
|
||||||
// Validate the token with the backend (it won't store it)
|
|
||||||
const result = await setAuthToken(token);
|
const result = await setAuthToken(token);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
localStorage.setItem(STORAGE_KEY, token);
|
localStorage.setItem(STORAGE_KEY, token);
|
||||||
// Clear all cached data so the new user gets fresh data
|
|
||||||
queryClient.clear();
|
|
||||||
setStatus({ has_token: true, source: "user" });
|
setStatus({ has_token: true, source: "user" });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
@ -69,14 +85,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queryClient]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveToken = useCallback(async () => {
|
const handleRemoveToken = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await clearAuthToken();
|
||||||
|
setStatus(s);
|
||||||
|
} catch {
|
||||||
|
setStatus({ has_token: false, source: "none" });
|
||||||
|
}
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
queryClient.clear();
|
}, []);
|
||||||
setStatus({ has_token: false, source: "none" });
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { X, Loader2 } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -9,44 +7,16 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
||||||
import type { Character } from "@/lib/types";
|
import type { Character } from "@/lib/types";
|
||||||
import { GameIcon } from "@/components/ui/game-icon";
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { executeAction } from "@/lib/api-client";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface EquipmentGridProps {
|
interface EquipmentGridProps {
|
||||||
character: Character;
|
character: Character;
|
||||||
onActionComplete?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EquipmentGrid({ character, onActionComplete }: EquipmentGridProps) {
|
export function EquipmentGrid({ character }: EquipmentGridProps) {
|
||||||
const [pendingSlot, setPendingSlot] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleUnequip(slotKey: string) {
|
|
||||||
const slot = slotKey.replace("_slot", "");
|
|
||||||
setPendingSlot(slotKey);
|
|
||||||
try {
|
|
||||||
await executeAction(character.name, "unequip", { slot });
|
|
||||||
toast.success(`Unequipped ${slot}`);
|
|
||||||
onActionComplete?.();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
`Unequip failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setPendingSlot(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|
@ -57,8 +27,8 @@ export function EquipmentGrid({ character, onActionComplete }: EquipmentGridProp
|
||||||
{EQUIPMENT_SLOTS.map((slot) => {
|
{EQUIPMENT_SLOTS.map((slot) => {
|
||||||
const itemCode = character[slot.key as keyof Character] as string;
|
const itemCode = character[slot.key as keyof Character] as string;
|
||||||
const isEmpty = !itemCode;
|
const isEmpty = !itemCode;
|
||||||
const isPending = pendingSlot === slot.key;
|
|
||||||
|
|
||||||
|
// Show quantity for utility slots
|
||||||
let quantity: number | null = null;
|
let quantity: number | null = null;
|
||||||
if (slot.key === "utility1_slot" && itemCode) {
|
if (slot.key === "utility1_slot" && itemCode) {
|
||||||
quantity = character.utility1_slot_quantity;
|
quantity = character.utility1_slot_quantity;
|
||||||
|
|
@ -70,10 +40,10 @@ export function EquipmentGrid({ character, onActionComplete }: EquipmentGridProp
|
||||||
<div
|
<div
|
||||||
key={slot.key}
|
key={slot.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex flex-col gap-1 rounded-lg border p-2.5 transition-colors",
|
"flex flex-col gap-1 rounded-lg border p-2.5 transition-colors",
|
||||||
isEmpty
|
isEmpty
|
||||||
? "border-dashed border-border/50 bg-transparent"
|
? "border-dashed border-border/50 bg-transparent"
|
||||||
: "border-border bg-accent/30 hover:bg-accent/50"
|
: "border-border bg-accent/30"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
|
@ -99,32 +69,6 @@ export function EquipmentGrid({ character, onActionComplete }: EquipmentGridProp
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unequip button - shown on hover */}
|
|
||||||
{!isEmpty && (
|
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-1 right-1 size-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/20 hover:text-destructive"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => handleUnequip(slot.key)}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<X className="size-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
<p>Unequip {slot.label}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Shield,
|
|
||||||
FlaskConical,
|
|
||||||
Landmark,
|
|
||||||
Recycle,
|
|
||||||
Loader2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Package,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -19,64 +8,26 @@ import {
|
||||||
CardDescription,
|
CardDescription,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { GameIcon } from "@/components/ui/game-icon";
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
import type { Character } from "@/lib/types";
|
||||||
import type { Character, InventorySlot } from "@/lib/types";
|
|
||||||
import { executeAction } from "@/lib/api-client";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface InventoryGridProps {
|
interface InventoryGridProps {
|
||||||
character: Character;
|
character: Character;
|
||||||
onActionComplete?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InventoryGrid({ character, onActionComplete }: InventoryGridProps) {
|
export function InventoryGrid({ character }: InventoryGridProps) {
|
||||||
const [pendingSlot, setPendingSlot] = useState<number | null>(null);
|
const usedSlots = character.inventory.filter(
|
||||||
const [showAllSlots, setShowAllSlots] = useState(false);
|
|
||||||
|
|
||||||
const filledItems = character.inventory.filter(
|
|
||||||
(slot) => slot.code && slot.code !== ""
|
(slot) => slot.code && slot.code !== ""
|
||||||
);
|
).length;
|
||||||
const usedSlots = filledItems.length;
|
|
||||||
const totalSlots = character.inventory_max_items;
|
const totalSlots = character.inventory_max_items;
|
||||||
const emptySlots = totalSlots - usedSlots;
|
|
||||||
|
|
||||||
async function handleAction(
|
// Build a map from slot number to inventory item
|
||||||
slotNum: number,
|
const slotMap = new Map(
|
||||||
action: string,
|
character.inventory
|
||||||
params: Record<string, unknown>
|
.filter((slot) => slot.code && slot.code !== "")
|
||||||
) {
|
.map((slot) => [slot.slot, slot])
|
||||||
setPendingSlot(slotNum);
|
);
|
||||||
try {
|
|
||||||
await executeAction(character.name, action, params);
|
|
||||||
toast.success(`${action} successful`);
|
|
||||||
onActionComplete?.();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(
|
|
||||||
`${action} failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setPendingSlot(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const equipSlots = EQUIPMENT_SLOTS.map((s) => ({
|
|
||||||
key: s.key.replace("_slot", ""),
|
|
||||||
label: s.label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -88,223 +39,40 @@ export function InventoryGrid({ character, onActionComplete }: InventoryGridProp
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{emptySlots} slots available
|
{totalSlots - usedSlots} slots available
|
||||||
{usedSlots > 0 && " \u00b7 Click items for actions"}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{usedSlots === 0 ? (
|
<div className="grid grid-cols-5 sm:grid-cols-6 md:grid-cols-5 lg:grid-cols-6 gap-1.5">
|
||||||
<div className="flex items-center gap-2 py-4 justify-center text-sm text-muted-foreground">
|
{Array.from({ length: totalSlots }).map((_, index) => {
|
||||||
<Package className="size-4" />
|
const item = slotMap.get(index + 1);
|
||||||
Inventory is empty
|
const isEmpty = !item;
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-5 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-1.5">
|
|
||||||
{filledItems.map((item) => (
|
|
||||||
<InventoryItemSlot
|
|
||||||
key={item.slot}
|
|
||||||
item={item}
|
|
||||||
isPending={pendingSlot === item.slot}
|
|
||||||
equipSlots={equipSlots}
|
|
||||||
characterName={character.name}
|
|
||||||
onAction={(action, params) =>
|
|
||||||
handleAction(item.slot, action, params)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Expandable full grid with all slots */}
|
return (
|
||||||
{emptySlots > 0 && (
|
<div
|
||||||
<div className="mt-2">
|
key={index}
|
||||||
<Button
|
className={cn(
|
||||||
variant="ghost"
|
"flex flex-col items-center justify-center rounded-md border p-1.5 aspect-square min-h-[48px]",
|
||||||
size="sm"
|
isEmpty
|
||||||
className="w-full text-xs text-muted-foreground h-7"
|
? "border-dashed border-border/40"
|
||||||
onClick={() => setShowAllSlots(!showAllSlots)}
|
: "border-border bg-accent/30"
|
||||||
>
|
)}
|
||||||
{showAllSlots ? (
|
>
|
||||||
<>
|
{item && (
|
||||||
<ChevronUp className="size-3 mr-1" />
|
<div className="relative flex items-center justify-center">
|
||||||
Hide empty slots
|
<GameIcon type="item" code={item.code} size="md" />
|
||||||
</>
|
{item.quantity > 1 && (
|
||||||
) : (
|
<span className="absolute -bottom-0.5 -right-0.5 rounded bg-background/80 px-0.5 text-[9px] font-medium text-muted-foreground leading-tight">
|
||||||
<>
|
{item.quantity}
|
||||||
<ChevronDown className="size-3 mr-1" />
|
</span>
|
||||||
Show all {totalSlots} slots ({emptySlots} empty)
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
|
|
||||||
{showAllSlots && (
|
|
||||||
<div className="grid grid-cols-8 sm:grid-cols-10 md:grid-cols-12 lg:grid-cols-14 gap-1 mt-2">
|
|
||||||
{Array.from({ length: totalSlots }).map((_, index) => {
|
|
||||||
const slotNum = index + 1;
|
|
||||||
const item = filledItems.find((i) => i.slot === slotNum);
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="rounded border border-dashed border-border/30 aspect-square min-h-[32px]"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InventoryItemSlot
|
|
||||||
key={index}
|
|
||||||
item={item}
|
|
||||||
isPending={pendingSlot === slotNum}
|
|
||||||
equipSlots={equipSlots}
|
|
||||||
characterName={character.name}
|
|
||||||
onAction={(action, params) =>
|
|
||||||
handleAction(item.slot, action, params)
|
|
||||||
}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InventoryItemSlot({
|
|
||||||
item,
|
|
||||||
isPending,
|
|
||||||
equipSlots,
|
|
||||||
onAction,
|
|
||||||
compact,
|
|
||||||
}: {
|
|
||||||
item: InventorySlot;
|
|
||||||
isPending: boolean;
|
|
||||||
equipSlots: { key: string; label: string }[];
|
|
||||||
characterName: string;
|
|
||||||
onAction: (action: string, params: Record<string, unknown>) => void;
|
|
||||||
compact?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-center justify-center rounded-md border transition-colors cursor-pointer",
|
|
||||||
"border-border bg-accent/30 hover:bg-accent/60 hover:border-primary/40",
|
|
||||||
isPending && "opacity-50 pointer-events-none",
|
|
||||||
compact
|
|
||||||
? "p-0.5 aspect-square min-h-[32px]"
|
|
||||||
: "p-1.5 aspect-square min-h-[48px]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2
|
|
||||||
className={cn(
|
|
||||||
"animate-spin text-muted-foreground",
|
|
||||||
compact ? "size-3" : "size-5"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="relative flex items-center justify-center">
|
|
||||||
<GameIcon
|
|
||||||
type="item"
|
|
||||||
code={item.code}
|
|
||||||
size={compact ? "sm" : "md"}
|
|
||||||
/>
|
|
||||||
{item.quantity > 1 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute -bottom-0.5 -right-0.5 rounded bg-background/80 font-medium text-muted-foreground leading-tight",
|
|
||||||
compact ? "px-0.5 text-[7px]" : "px-0.5 text-[9px]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.quantity}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-48">
|
|
||||||
<DropdownMenuLabel className="flex items-center gap-2">
|
|
||||||
<GameIcon type="item" code={item.code} size="sm" />
|
|
||||||
{item.code}
|
|
||||||
{item.quantity > 1 && (
|
|
||||||
<span className="text-muted-foreground font-normal">
|
|
||||||
x{item.quantity}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{/* Equip - submenu with slot selection */}
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger>
|
|
||||||
<Shield className="size-4 text-blue-400" />
|
|
||||||
Equip
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
{equipSlots.map((slot) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={slot.key}
|
|
||||||
onClick={() =>
|
|
||||||
onAction("equip", { code: item.code, slot: slot.key })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{slot.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
|
|
||||||
{/* Use item */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onAction("use_item", { code: item.code, quantity: 1 })}
|
|
||||||
>
|
|
||||||
<FlaskConical className="size-4 text-green-400" />
|
|
||||||
Use
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{/* Deposit to bank */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
onAction("deposit", { code: item.code, quantity: item.quantity })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Landmark className="size-4 text-purple-400" />
|
|
||||||
Deposit All ({item.quantity})
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{item.quantity > 1 && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
onAction("deposit", { code: item.code, quantity: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Landmark className="size-4 text-purple-400" />
|
|
||||||
Deposit 1
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{/* Recycle */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
onAction("recycle", { code: item.code, quantity: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Recycle className="size-4 text-orange-400" />
|
|
||||||
Recycle
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Loader2, ShoppingCart, Shield, CheckCircle2 } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { GameIcon } from "@/components/ui/game-icon";
|
|
||||||
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
|
||||||
import { executeAction } from "@/lib/api-client";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { GEOrder, Item } from "@/lib/types";
|
|
||||||
|
|
||||||
// Map item type → suggested equipment slot key (without _slot suffix)
|
|
||||||
const TYPE_TO_SLOT: Record<string, string> = {
|
|
||||||
weapon: "weapon",
|
|
||||||
shield: "shield",
|
|
||||||
helmet: "helmet",
|
|
||||||
body_armor: "body_armor",
|
|
||||||
leg_armor: "leg_armor",
|
|
||||||
boots: "boots",
|
|
||||||
ring: "ring1",
|
|
||||||
amulet: "amulet",
|
|
||||||
artifact: "artifact1",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BuyEquipDialogProps {
|
|
||||||
order: GEOrder | null;
|
|
||||||
characterName: string;
|
|
||||||
items?: Item[];
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step = "confirm" | "buying" | "bought" | "equipping" | "equipped";
|
|
||||||
|
|
||||||
export function BuyEquipDialog({
|
|
||||||
order,
|
|
||||||
characterName,
|
|
||||||
items,
|
|
||||||
onOpenChange,
|
|
||||||
}: BuyEquipDialogProps) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [step, setStep] = useState<Step>("confirm");
|
|
||||||
const [quantity, setQuantity] = useState("1");
|
|
||||||
const [equipSlot, setEquipSlot] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!order) return null;
|
|
||||||
|
|
||||||
const itemInfo = items?.find((i) => i.code === order.code);
|
|
||||||
const isEquippable = itemInfo
|
|
||||||
? Object.keys(TYPE_TO_SLOT).includes(itemInfo.type)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const maxQty = order.quantity;
|
|
||||||
const qty = Math.max(1, Math.min(parseInt(quantity, 10) || 1, maxQty));
|
|
||||||
const totalCost = qty * order.price;
|
|
||||||
|
|
||||||
// Auto-suggest slot based on item type
|
|
||||||
const suggestedSlot = itemInfo ? TYPE_TO_SLOT[itemInfo.type] ?? "" : "";
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
setStep("confirm");
|
|
||||||
setQuantity("1");
|
|
||||||
setEquipSlot("");
|
|
||||||
setError(null);
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBuy() {
|
|
||||||
setStep("buying");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await executeAction(characterName, "ge_buy", {
|
|
||||||
id: order!.id,
|
|
||||||
quantity: qty,
|
|
||||||
});
|
|
||||||
toast.success(`Bought ${qty}x ${order!.code}`);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["exchange"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["character", characterName] });
|
|
||||||
setStep("bought");
|
|
||||||
|
|
||||||
// Pre-select suggested slot
|
|
||||||
if (suggestedSlot && !equipSlot) {
|
|
||||||
setEquipSlot(suggestedSlot);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Buy failed");
|
|
||||||
setStep("confirm");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEquip() {
|
|
||||||
if (!equipSlot) {
|
|
||||||
toast.error("Select an equipment slot");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStep("equipping");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await executeAction(characterName, "equip", {
|
|
||||||
code: order!.code,
|
|
||||||
slot: equipSlot,
|
|
||||||
});
|
|
||||||
toast.success(`Equipped ${order!.code} to ${equipSlot}`);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["character", characterName] });
|
|
||||||
setStep("equipped");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Equip failed");
|
|
||||||
setStep("bought");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const equipSlots = EQUIPMENT_SLOTS.map((s) => ({
|
|
||||||
key: s.key.replace("_slot", ""),
|
|
||||||
label: s.label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={!!order} onOpenChange={(open) => !open && handleClose()}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<GameIcon type="item" code={order.code} size="md" showTooltip={false} />
|
|
||||||
{step === "equipped" ? "Equipped!" : step === "bought" ? "Purchased!" : `Buy ${order.code}`}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{step === "confirm" && "Place a buy order to match this sell listing."}
|
|
||||||
{step === "buying" && "Placing buy order..."}
|
|
||||||
{step === "bought" && (isEquippable
|
|
||||||
? "Item purchased! You can now equip it."
|
|
||||||
: "Item purchased successfully!")}
|
|
||||||
{step === "equipping" && "Equipping item..."}
|
|
||||||
{step === "equipped" && "Item equipped successfully!"}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Item details */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Price each</span>
|
|
||||||
<span className="text-amber-400 font-medium tabular-nums">
|
|
||||||
{order.price.toLocaleString()} gold
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{itemInfo && (
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Type</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{itemInfo.type}{itemInfo.subtype ? ` / ${itemInfo.subtype}` : ""}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{itemInfo && itemInfo.level > 0 && (
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Required level</span>
|
|
||||||
<span className="tabular-nums">{itemInfo.level}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Seller</span>
|
|
||||||
<span className="text-xs">{order.account ?? "Unknown"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirm step: quantity selector */}
|
|
||||||
{step === "confirm" && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Quantity (max {maxQty})</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max={maxQty}
|
|
||||||
value={quantity}
|
|
||||||
onChange={(e) => setQuantity(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm font-medium">
|
|
||||||
<span>Total cost</span>
|
|
||||||
<span className="text-amber-400 tabular-nums">
|
|
||||||
{totalCost.toLocaleString()} gold
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bought step: equip slot selector */}
|
|
||||||
{step === "bought" && isEquippable && (
|
|
||||||
<div className="space-y-1.5 pt-2 border-t">
|
|
||||||
<Label className="text-xs">Equip to slot</Label>
|
|
||||||
<Select value={equipSlot} onValueChange={setEquipSlot}>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue placeholder="Select slot..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{equipSlots.map((slot) => (
|
|
||||||
<SelectItem key={slot.key} value={slot.key}>
|
|
||||||
{slot.label}
|
|
||||||
{slot.key === suggestedSlot && " (suggested)"}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success states */}
|
|
||||||
{step === "equipped" && (
|
|
||||||
<div className="flex items-center gap-2 text-green-400 pt-2 border-t">
|
|
||||||
<CheckCircle2 className="size-4" />
|
|
||||||
<span className="text-sm">
|
|
||||||
{order.code} equipped to {equipSlots.find((s) => s.key === equipSlot)?.label ?? equipSlot}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{step === "confirm" && (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={handleClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-green-600 hover:bg-green-700 text-white"
|
|
||||||
disabled={!characterName || qty < 1}
|
|
||||||
onClick={handleBuy}
|
|
||||||
>
|
|
||||||
<ShoppingCart className="size-4" />
|
|
||||||
Buy {qty}x for {totalCost.toLocaleString()}g
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "buying" && (
|
|
||||||
<Button disabled>
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Buying...
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "bought" && (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={handleClose}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
{isEquippable && (
|
|
||||||
<Button
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
disabled={!equipSlot}
|
|
||||||
onClick={handleEquip}
|
|
||||||
>
|
|
||||||
<Shield className="size-4" />
|
|
||||||
Equip
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "equipping" && (
|
|
||||||
<Button disabled>
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Equipping...
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "equipped" && (
|
|
||||||
<Button onClick={handleClose}>Done</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Zap,
|
Zap,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
AlertTriangle,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
|
@ -36,7 +35,6 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Zap,
|
Zap,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
AlertTriangle,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Settings,
|
Settings,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,714 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
Loader2,
|
|
||||||
Users,
|
|
||||||
} 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 { Switch } from "@/components/ui/switch";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { useCharacters } from "@/hooks/use-characters";
|
|
||||||
import { useCreatePipeline } from "@/hooks/use-pipelines";
|
|
||||||
import { PIPELINE_TEMPLATES } from "./pipeline-templates";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface CharacterStepDraft {
|
|
||||||
id: string;
|
|
||||||
character_name: string;
|
|
||||||
strategy_type: string;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
transition: {
|
|
||||||
type: string;
|
|
||||||
operator?: string;
|
|
||||||
value?: number;
|
|
||||||
item_code?: string;
|
|
||||||
skill?: string;
|
|
||||||
seconds?: number;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StageDraft {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
character_steps: CharacterStepDraft[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const STRATEGY_OPTIONS = [
|
|
||||||
{ value: "combat", label: "Combat" },
|
|
||||||
{ value: "gathering", label: "Gathering" },
|
|
||||||
{ value: "crafting", label: "Crafting" },
|
|
||||||
{ value: "trading", label: "Trading" },
|
|
||||||
{ value: "task", label: "Task" },
|
|
||||||
{ value: "leveling", label: "Leveling" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TRANSITION_OPTIONS = [
|
|
||||||
{ value: "strategy_complete", label: "Strategy Complete" },
|
|
||||||
{ value: "inventory_full", label: "Inventory Full" },
|
|
||||||
{ value: "inventory_item_count", label: "Inventory Item Count" },
|
|
||||||
{ value: "bank_item_count", label: "Bank Item Count" },
|
|
||||||
{ value: "skill_level", label: "Skill Level" },
|
|
||||||
{ value: "gold_amount", label: "Gold Amount" },
|
|
||||||
{ value: "actions_count", label: "Actions Count" },
|
|
||||||
{ value: "timer", label: "Timer (seconds)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function makeStageId(idx: number) {
|
|
||||||
return `stage_${idx + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeStepId(stageIdx: number, stepIdx: number) {
|
|
||||||
return `cs_${stageIdx + 1}${String.fromCharCode(97 + stepIdx)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PipelineBuilder() {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const templateId = searchParams.get("template");
|
|
||||||
const { data: characters } = useCharacters();
|
|
||||||
const createMutation = useCreatePipeline();
|
|
||||||
|
|
||||||
// Initialize from template if provided
|
|
||||||
const template = templateId
|
|
||||||
? PIPELINE_TEMPLATES.find((t) => t.id === templateId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const [name, setName] = useState(template?.name ?? "");
|
|
||||||
const [description, setDescription] = useState(template?.description ?? "");
|
|
||||||
const [loop, setLoop] = useState(template?.loop ?? false);
|
|
||||||
const [maxLoops, setMaxLoops] = useState(template?.max_loops ?? 0);
|
|
||||||
const [stages, setStages] = useState<StageDraft[]>(() => {
|
|
||||||
if (template) {
|
|
||||||
return template.stages.map((s, i) => ({
|
|
||||||
id: makeStageId(i),
|
|
||||||
name: s.name,
|
|
||||||
character_steps: s.character_steps.map((cs, j) => ({
|
|
||||||
id: makeStepId(i, j),
|
|
||||||
character_name: cs.role ?? "",
|
|
||||||
strategy_type: cs.strategy_type,
|
|
||||||
config: cs.config,
|
|
||||||
transition: cs.transition,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "stage_1",
|
|
||||||
name: "Stage 1",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_1a",
|
|
||||||
character_name: "",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: {},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
function addStage() {
|
|
||||||
const idx = stages.length;
|
|
||||||
setStages([
|
|
||||||
...stages,
|
|
||||||
{
|
|
||||||
id: makeStageId(idx),
|
|
||||||
name: `Stage ${idx + 1}`,
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: makeStepId(idx, 0),
|
|
||||||
character_name: "",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: {},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeStage(idx: number) {
|
|
||||||
setStages(stages.filter((_, i) => i !== idx));
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveStage(idx: number, dir: -1 | 1) {
|
|
||||||
const newIdx = idx + dir;
|
|
||||||
if (newIdx < 0 || newIdx >= stages.length) return;
|
|
||||||
const copy = [...stages];
|
|
||||||
[copy[idx], copy[newIdx]] = [copy[newIdx], copy[idx]];
|
|
||||||
setStages(copy);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStage(idx: number, patch: Partial<StageDraft>) {
|
|
||||||
setStages(stages.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCharacterStep(stageIdx: number) {
|
|
||||||
const stage = stages[stageIdx];
|
|
||||||
const stepIdx = stage.character_steps.length;
|
|
||||||
updateStage(stageIdx, {
|
|
||||||
character_steps: [
|
|
||||||
...stage.character_steps,
|
|
||||||
{
|
|
||||||
id: makeStepId(stageIdx, stepIdx),
|
|
||||||
character_name: "",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: {},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCharacterStep(stageIdx: number, stepIdx: number) {
|
|
||||||
const stage = stages[stageIdx];
|
|
||||||
updateStage(stageIdx, {
|
|
||||||
character_steps: stage.character_steps.filter((_, i) => i !== stepIdx),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCharacterStep(
|
|
||||||
stageIdx: number,
|
|
||||||
stepIdx: number,
|
|
||||||
patch: Partial<CharacterStepDraft>
|
|
||||||
) {
|
|
||||||
const stage = stages[stageIdx];
|
|
||||||
updateStage(stageIdx, {
|
|
||||||
character_steps: stage.character_steps.map((cs, i) =>
|
|
||||||
i === stepIdx ? { ...cs, ...patch } : cs
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
if (!name.trim()) {
|
|
||||||
toast.error("Pipeline name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasEmptyChar = stages.some((s) =>
|
|
||||||
s.character_steps.some((cs) => !cs.character_name)
|
|
||||||
);
|
|
||||||
if (hasEmptyChar) {
|
|
||||||
toast.error("All character steps must have a character assigned");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createMutation.mutate(
|
|
||||||
{
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim(),
|
|
||||||
stages: stages.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
character_steps: s.character_steps.map((cs) => ({
|
|
||||||
id: cs.id,
|
|
||||||
character_name: cs.character_name,
|
|
||||||
strategy_type: cs.strategy_type,
|
|
||||||
config: cs.config,
|
|
||||||
transition: cs.transition,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
loop,
|
|
||||||
max_loops: maxLoops,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Pipeline created");
|
|
||||||
router.push("/automations");
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(`Failed to create pipeline: ${err.message}`);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-4xl">
|
|
||||||
{/* Basic info */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Users className="size-5" />
|
|
||||||
Pipeline Settings
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="My Pipeline"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="What does this pipeline do?"
|
|
||||||
rows={1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="loop"
|
|
||||||
checked={loop}
|
|
||||||
onCheckedChange={setLoop}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="loop">Loop pipeline</Label>
|
|
||||||
</div>
|
|
||||||
{loop && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label htmlFor="maxLoops">Max loops (0 = unlimited)</Label>
|
|
||||||
<Input
|
|
||||||
id="maxLoops"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="w-24"
|
|
||||||
value={maxLoops}
|
|
||||||
onChange={(e) => setMaxLoops(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Stages */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{stages.map((stage, stageIdx) => (
|
|
||||||
<Card key={stage.id}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Stage {stageIdx + 1}
|
|
||||||
</Badge>
|
|
||||||
<Input
|
|
||||||
value={stage.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateStage(stageIdx, { name: e.target.value })
|
|
||||||
}
|
|
||||||
className="h-8 w-48"
|
|
||||||
placeholder="Stage name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => moveStage(stageIdx, -1)}
|
|
||||||
disabled={stageIdx === 0}
|
|
||||||
>
|
|
||||||
<ChevronUp className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => moveStage(stageIdx, 1)}
|
|
||||||
disabled={stageIdx === stages.length - 1}
|
|
||||||
>
|
|
||||||
<ChevronDown className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => removeStage(stageIdx)}
|
|
||||||
disabled={stages.length <= 1}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Character steps run in parallel within this stage
|
|
||||||
</p>
|
|
||||||
{stage.character_steps.map((cs, stepIdx) => (
|
|
||||||
<div
|
|
||||||
key={cs.id}
|
|
||||||
className="border rounded-lg p-3 space-y-3 bg-muted/30"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
Worker {stepIdx + 1}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => removeCharacterStep(stageIdx, stepIdx)}
|
|
||||||
disabled={stage.character_steps.length <= 1}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Character</Label>
|
|
||||||
<Select
|
|
||||||
value={cs.character_name}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateCharacterStep(stageIdx, stepIdx, {
|
|
||||||
character_name: v,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="Select character" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(characters ?? []).map((c) => (
|
|
||||||
<SelectItem key={c.name} value={c.name}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Strategy</Label>
|
|
||||||
<Select
|
|
||||||
value={cs.strategy_type}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateCharacterStep(stageIdx, stepIdx, {
|
|
||||||
strategy_type: v,
|
|
||||||
config: {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{STRATEGY_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Transition</Label>
|
|
||||||
<Select
|
|
||||||
value={cs.transition?.type ?? "strategy_complete"}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateCharacterStep(stageIdx, stepIdx, {
|
|
||||||
transition: { type: v },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{TRANSITION_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Config fields based on strategy */}
|
|
||||||
<StrategyConfigFields
|
|
||||||
strategyType={cs.strategy_type}
|
|
||||||
config={cs.config}
|
|
||||||
onChange={(config) =>
|
|
||||||
updateCharacterStep(stageIdx, stepIdx, { config })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* Transition value fields */}
|
|
||||||
<TransitionValueFields
|
|
||||||
transition={cs.transition}
|
|
||||||
onChange={(transition) =>
|
|
||||||
updateCharacterStep(stageIdx, stepIdx, { transition })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => addCharacterStep(stageIdx)}
|
|
||||||
>
|
|
||||||
<Plus className="size-3" />
|
|
||||||
Add Character Worker
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={addStage}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Add Stage
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<div className="flex items-center gap-3 pt-4 border-t">
|
|
||||||
<Button onClick={handleSubmit} disabled={createMutation.isPending}>
|
|
||||||
{createMutation.isPending && (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Create Pipeline
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/automations")}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StrategyConfigFields({
|
|
||||||
strategyType,
|
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
strategyType: string;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
onChange: (config: Record<string, unknown>) => void;
|
|
||||||
}) {
|
|
||||||
switch (strategyType) {
|
|
||||||
case "gathering":
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Resource Code</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={(config.resource_code as string) ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...config, resource_code: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="copper_rocks"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 pt-4">
|
|
||||||
<Switch
|
|
||||||
checked={(config.deposit_on_full as boolean) ?? true}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
onChange({ ...config, deposit_on_full: v })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label className="text-xs">Deposit on full</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "combat":
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Monster Code</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={(config.monster_code as string) ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...config, monster_code: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="chicken"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 pt-4">
|
|
||||||
<Switch
|
|
||||||
checked={(config.deposit_loot as boolean) ?? true}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
onChange({ ...config, deposit_loot: v })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label className="text-xs">Deposit loot</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "crafting":
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Item Code</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={(config.item_code as string) ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...config, item_code: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="copper_dagger"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Quantity</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={(config.quantity as number) ?? 10}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...config, quantity: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "trading":
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Mode</Label>
|
|
||||||
<Select
|
|
||||||
value={(config.mode as string) ?? "sell_loot"}
|
|
||||||
onValueChange={(v) => onChange({ ...config, mode: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="sell_loot">Sell Loot</SelectItem>
|
|
||||||
<SelectItem value="buy_materials">Buy Materials</SelectItem>
|
|
||||||
<SelectItem value="flip">Flip</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Item Code</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={(config.item_code as string) ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...config, item_code: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function TransitionValueFields({
|
|
||||||
transition,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
transition: CharacterStepDraft["transition"];
|
|
||||||
onChange: (t: CharacterStepDraft["transition"]) => void;
|
|
||||||
}) {
|
|
||||||
if (!transition) return null;
|
|
||||||
const type = transition.type;
|
|
||||||
|
|
||||||
if (
|
|
||||||
type === "strategy_complete" ||
|
|
||||||
type === "inventory_full" ||
|
|
||||||
type === "loops_completed"
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
|
||||||
{(type === "inventory_item_count" || type === "bank_item_count") && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Item Code</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={transition.item_code ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...transition, item_code: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="copper_ore"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{type === "skill_level" && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Skill</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={transition.skill ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...transition, skill: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="mining"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{type === "timer" ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Seconds</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={transition.seconds ?? 60}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...transition, seconds: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Operator</Label>
|
|
||||||
<Select
|
|
||||||
value={transition.operator ?? ">="}
|
|
||||||
onValueChange={(v) => onChange({ ...transition, operator: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value=">=">>=</SelectItem>
|
|
||||||
<SelectItem value="<="><=</SelectItem>
|
|
||||||
<SelectItem value="==">=</SelectItem>
|
|
||||||
<SelectItem value=">">></SelectItem>
|
|
||||||
<SelectItem value="<"><</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Value</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={transition.value ?? 0}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...transition, value: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,352 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
Bot,
|
|
||||||
Play,
|
|
||||||
Square,
|
|
||||||
Pause,
|
|
||||||
Repeat,
|
|
||||||
Users,
|
|
||||||
} 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 {
|
|
||||||
usePipelines,
|
|
||||||
usePipelineStatuses,
|
|
||||||
useDeletePipeline,
|
|
||||||
useControlPipeline,
|
|
||||||
} from "@/hooks/use-pipelines";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
label: string;
|
|
||||||
variant: "default" | "secondary" | "destructive" | "outline";
|
|
||||||
className: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
running: {
|
|
||||||
label: "Running",
|
|
||||||
variant: "default",
|
|
||||||
className: "bg-green-600 hover:bg-green-600 text-white",
|
|
||||||
},
|
|
||||||
paused: {
|
|
||||||
label: "Paused",
|
|
||||||
variant: "default",
|
|
||||||
className: "bg-yellow-600 hover:bg-yellow-600 text-white",
|
|
||||||
},
|
|
||||||
stopped: { label: "Stopped", variant: "secondary", className: "" },
|
|
||||||
completed: {
|
|
||||||
label: "Completed",
|
|
||||||
variant: "default",
|
|
||||||
className: "bg-blue-600 hover:bg-blue-600 text-white",
|
|
||||||
},
|
|
||||||
error: { label: "Error", variant: "destructive", className: "" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function getUniqueCharacters(stages: { character_steps?: { character_name: string }[] }[]): string[] {
|
|
||||||
const names = new Set<string>();
|
|
||||||
for (const stage of stages) {
|
|
||||||
for (const cs of stage.character_steps ?? []) {
|
|
||||||
names.add(cs.character_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(names).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PipelineList() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: pipelines, isLoading, error } = usePipelines();
|
|
||||||
const { data: statuses } = usePipelineStatuses();
|
|
||||||
const deleteMutation = useDeletePipeline();
|
|
||||||
const control = useControlPipeline();
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const statusMap = new Map(
|
|
||||||
(statuses ?? []).map((s) => [s.pipeline_id, s])
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
deleteMutation.mutate(deleteTarget.id, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(`Pipeline "${deleteTarget.name}" deleted`);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(`Failed to delete: ${err.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleControl(
|
|
||||||
id: number,
|
|
||||||
action: "start" | "stop" | "pause" | "resume"
|
|
||||||
) {
|
|
||||||
control.mutate(
|
|
||||||
{ id, action },
|
|
||||||
{
|
|
||||||
onSuccess: () => toast.success(`Pipeline ${action}ed`),
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(`Failed to ${action} pipeline: ${err.message}`),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className="border-destructive bg-destructive/10 p-4">
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
Failed to load pipelines. Make sure the backend is running.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pipelines || pipelines.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<Users className="size-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<p className="text-muted-foreground mb-4">
|
|
||||||
No pipelines configured yet. Create a multi-character pipeline to
|
|
||||||
coordinate characters across stages.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => router.push("/automations/pipelines/new")}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Create Pipeline
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Characters</TableHead>
|
|
||||||
<TableHead>Stages</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Progress</TableHead>
|
|
||||||
<TableHead>Controls</TableHead>
|
|
||||||
<TableHead className="w-10" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{pipelines.map((pl) => {
|
|
||||||
const status = statusMap.get(pl.id);
|
|
||||||
const currentStatus = status?.status ?? "stopped";
|
|
||||||
const statusCfg =
|
|
||||||
STATUS_CONFIG[currentStatus] ?? STATUS_CONFIG.stopped;
|
|
||||||
const isStopped =
|
|
||||||
currentStatus === "stopped" ||
|
|
||||||
currentStatus === "completed" ||
|
|
||||||
currentStatus === "error" ||
|
|
||||||
!currentStatus;
|
|
||||||
const isRunning = currentStatus === "running";
|
|
||||||
const isPaused = currentStatus === "paused";
|
|
||||||
const characters = getUniqueCharacters(pl.stages);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={pl.id}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/automations/pipelines/${pl.id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{pl.name}
|
|
||||||
{pl.loop && (
|
|
||||||
<Repeat className="size-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{characters.map((c) => (
|
|
||||||
<Badge key={c} variant="outline" className="text-[10px]">
|
|
||||||
{c}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{pl.stages.length} stage{pl.stages.length !== 1 && "s"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={statusCfg.variant}
|
|
||||||
className={statusCfg.className}
|
|
||||||
>
|
|
||||||
{statusCfg.label}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{status ? (
|
|
||||||
<span>
|
|
||||||
Stage {status.current_stage_index + 1}/
|
|
||||||
{status.total_stages}
|
|
||||||
{status.loop_count > 0 && (
|
|
||||||
<> · Loop {status.loop_count}</>
|
|
||||||
)}
|
|
||||||
{" "}· {status.total_actions_count} actions
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"\u2014"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{isStopped && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
|
||||||
onClick={() => handleControl(pl.id, "start")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isRunning && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
|
|
||||||
onClick={() => handleControl(pl.id, "pause")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Pause className="size-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
onClick={() => handleControl(pl.id, "stop")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Square className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isPaused && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
|
||||||
onClick={() => handleControl(pl.id, "resume")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
onClick={() => handleControl(pl.id, "stop")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Square className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteTarget({ id: pl.id, name: pl.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 Pipeline</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete “{deleteTarget?.name}
|
|
||||||
”? This action cannot be undone. Any running pipeline 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { CheckCircle2, Circle, AlertCircle, Loader2, Play, Square, Pause } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import type { PipelineConfig, PipelineStatus } from "@/lib/types";
|
|
||||||
import { useControlPipeline } from "@/hooks/use-pipelines";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface PipelineProgressProps {
|
|
||||||
config: PipelineConfig;
|
|
||||||
status: PipelineStatus | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHAR_STATUS_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
running: <Loader2 className="size-3 animate-spin text-green-400" />,
|
|
||||||
completed: <CheckCircle2 className="size-3 text-blue-400" />,
|
|
||||||
error: <AlertCircle className="size-3 text-red-400" />,
|
|
||||||
idle: <Circle className="size-3 text-muted-foreground" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PipelineProgress({ config, status }: PipelineProgressProps) {
|
|
||||||
const control = useControlPipeline();
|
|
||||||
const currentStatus = status?.status ?? "stopped";
|
|
||||||
const isStopped =
|
|
||||||
currentStatus === "stopped" ||
|
|
||||||
currentStatus === "completed" ||
|
|
||||||
currentStatus === "error" ||
|
|
||||||
!currentStatus;
|
|
||||||
const isRunning = currentStatus === "running";
|
|
||||||
const isPaused = currentStatus === "paused";
|
|
||||||
|
|
||||||
function handleControl(action: "start" | "stop" | "pause" | "resume") {
|
|
||||||
control.mutate(
|
|
||||||
{ id: config.id, action },
|
|
||||||
{
|
|
||||||
onSuccess: () => toast.success(`Pipeline ${action}ed`),
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(`Failed to ${action}: ${err.message}`),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a map of character states from the status
|
|
||||||
const charStateMap = new Map(
|
|
||||||
(status?.character_states ?? []).map((cs) => [cs.character_name, cs])
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-base">Live Progress</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{status && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{status.total_actions_count} total actions
|
|
||||||
{status.loop_count > 0 && ` \u00b7 Loop ${status.loop_count}`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{isStopped && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-400"
|
|
||||||
onClick={() => handleControl("start")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-3" />
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isRunning && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-yellow-400"
|
|
||||||
onClick={() => handleControl("pause")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Pause className="size-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400"
|
|
||||||
onClick={() => handleControl("stop")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Square className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isPaused && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-400"
|
|
||||||
onClick={() => handleControl("resume")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400"
|
|
||||||
onClick={() => handleControl("stop")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Square className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="relative space-y-0">
|
|
||||||
{config.stages.map((stage, idx) => {
|
|
||||||
const isActive =
|
|
||||||
status != null && status.current_stage_index === idx && isRunning;
|
|
||||||
const isCompleted =
|
|
||||||
status != null && status.current_stage_index > idx;
|
|
||||||
const isPastOrActive = isActive || isCompleted;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={stage.id} className="relative flex gap-4">
|
|
||||||
{/* Timeline line */}
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
className={`size-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
|
||||||
isActive
|
|
||||||
? "border-green-500 bg-green-500/20 text-green-400"
|
|
||||||
: isCompleted
|
|
||||||
? "border-blue-500 bg-blue-500/20 text-blue-400"
|
|
||||||
: "border-muted-foreground/30 text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isCompleted ? (
|
|
||||||
<CheckCircle2 className="size-3.5" />
|
|
||||||
) : isActive ? (
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
idx + 1
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{idx < config.stages.length - 1 && (
|
|
||||||
<div
|
|
||||||
className={`w-0.5 flex-1 min-h-4 ${
|
|
||||||
isPastOrActive
|
|
||||||
? "bg-blue-500/50"
|
|
||||||
: "bg-muted-foreground/20"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stage content */}
|
|
||||||
<div className="flex-1 pb-4">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span
|
|
||||||
className={`text-sm font-medium ${
|
|
||||||
isActive
|
|
||||||
? "text-green-400"
|
|
||||||
: isCompleted
|
|
||||||
? "text-blue-400"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{stage.name}
|
|
||||||
</span>
|
|
||||||
{isActive && (
|
|
||||||
<Badge className="bg-green-600 text-white text-[10px] px-1.5">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isCompleted && (
|
|
||||||
<Badge className="bg-blue-600 text-white text-[10px] px-1.5">
|
|
||||||
Done
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Character workers */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{stage.character_steps.map((cs) => {
|
|
||||||
const charState = charStateMap.get(cs.character_name);
|
|
||||||
const charStatus = charState?.status ?? "idle";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={cs.id}
|
|
||||||
className="flex items-center gap-2 text-xs"
|
|
||||||
>
|
|
||||||
{isActive
|
|
||||||
? CHAR_STATUS_ICONS[charStatus] ??
|
|
||||||
CHAR_STATUS_ICONS.idle
|
|
||||||
: <Circle className="size-3 text-muted-foreground/40" />}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{cs.character_name || "Unassigned"}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground capitalize">
|
|
||||||
{cs.strategy_type}
|
|
||||||
</span>
|
|
||||||
{isActive && charState && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{charState.actions_count} actions
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{charState?.error && (
|
|
||||||
<span className="text-red-400 truncate max-w-48">
|
|
||||||
{charState.error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
Network,
|
|
||||||
Repeat,
|
|
||||||
Swords,
|
|
||||||
Pickaxe,
|
|
||||||
Hammer,
|
|
||||||
TrendingUp,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import {
|
|
||||||
PIPELINE_TEMPLATES,
|
|
||||||
PIPELINE_CATEGORY_LABELS,
|
|
||||||
PIPELINE_CATEGORY_COLORS,
|
|
||||||
} from "./pipeline-templates";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const STRATEGY_ICONS: Record<string, React.ElementType> = {
|
|
||||||
combat: Swords,
|
|
||||||
gathering: Pickaxe,
|
|
||||||
crafting: Hammer,
|
|
||||||
trading: TrendingUp,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PipelineTemplateGallery() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Separator />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Network className="size-5 text-muted-foreground" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
Pipeline Templates
|
|
||||||
</h2>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Multi-character collaboration
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{PIPELINE_TEMPLATES.map((template) => (
|
|
||||||
<Card
|
|
||||||
key={template.id}
|
|
||||||
className="cursor-pointer transition-all hover:shadow-md group py-0 overflow-hidden hover:border-primary/30"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(
|
|
||||||
`/automations/pipelines/new?template=${template.id}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="h-1 bg-primary/40" />
|
|
||||||
<CardContent className="p-4 space-y-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Network className="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-semibold text-sm text-foreground group-hover:text-primary transition-colors">
|
|
||||||
{template.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{template.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"text-[10px] capitalize",
|
|
||||||
PIPELINE_CATEGORY_COLORS[template.category]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{PIPELINE_CATEGORY_LABELS[template.category] ??
|
|
||||||
template.category}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{template.stages.length} stage
|
|
||||||
{template.stages.length !== 1 && "s"}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-[10px] gap-0.5">
|
|
||||||
<Users className="size-2.5" />
|
|
||||||
{template.roles.length} roles
|
|
||||||
</Badge>
|
|
||||||
{template.loop && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] gap-0.5"
|
|
||||||
>
|
|
||||||
<Repeat className="size-2.5" />
|
|
||||||
Loop
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stage mini-preview */}
|
|
||||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
||||||
{template.stages.map((stage, idx) => {
|
|
||||||
const strategyTypes = [
|
|
||||||
...new Set(stage.character_steps.map((cs) => cs.strategy_type)),
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<span key={stage.id} className="flex items-center gap-0.5">
|
|
||||||
{idx > 0 && <span className="mx-0.5">→</span>}
|
|
||||||
{strategyTypes.map((st) => {
|
|
||||||
const Icon = STRATEGY_ICONS[st] ?? Network;
|
|
||||||
return <Icon key={st} className="size-3" />;
|
|
||||||
})}
|
|
||||||
{stage.character_steps.length > 1 && (
|
|
||||||
<span className="text-[9px]">
|
|
||||||
x{stage.character_steps.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
export interface PipelineTemplate {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
category: "production" | "combat" | "economy" | "leveling";
|
|
||||||
roles: string[];
|
|
||||||
stages: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
character_steps: {
|
|
||||||
id: string;
|
|
||||||
role: string;
|
|
||||||
strategy_type: string;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
transition: {
|
|
||||||
type: string;
|
|
||||||
operator?: string;
|
|
||||||
value?: number;
|
|
||||||
item_code?: string;
|
|
||||||
skill?: string;
|
|
||||||
seconds?: number;
|
|
||||||
} | null;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
loop: boolean;
|
|
||||||
max_loops: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
|
|
||||||
{
|
|
||||||
id: "resource_pipeline",
|
|
||||||
name: "Resource Pipeline",
|
|
||||||
description:
|
|
||||||
"Gatherer collects resources and banks them, Crafter withdraws and crafts, Seller lists on GE. 3 characters, 3 stages.",
|
|
||||||
category: "production",
|
|
||||||
roles: ["Gatherer", "Crafter", "Seller"],
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
id: "stage_1",
|
|
||||||
name: "Gather Resources",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_1a",
|
|
||||||
role: "Gatherer",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
|
||||||
transition: {
|
|
||||||
type: "bank_item_count",
|
|
||||||
item_code: "copper_ore",
|
|
||||||
operator: ">=",
|
|
||||||
value: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stage_2",
|
|
||||||
name: "Craft Items",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_2a",
|
|
||||||
role: "Crafter",
|
|
||||||
strategy_type: "crafting",
|
|
||||||
config: {
|
|
||||||
item_code: "copper_dagger",
|
|
||||||
quantity: 50,
|
|
||||||
gather_materials: false,
|
|
||||||
},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stage_3",
|
|
||||||
name: "Sell on GE",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_3a",
|
|
||||||
role: "Seller",
|
|
||||||
strategy_type: "trading",
|
|
||||||
config: {
|
|
||||||
mode: "sell_loot",
|
|
||||||
item_code: "copper_dagger",
|
|
||||||
quantity: 50,
|
|
||||||
min_price: 1,
|
|
||||||
},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
loop: true,
|
|
||||||
max_loops: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "dual_gatherer_pipeline",
|
|
||||||
name: "Dual Gatherer Pipeline",
|
|
||||||
description:
|
|
||||||
"Two miners gather copper in parallel, then one crafter turns it all into daggers. Double the gathering speed.",
|
|
||||||
category: "production",
|
|
||||||
roles: ["Miner1", "Miner2", "Crafter"],
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
id: "stage_1",
|
|
||||||
name: "Parallel Gathering",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_1a",
|
|
||||||
role: "Miner1",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
|
||||||
transition: {
|
|
||||||
type: "bank_item_count",
|
|
||||||
item_code: "copper_ore",
|
|
||||||
operator: ">=",
|
|
||||||
value: 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cs_1b",
|
|
||||||
role: "Miner2",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
|
||||||
transition: {
|
|
||||||
type: "bank_item_count",
|
|
||||||
item_code: "copper_ore",
|
|
||||||
operator: ">=",
|
|
||||||
value: 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stage_2",
|
|
||||||
name: "Craft Items",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_2a",
|
|
||||||
role: "Crafter",
|
|
||||||
strategy_type: "crafting",
|
|
||||||
config: {
|
|
||||||
item_code: "copper_dagger",
|
|
||||||
quantity: 100,
|
|
||||||
gather_materials: false,
|
|
||||||
},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
loop: true,
|
|
||||||
max_loops: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "combat_supply_chain",
|
|
||||||
name: "Combat Supply Chain",
|
|
||||||
description:
|
|
||||||
"Fisher catches and cooks food, then Fighter uses it for sustained combat with consumable healing.",
|
|
||||||
category: "combat",
|
|
||||||
roles: ["Chef", "Fighter"],
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
id: "stage_1",
|
|
||||||
name: "Prepare Food",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_1a",
|
|
||||||
role: "Chef",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: { resource_code: "gudgeon_fishing_spot", deposit_on_full: true },
|
|
||||||
transition: { type: "actions_count", operator: ">=", value: 40 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stage_2",
|
|
||||||
name: "Cook Food",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_1a",
|
|
||||||
role: "Chef",
|
|
||||||
strategy_type: "crafting",
|
|
||||||
config: {
|
|
||||||
item_code: "cooked_gudgeon",
|
|
||||||
quantity: 30,
|
|
||||||
gather_materials: false,
|
|
||||||
},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stage_3",
|
|
||||||
name: "Fight with Food",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_3a",
|
|
||||||
role: "Fighter",
|
|
||||||
strategy_type: "combat",
|
|
||||||
config: {
|
|
||||||
monster_code: "yellow_slime",
|
|
||||||
deposit_loot: true,
|
|
||||||
auto_heal_threshold: 50,
|
|
||||||
heal_method: "consumable",
|
|
||||||
consumable_code: "cooked_gudgeon",
|
|
||||||
},
|
|
||||||
transition: { type: "actions_count", operator: ">=", value: 80 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
loop: true,
|
|
||||||
max_loops: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "full_economy",
|
|
||||||
name: "Full Economy",
|
|
||||||
description:
|
|
||||||
"2 Gatherers mine in parallel, Crafter crafts items, Seller sells on GE. Complete 4-character production line.",
|
|
||||||
category: "economy",
|
|
||||||
roles: ["Miner1", "Miner2", "Crafter", "Seller"],
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
id: "stage_1",
|
|
||||||
name: "Gather Resources",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_1a",
|
|
||||||
role: "Miner1",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
|
||||||
transition: {
|
|
||||||
type: "bank_item_count",
|
|
||||||
item_code: "copper_ore",
|
|
||||||
operator: ">=",
|
|
||||||
value: 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cs_1b",
|
|
||||||
role: "Miner2",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: { resource_code: "copper_rocks", deposit_on_full: true },
|
|
||||||
transition: {
|
|
||||||
type: "bank_item_count",
|
|
||||||
item_code: "copper_ore",
|
|
||||||
operator: ">=",
|
|
||||||
value: 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stage_2",
|
|
||||||
name: "Craft Items",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_2a",
|
|
||||||
role: "Crafter",
|
|
||||||
strategy_type: "crafting",
|
|
||||||
config: {
|
|
||||||
item_code: "copper_dagger",
|
|
||||||
quantity: 100,
|
|
||||||
gather_materials: false,
|
|
||||||
},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stage_3",
|
|
||||||
name: "Sell on GE",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_3a",
|
|
||||||
role: "Seller",
|
|
||||||
strategy_type: "trading",
|
|
||||||
config: {
|
|
||||||
mode: "sell_loot",
|
|
||||||
item_code: "copper_dagger",
|
|
||||||
quantity: 100,
|
|
||||||
min_price: 1,
|
|
||||||
},
|
|
||||||
transition: { type: "strategy_complete" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
loop: true,
|
|
||||||
max_loops: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "power_leveling_duo",
|
|
||||||
name: "Power Leveling Duo",
|
|
||||||
description:
|
|
||||||
"Two characters fight different monsters in parallel for maximum XP gain. Both deposit loot at end.",
|
|
||||||
category: "leveling",
|
|
||||||
roles: ["Fighter1", "Fighter2"],
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
id: "stage_1",
|
|
||||||
name: "Parallel Combat",
|
|
||||||
character_steps: [
|
|
||||||
{
|
|
||||||
id: "cs_1a",
|
|
||||||
role: "Fighter1",
|
|
||||||
strategy_type: "combat",
|
|
||||||
config: {
|
|
||||||
monster_code: "chicken",
|
|
||||||
deposit_loot: true,
|
|
||||||
auto_heal_threshold: 40,
|
|
||||||
},
|
|
||||||
transition: { type: "actions_count", operator: ">=", value: 100 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cs_1b",
|
|
||||||
role: "Fighter2",
|
|
||||||
strategy_type: "combat",
|
|
||||||
config: {
|
|
||||||
monster_code: "yellow_slime",
|
|
||||||
deposit_loot: true,
|
|
||||||
auto_heal_threshold: 50,
|
|
||||||
},
|
|
||||||
transition: { type: "actions_count", operator: ">=", value: 100 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
loop: true,
|
|
||||||
max_loops: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PIPELINE_CATEGORY_LABELS: Record<string, string> = {
|
|
||||||
production: "Production",
|
|
||||||
combat: "Combat",
|
|
||||||
economy: "Economy",
|
|
||||||
leveling: "Leveling",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PIPELINE_CATEGORY_COLORS: Record<string, string> = {
|
|
||||||
production: "text-blue-400",
|
|
||||||
combat: "text-red-400",
|
|
||||||
economy: "text-yellow-400",
|
|
||||||
leveling: "text-cyan-400",
|
|
||||||
};
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import type { TransitionCondition } from "@/lib/types";
|
|
||||||
|
|
||||||
const TRANSITION_TYPES = [
|
|
||||||
{ value: "strategy_complete", label: "Strategy Completes" },
|
|
||||||
{ value: "actions_count", label: "Actions Count" },
|
|
||||||
{ value: "timer", label: "Timer (seconds)" },
|
|
||||||
{ value: "inventory_full", label: "Inventory Full" },
|
|
||||||
{ value: "inventory_item_count", label: "Inventory Item Count" },
|
|
||||||
{ value: "bank_item_count", label: "Bank Item Count" },
|
|
||||||
{ value: "skill_level", label: "Skill Level" },
|
|
||||||
{ value: "gold_amount", label: "Gold Amount" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const OPERATORS = [
|
|
||||||
{ value: ">=", label: ">=" },
|
|
||||||
{ value: "<=", label: "<=" },
|
|
||||||
{ value: "==", label: "==" },
|
|
||||||
{ value: ">", label: ">" },
|
|
||||||
{ value: "<", label: "<" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const SKILLS = [
|
|
||||||
"mining",
|
|
||||||
"woodcutting",
|
|
||||||
"fishing",
|
|
||||||
"weaponcrafting",
|
|
||||||
"gearcrafting",
|
|
||||||
"jewelrycrafting",
|
|
||||||
"cooking",
|
|
||||||
"alchemy",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface TransitionEditorProps {
|
|
||||||
value: TransitionCondition | null;
|
|
||||||
onChange: (condition: TransitionCondition | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TransitionEditor({ value, onChange }: TransitionEditorProps) {
|
|
||||||
const condType = value?.type ?? "";
|
|
||||||
const needsValue =
|
|
||||||
condType === "actions_count" ||
|
|
||||||
condType === "inventory_item_count" ||
|
|
||||||
condType === "bank_item_count" ||
|
|
||||||
condType === "skill_level" ||
|
|
||||||
condType === "gold_amount";
|
|
||||||
const needsItemCode =
|
|
||||||
condType === "inventory_item_count" || condType === "bank_item_count";
|
|
||||||
const needsSkill = condType === "skill_level";
|
|
||||||
const needsSeconds = condType === "timer";
|
|
||||||
|
|
||||||
function update(partial: Partial<TransitionCondition>) {
|
|
||||||
const base: TransitionCondition = value ?? {
|
|
||||||
type: "strategy_complete",
|
|
||||||
operator: ">=",
|
|
||||||
value: 0,
|
|
||||||
item_code: "",
|
|
||||||
skill: "",
|
|
||||||
seconds: 0,
|
|
||||||
};
|
|
||||||
onChange({ ...base, ...partial });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
Transition Condition
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={condType || "none"}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v === "none") {
|
|
||||||
onChange(null);
|
|
||||||
} else {
|
|
||||||
update({
|
|
||||||
type: v as TransitionCondition["type"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="No transition (run until complete)" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">
|
|
||||||
No transition (run until strategy completes)
|
|
||||||
</SelectItem>
|
|
||||||
{TRANSITION_TYPES.map((t) => (
|
|
||||||
<SelectItem key={t.value} value={t.value}>
|
|
||||||
{t.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{needsValue && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="w-20">
|
|
||||||
<Label className="text-xs text-muted-foreground">Operator</Label>
|
|
||||||
<Select
|
|
||||||
value={value?.operator ?? ">="}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
update({ operator: v as TransitionCondition["operator"] })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{OPERATORS.map((op) => (
|
|
||||||
<SelectItem key={op.value} value={op.value}>
|
|
||||||
{op.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Value</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={value?.value ?? 0}
|
|
||||||
onChange={(e) => update({ value: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{needsItemCode && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">Item Code</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs"
|
|
||||||
placeholder="e.g. copper_ore"
|
|
||||||
value={value?.item_code ?? ""}
|
|
||||||
onChange={(e) => update({ item_code: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{needsSkill && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">Skill</Label>
|
|
||||||
<Select
|
|
||||||
value={value?.skill ?? "mining"}
|
|
||||||
onValueChange={(v) => update({ skill: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SKILLS.map((s) => (
|
|
||||||
<SelectItem key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{needsSeconds && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">Seconds</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={value?.seconds ?? 0}
|
|
||||||
onChange={(e) => update({ seconds: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Plus, Loader2, ArrowLeft, Repeat } 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 { Switch } from "@/components/ui/switch";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { WorkflowStepCard } from "@/components/workflow/workflow-step-card";
|
|
||||||
import { useCharacters } from "@/hooks/use-characters";
|
|
||||||
import { useCreateWorkflow } from "@/hooks/use-workflows";
|
|
||||||
import type { WorkflowStep } from "@/lib/types";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
function generateId(): string {
|
|
||||||
return `step_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEmptyStep(): WorkflowStep {
|
|
||||||
return {
|
|
||||||
id: generateId(),
|
|
||||||
name: "",
|
|
||||||
strategy_type: "gathering",
|
|
||||||
config: { resource_code: "", deposit_on_full: true, max_loops: 0 },
|
|
||||||
transition: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkflowBuilderProps {
|
|
||||||
/** If provided, pre-populates the builder (e.g. from a template). */
|
|
||||||
initialSteps?: WorkflowStep[];
|
|
||||||
initialName?: string;
|
|
||||||
initialDescription?: string;
|
|
||||||
initialLoop?: boolean;
|
|
||||||
initialMaxLoops?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowBuilder({
|
|
||||||
initialSteps,
|
|
||||||
initialName = "",
|
|
||||||
initialDescription = "",
|
|
||||||
initialLoop = false,
|
|
||||||
initialMaxLoops = 0,
|
|
||||||
}: WorkflowBuilderProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: characters, isLoading: loadingCharacters } = useCharacters();
|
|
||||||
const createMutation = useCreateWorkflow();
|
|
||||||
|
|
||||||
const [name, setName] = useState(initialName);
|
|
||||||
const [description, setDescription] = useState(initialDescription);
|
|
||||||
const [characterName, setCharacterName] = useState("");
|
|
||||||
const [loop, setLoop] = useState(initialLoop);
|
|
||||||
const [maxLoops, setMaxLoops] = useState(initialMaxLoops);
|
|
||||||
const [steps, setSteps] = useState<WorkflowStep[]>(
|
|
||||||
initialSteps ?? [makeEmptyStep()]
|
|
||||||
);
|
|
||||||
|
|
||||||
function updateStep(index: number, updated: WorkflowStep) {
|
|
||||||
setSteps((prev) => prev.map((s, i) => (i === index ? updated : s)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStep() {
|
|
||||||
setSteps((prev) => [...prev, makeEmptyStep()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeStep(index: number) {
|
|
||||||
setSteps((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveStep(from: number, to: number) {
|
|
||||||
setSteps((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const [moved] = next.splice(from, 1);
|
|
||||||
next.splice(to, 0, moved);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreate() {
|
|
||||||
if (!name.trim()) {
|
|
||||||
toast.error("Please enter a workflow name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!characterName) {
|
|
||||||
toast.error("Please select a character");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (steps.length === 0) {
|
|
||||||
toast.error("Add at least one step");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasEmptyStrategy = steps.some((s) => !s.strategy_type);
|
|
||||||
if (hasEmptyStrategy) {
|
|
||||||
toast.error("All steps must have a strategy selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createMutation.mutate(
|
|
||||||
{
|
|
||||||
name: name.trim(),
|
|
||||||
character_name: characterName,
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
steps: steps.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name || `Step ${steps.indexOf(s) + 1}`,
|
|
||||||
strategy_type: s.strategy_type,
|
|
||||||
config: s.config,
|
|
||||||
transition: s.transition,
|
|
||||||
})),
|
|
||||||
loop,
|
|
||||||
max_loops: maxLoops,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Workflow created successfully");
|
|
||||||
router.push("/automations");
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(`Failed to create workflow: ${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 Workflow
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Chain multiple strategies into a multi-step pipeline
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Basic info */}
|
|
||||||
<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="wf-name">Workflow Name</Label>
|
|
||||||
<Input
|
|
||||||
id="wf-name"
|
|
||||||
placeholder="e.g. Copper Pipeline, Mining Progression"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="wf-desc">Description (optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="wf-desc"
|
|
||||||
rows={2}
|
|
||||||
placeholder="Brief description of what this workflow does..."
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(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>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Repeat className="size-4 text-muted-foreground" />
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Loop Workflow</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Restart from step 1 after the last step completes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch checked={loop} onCheckedChange={setLoop} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loop && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="max-loops">Max Loops</Label>
|
|
||||||
<Input
|
|
||||||
id="max-loops"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="0 = infinite"
|
|
||||||
value={maxLoops}
|
|
||||||
onChange={(e) => setMaxLoops(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Max number of loop iterations. 0 = run forever.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Steps */}
|
|
||||||
<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">
|
|
||||||
2
|
|
||||||
</span>
|
|
||||||
Pipeline Steps
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{steps.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
No steps yet. Add your first step below.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{steps.map((step, idx) => (
|
|
||||||
<WorkflowStepCard
|
|
||||||
key={step.id}
|
|
||||||
step={step}
|
|
||||||
index={idx}
|
|
||||||
totalSteps={steps.length}
|
|
||||||
onChange={(updated) => updateStep(idx, updated)}
|
|
||||||
onMoveUp={() => moveStep(idx, idx - 1)}
|
|
||||||
onMoveDown={() => moveStep(idx, idx + 1)}
|
|
||||||
onDelete={() => removeStep(idx)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={addStep}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Add Step
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Create button */}
|
|
||||||
<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 ||
|
|
||||||
steps.length === 0 ||
|
|
||||||
createMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{createMutation.isPending && (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Create Workflow
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,334 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
Bot,
|
|
||||||
Play,
|
|
||||||
Square,
|
|
||||||
Pause,
|
|
||||||
Repeat,
|
|
||||||
} 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 {
|
|
||||||
useWorkflows,
|
|
||||||
useWorkflowStatuses,
|
|
||||||
useDeleteWorkflow,
|
|
||||||
useControlWorkflow,
|
|
||||||
} from "@/hooks/use-workflows";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
label: string;
|
|
||||||
variant: "default" | "secondary" | "destructive" | "outline";
|
|
||||||
className: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
running: {
|
|
||||||
label: "Running",
|
|
||||||
variant: "default",
|
|
||||||
className: "bg-green-600 hover:bg-green-600 text-white",
|
|
||||||
},
|
|
||||||
paused: {
|
|
||||||
label: "Paused",
|
|
||||||
variant: "default",
|
|
||||||
className: "bg-yellow-600 hover:bg-yellow-600 text-white",
|
|
||||||
},
|
|
||||||
stopped: { label: "Stopped", variant: "secondary", className: "" },
|
|
||||||
completed: {
|
|
||||||
label: "Completed",
|
|
||||||
variant: "default",
|
|
||||||
className: "bg-blue-600 hover:bg-blue-600 text-white",
|
|
||||||
},
|
|
||||||
error: { label: "Error", variant: "destructive", className: "" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkflowList() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: workflows, isLoading, error } = useWorkflows();
|
|
||||||
const { data: statuses } = useWorkflowStatuses();
|
|
||||||
const deleteMutation = useDeleteWorkflow();
|
|
||||||
const control = useControlWorkflow();
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const statusMap = new Map(
|
|
||||||
(statuses ?? []).map((s) => [s.workflow_id, s])
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
deleteMutation.mutate(deleteTarget.id, {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(`Workflow "${deleteTarget.name}" deleted`);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(`Failed to delete: ${err.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleControl(
|
|
||||||
id: number,
|
|
||||||
action: "start" | "stop" | "pause" | "resume"
|
|
||||||
) {
|
|
||||||
control.mutate(
|
|
||||||
{ id, action },
|
|
||||||
{
|
|
||||||
onSuccess: () => toast.success(`Workflow ${action}ed`),
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(`Failed to ${action} workflow: ${err.message}`),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className="border-destructive bg-destructive/10 p-4">
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
Failed to load workflows. Make sure the backend is running.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workflows || workflows.length === 0) {
|
|
||||||
return (
|
|
||||||
<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 workflows configured yet. Create a multi-step pipeline to chain
|
|
||||||
strategies together.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => router.push("/automations/workflows/new")}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Create Workflow
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Character</TableHead>
|
|
||||||
<TableHead>Steps</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Progress</TableHead>
|
|
||||||
<TableHead>Controls</TableHead>
|
|
||||||
<TableHead className="w-10" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{workflows.map((wf) => {
|
|
||||||
const status = statusMap.get(wf.id);
|
|
||||||
const currentStatus = status?.status ?? "stopped";
|
|
||||||
const statusCfg =
|
|
||||||
STATUS_CONFIG[currentStatus] ?? STATUS_CONFIG.stopped;
|
|
||||||
const isStopped =
|
|
||||||
currentStatus === "stopped" ||
|
|
||||||
currentStatus === "completed" ||
|
|
||||||
currentStatus === "error" ||
|
|
||||||
!currentStatus;
|
|
||||||
const isRunning = currentStatus === "running";
|
|
||||||
const isPaused = currentStatus === "paused";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={wf.id}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/automations/workflows/${wf.id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{wf.name}
|
|
||||||
{wf.loop && (
|
|
||||||
<Repeat className="size-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{wf.character_name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{wf.steps.length} step{wf.steps.length !== 1 && "s"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={statusCfg.variant}
|
|
||||||
className={statusCfg.className}
|
|
||||||
>
|
|
||||||
{statusCfg.label}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{status ? (
|
|
||||||
<span>
|
|
||||||
Step {status.current_step_index + 1}/{status.total_steps}
|
|
||||||
{status.loop_count > 0 && (
|
|
||||||
<> · Loop {status.loop_count}</>
|
|
||||||
)}
|
|
||||||
{" "}· {status.total_actions_count} actions
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{isStopped && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
|
||||||
onClick={() => handleControl(wf.id, "start")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isRunning && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
|
|
||||||
onClick={() => handleControl(wf.id, "pause")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Pause className="size-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
onClick={() => handleControl(wf.id, "stop")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Square className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isPaused && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
|
||||||
onClick={() => handleControl(wf.id, "resume")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
onClick={() => handleControl(wf.id, "stop")}
|
|
||||||
disabled={control.isPending}
|
|
||||||
>
|
|
||||||
<Square className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteTarget({ id: wf.id, name: wf.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 Workflow</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete “{deleteTarget?.name}
|
|
||||||
”? This action cannot be undone. Any running workflow 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
Circle,
|
|
||||||
Loader2,
|
|
||||||
Repeat,
|
|
||||||
Swords,
|
|
||||||
Pickaxe,
|
|
||||||
Hammer,
|
|
||||||
TrendingUp,
|
|
||||||
ClipboardList,
|
|
||||||
GraduationCap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import type { WorkflowStep, WorkflowStatus, AutomationLog } from "@/lib/types";
|
|
||||||
import { LogStream } from "@/components/automation/log-stream";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const STRATEGY_ICONS: Record<string, React.ElementType> = {
|
|
||||||
combat: Swords,
|
|
||||||
gathering: Pickaxe,
|
|
||||||
crafting: Hammer,
|
|
||||||
trading: TrendingUp,
|
|
||||||
task: ClipboardList,
|
|
||||||
leveling: GraduationCap,
|
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface WorkflowProgressProps {
|
|
||||||
steps: WorkflowStep[];
|
|
||||||
status: WorkflowStatus | null;
|
|
||||||
logs: AutomationLog[];
|
|
||||||
loop: boolean;
|
|
||||||
maxLoops: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowProgress({
|
|
||||||
steps,
|
|
||||||
status,
|
|
||||||
logs,
|
|
||||||
loop,
|
|
||||||
maxLoops,
|
|
||||||
}: WorkflowProgressProps) {
|
|
||||||
const currentIndex = status?.current_step_index ?? 0;
|
|
||||||
const isRunning = status?.status === "running";
|
|
||||||
const isPaused = status?.status === "paused";
|
|
||||||
const isActive = isRunning || isPaused;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Summary bar */}
|
|
||||||
<div className="flex items-center gap-4 flex-wrap text-sm">
|
|
||||||
{status && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Step{" "}
|
|
||||||
<strong className="text-foreground">
|
|
||||||
{currentIndex + 1}/{steps.length}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
{loop && (
|
|
||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
|
||||||
<Repeat className="size-3" />
|
|
||||||
Loop{" "}
|
|
||||||
<strong className="text-foreground">
|
|
||||||
{status.loop_count}
|
|
||||||
{maxLoops > 0 && `/${maxLoops}`}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Actions:{" "}
|
|
||||||
<strong className="text-foreground">
|
|
||||||
{status.total_actions_count.toLocaleString()}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
{status.strategy_state && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{status.strategy_state}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step timeline */}
|
|
||||||
<div className="space-y-0">
|
|
||||||
{steps.map((step, idx) => {
|
|
||||||
const isCompleted = isActive && idx < currentIndex;
|
|
||||||
const isCurrent = isActive && idx === currentIndex;
|
|
||||||
const isPending = !isActive || idx > currentIndex;
|
|
||||||
const Icon = STRATEGY_ICONS[step.strategy_type] ?? Circle;
|
|
||||||
const color = STRATEGY_COLORS[step.strategy_type] ?? "text-muted-foreground";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={step.id} className="flex items-stretch gap-3">
|
|
||||||
{/* Vertical connector */}
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
|
|
||||||
isCompleted &&
|
|
||||||
"bg-green-600/20 border-green-600 text-green-400",
|
|
||||||
isCurrent &&
|
|
||||||
"bg-primary/20 border-primary text-primary animate-pulse",
|
|
||||||
isPending &&
|
|
||||||
"bg-muted/50 border-muted-foreground/30 text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCompleted ? (
|
|
||||||
<CheckCircle2 className="size-4" />
|
|
||||||
) : isCurrent && isRunning ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Icon className={cn("size-4", isCurrent ? "" : isPending ? "opacity-50" : color)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{idx < steps.length - 1 && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-0.5 flex-1 min-h-4",
|
|
||||||
isCompleted ? "bg-green-600/50" : "bg-muted-foreground/20"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step content */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex-1 pb-4",
|
|
||||||
isPending && "opacity-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium",
|
|
||||||
isCurrent && "text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{step.name || `Step ${idx + 1}`}
|
|
||||||
</span>
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{step.strategy_type}
|
|
||||||
</Badge>
|
|
||||||
{isCurrent && status && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
({status.step_actions_count} actions)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{step.transition && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Transition: {step.transition.type.replace(/_/g, " ")}
|
|
||||||
{step.transition.operator && ` ${step.transition.operator}`}
|
|
||||||
{step.transition.value !== undefined &&
|
|
||||||
` ${step.transition.value}`}
|
|
||||||
{step.transition.item_code &&
|
|
||||||
` (${step.transition.item_code})`}
|
|
||||||
{step.transition.skill && ` (${step.transition.skill})`}
|
|
||||||
{step.transition.seconds !== undefined &&
|
|
||||||
step.transition.type === "timer" &&
|
|
||||||
` ${step.transition.seconds}s`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Loop indicator */}
|
|
||||||
{loop && (
|
|
||||||
<div className="flex items-center gap-3 pl-3">
|
|
||||||
<Repeat className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loop back to Step 1
|
|
||||||
{maxLoops > 0 && ` (max ${maxLoops} loops)`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live logs */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Live Logs</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<LogStream logs={logs} maxHeight="400px" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
Trash2,
|
|
||||||
Swords,
|
|
||||||
Pickaxe,
|
|
||||||
Hammer,
|
|
||||||
TrendingUp,
|
|
||||||
ClipboardList,
|
|
||||||
GraduationCap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
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 { TransitionEditor } from "@/components/workflow/transition-editor";
|
|
||||||
import type { WorkflowStep, TransitionCondition } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type StrategyType =
|
|
||||||
| "combat"
|
|
||||||
| "gathering"
|
|
||||||
| "crafting"
|
|
||||||
| "trading"
|
|
||||||
| "task"
|
|
||||||
| "leveling";
|
|
||||||
|
|
||||||
const STRATEGY_META: Record<
|
|
||||||
string,
|
|
||||||
{ icon: React.ElementType; color: string; label: string }
|
|
||||||
> = {
|
|
||||||
combat: { icon: Swords, color: "text-red-400", label: "Combat" },
|
|
||||||
gathering: { icon: Pickaxe, color: "text-green-400", label: "Gathering" },
|
|
||||||
crafting: { icon: Hammer, color: "text-blue-400", label: "Crafting" },
|
|
||||||
trading: { icon: TrendingUp, color: "text-yellow-400", label: "Trading" },
|
|
||||||
task: { icon: ClipboardList, color: "text-purple-400", label: "Task" },
|
|
||||||
leveling: { icon: GraduationCap, color: "text-cyan-400", label: "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>,
|
|
||||||
};
|
|
||||||
|
|
||||||
function configSummary(step: WorkflowStep): string {
|
|
||||||
const c = step.config;
|
|
||||||
switch (step.strategy_type) {
|
|
||||||
case "combat":
|
|
||||||
return c.monster_code
|
|
||||||
? `${c.monster_code}`
|
|
||||||
: "auto-select";
|
|
||||||
case "gathering":
|
|
||||||
return c.resource_code
|
|
||||||
? `${c.resource_code}`
|
|
||||||
: "auto-select";
|
|
||||||
case "crafting":
|
|
||||||
return c.item_code
|
|
||||||
? `${c.item_code} x${c.quantity ?? 1}`
|
|
||||||
: "no item set";
|
|
||||||
case "trading":
|
|
||||||
return `${c.mode ?? "sell_loot"} ${c.item_code ?? ""}`.trim();
|
|
||||||
case "task":
|
|
||||||
return "auto tasks";
|
|
||||||
case "leveling":
|
|
||||||
return c.target_skill ? `${c.target_skill}` : "auto";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function transitionBadge(t: TransitionCondition | null): string {
|
|
||||||
if (!t) return "run to completion";
|
|
||||||
switch (t.type) {
|
|
||||||
case "strategy_complete":
|
|
||||||
return "on complete";
|
|
||||||
case "inventory_full":
|
|
||||||
return "inv full";
|
|
||||||
case "actions_count":
|
|
||||||
return `${t.operator} ${t.value} actions`;
|
|
||||||
case "skill_level":
|
|
||||||
return `${t.skill} ${t.operator} ${t.value}`;
|
|
||||||
case "inventory_item_count":
|
|
||||||
return `inv ${t.item_code} ${t.operator} ${t.value}`;
|
|
||||||
case "bank_item_count":
|
|
||||||
return `bank ${t.item_code} ${t.operator} ${t.value}`;
|
|
||||||
case "gold_amount":
|
|
||||||
return `gold ${t.operator} ${t.value}`;
|
|
||||||
case "timer":
|
|
||||||
return `${t.seconds}s timer`;
|
|
||||||
default:
|
|
||||||
return t.type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkflowStepCardProps {
|
|
||||||
step: WorkflowStep;
|
|
||||||
index: number;
|
|
||||||
totalSteps: number;
|
|
||||||
onChange: (step: WorkflowStep) => void;
|
|
||||||
onMoveUp: () => void;
|
|
||||||
onMoveDown: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowStepCard({
|
|
||||||
step,
|
|
||||||
index,
|
|
||||||
totalSteps,
|
|
||||||
onChange,
|
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
onDelete,
|
|
||||||
}: WorkflowStepCardProps) {
|
|
||||||
const [expanded, setExpanded] = useState(!step.strategy_type);
|
|
||||||
const meta = STRATEGY_META[step.strategy_type];
|
|
||||||
const Icon = meta?.icon ?? ClipboardList;
|
|
||||||
|
|
||||||
function updateStep(partial: Partial<WorkflowStep>) {
|
|
||||||
onChange({ ...step, ...partial });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStrategyChange(strategy: StrategyType) {
|
|
||||||
onChange({
|
|
||||||
...step,
|
|
||||||
strategy_type: strategy,
|
|
||||||
config: DEFAULT_CONFIGS[strategy],
|
|
||||||
name: step.name || `${STRATEGY_META[strategy]?.label ?? strategy} Step`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
{/* Collapsed header */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-accent/30 transition-colors"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary text-xs font-bold">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<Icon className={cn("size-4 shrink-0", meta?.color)} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="text-sm font-medium truncate block">
|
|
||||||
{step.name || "Untitled Step"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground truncate block">
|
|
||||||
{meta?.label ?? step.strategy_type} · {configSummary(step)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
|
||||||
{transitionBadge(step.transition)}
|
|
||||||
</Badge>
|
|
||||||
{expanded ? (
|
|
||||||
<ChevronUp className="size-4 text-muted-foreground shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="size-4 text-muted-foreground shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expanded body */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t px-4 py-4 space-y-5">
|
|
||||||
{/* Step name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">Step Name</Label>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={step.name}
|
|
||||||
placeholder="e.g. Gather Copper Ore"
|
|
||||||
onChange={(e) => updateStep({ name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Strategy selector */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">Strategy</Label>
|
|
||||||
<StrategySelector
|
|
||||||
value={step.strategy_type}
|
|
||||||
onChange={handleStrategyChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Config form */}
|
|
||||||
{step.strategy_type && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
Configuration
|
|
||||||
</Label>
|
|
||||||
<ConfigForm
|
|
||||||
strategyType={step.strategy_type}
|
|
||||||
config={step.config}
|
|
||||||
onChange={(config) => updateStep({ config })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Transition editor */}
|
|
||||||
<TransitionEditor
|
|
||||||
value={step.transition}
|
|
||||||
onChange={(transition) => updateStep({ transition })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Reorder / Delete controls */}
|
|
||||||
<div className="flex items-center gap-2 pt-2 border-t">
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={onMoveUp}
|
|
||||||
>
|
|
||||||
<ArrowUp className="size-3" />
|
|
||||||
Up
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
disabled={index === totalSteps - 1}
|
|
||||||
onClick={onMoveDown}
|
|
||||||
>
|
|
||||||
<ArrowDown className="size-3" />
|
|
||||||
Down
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline"
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3" />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
GitBranch,
|
|
||||||
Repeat,
|
|
||||||
Swords,
|
|
||||||
Pickaxe,
|
|
||||||
Hammer,
|
|
||||||
TrendingUp,
|
|
||||||
ClipboardList,
|
|
||||||
GraduationCap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import {
|
|
||||||
WORKFLOW_TEMPLATES,
|
|
||||||
WORKFLOW_CATEGORY_LABELS,
|
|
||||||
WORKFLOW_CATEGORY_COLORS,
|
|
||||||
} from "./workflow-templates";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const STRATEGY_ICONS: Record<string, React.ElementType> = {
|
|
||||||
combat: Swords,
|
|
||||||
gathering: Pickaxe,
|
|
||||||
crafting: Hammer,
|
|
||||||
trading: TrendingUp,
|
|
||||||
task: ClipboardList,
|
|
||||||
leveling: GraduationCap,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkflowTemplateGallery() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Separator />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<GitBranch className="size-5 text-muted-foreground" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
Workflow Templates
|
|
||||||
</h2>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Multi-step pipelines
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{WORKFLOW_TEMPLATES.map((template) => (
|
|
||||||
<Card
|
|
||||||
key={template.id}
|
|
||||||
className="cursor-pointer transition-all hover:shadow-md group py-0 overflow-hidden hover:border-primary/30"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(
|
|
||||||
`/automations/workflows/new?template=${template.id}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="h-1 bg-primary/40" />
|
|
||||||
<CardContent className="p-4 space-y-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<GitBranch className="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-semibold text-sm text-foreground group-hover:text-primary transition-colors">
|
|
||||||
{template.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{template.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"text-[10px] capitalize",
|
|
||||||
WORKFLOW_CATEGORY_COLORS[template.category]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{WORKFLOW_CATEGORY_LABELS[template.category] ??
|
|
||||||
template.category}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{template.steps.length} step
|
|
||||||
{template.steps.length !== 1 && "s"}
|
|
||||||
</Badge>
|
|
||||||
{template.loop && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] gap-0.5"
|
|
||||||
>
|
|
||||||
<Repeat className="size-2.5" />
|
|
||||||
Loop
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step mini-preview */}
|
|
||||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
||||||
{template.steps.map((step, idx) => {
|
|
||||||
const Icon =
|
|
||||||
STRATEGY_ICONS[step.strategy_type] ?? ClipboardList;
|
|
||||||
return (
|
|
||||||
<span key={step.id} className="flex items-center gap-0.5">
|
|
||||||
{idx > 0 && <span className="mx-0.5">→</span>}
|
|
||||||
<Icon className="size-3" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getAnalytics, getLogs } from "@/lib/api-client";
|
import { getAnalytics, getLogs } from "@/lib/api-client";
|
||||||
import type { AnalyticsData, PaginatedLogs } from "@/lib/types";
|
import type { AnalyticsData, ActionLog } from "@/lib/types";
|
||||||
|
|
||||||
export function useAnalytics(characterName?: string, hours?: number) {
|
export function useAnalytics(characterName?: string, hours?: number) {
|
||||||
return useQuery<AnalyticsData>({
|
return useQuery<AnalyticsData>({
|
||||||
|
|
@ -12,21 +12,10 @@ export function useAnalytics(characterName?: string, hours?: number) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLogs(filters?: {
|
export function useLogs(filters?: { character?: string; type?: string }) {
|
||||||
character?: string;
|
return useQuery<ActionLog[]>({
|
||||||
type?: string;
|
queryKey: ["logs", filters?.character, filters?.type],
|
||||||
page?: number;
|
queryFn: () => getLogs(filters?.character),
|
||||||
size?: number;
|
|
||||||
}) {
|
|
||||||
return useQuery<PaginatedLogs>({
|
|
||||||
queryKey: ["logs", filters?.character, filters?.type, filters?.page, filters?.size],
|
|
||||||
queryFn: () =>
|
|
||||||
getLogs({
|
|
||||||
character: filters?.character,
|
|
||||||
type: filters?.type,
|
|
||||||
page: filters?.page,
|
|
||||||
size: filters?.size,
|
|
||||||
}),
|
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getAppErrors,
|
|
||||||
getErrorStats,
|
|
||||||
resolveError,
|
|
||||||
} from "@/lib/api-client";
|
|
||||||
import type { PaginatedErrors, ErrorStats, AppError } from "@/lib/types";
|
|
||||||
|
|
||||||
export function useErrors(filters?: {
|
|
||||||
severity?: string;
|
|
||||||
source?: string;
|
|
||||||
resolved?: string;
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
}) {
|
|
||||||
return useQuery<PaginatedErrors>({
|
|
||||||
queryKey: [
|
|
||||||
"app-errors",
|
|
||||||
filters?.severity,
|
|
||||||
filters?.source,
|
|
||||||
filters?.resolved,
|
|
||||||
filters?.page,
|
|
||||||
filters?.size,
|
|
||||||
],
|
|
||||||
queryFn: () => getAppErrors(filters),
|
|
||||||
refetchInterval: 15000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useErrorStats() {
|
|
||||||
return useQuery<ErrorStats>({
|
|
||||||
queryKey: ["app-errors-stats"],
|
|
||||||
queryFn: getErrorStats,
|
|
||||||
refetchInterval: 15000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResolveError() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation<AppError, Error, number>({
|
|
||||||
mutationFn: resolveError,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["app-errors"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["app-errors-stats"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getEvents, getEventHistory } from "@/lib/api-client";
|
import { getEvents, getEventHistory } from "@/lib/api-client";
|
||||||
import type { ActiveGameEvent, HistoricalEvent } from "@/lib/types";
|
import type { GameEvent } from "@/lib/types";
|
||||||
|
|
||||||
export function useEvents() {
|
export function useEvents() {
|
||||||
return useQuery<ActiveGameEvent[]>({
|
return useQuery<GameEvent[]>({
|
||||||
queryKey: ["events"],
|
queryKey: ["events"],
|
||||||
queryFn: getEvents,
|
queryFn: getEvents,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
|
|
@ -13,7 +13,7 @@ export function useEvents() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEventHistory() {
|
export function useEventHistory() {
|
||||||
return useQuery<HistoricalEvent[]>({
|
return useQuery<GameEvent[]>({
|
||||||
queryKey: ["events", "history"],
|
queryKey: ["events", "history"],
|
||||||
queryFn: getEventHistory,
|
queryFn: getEventHistory,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,10 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getExchangeOrders,
|
getExchangeOrders,
|
||||||
getMyOrders,
|
|
||||||
getExchangeHistory,
|
getExchangeHistory,
|
||||||
getSellHistory,
|
|
||||||
getPriceHistory,
|
getPriceHistory,
|
||||||
} from "@/lib/api-client";
|
} from "@/lib/api-client";
|
||||||
import type { GEOrder, GEHistoryEntry, PricePoint } from "@/lib/types";
|
import type { GEOrder, PricePoint } from "@/lib/types";
|
||||||
|
|
||||||
export function useExchangeOrders() {
|
export function useExchangeOrders() {
|
||||||
return useQuery<GEOrder[]>({
|
return useQuery<GEOrder[]>({
|
||||||
|
|
@ -18,31 +16,14 @@ export function useExchangeOrders() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMyOrders() {
|
|
||||||
return useQuery<GEOrder[]>({
|
|
||||||
queryKey: ["exchange", "my-orders"],
|
|
||||||
queryFn: getMyOrders,
|
|
||||||
refetchInterval: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExchangeHistory() {
|
export function useExchangeHistory() {
|
||||||
return useQuery<GEHistoryEntry[]>({
|
return useQuery<GEOrder[]>({
|
||||||
queryKey: ["exchange", "history"],
|
queryKey: ["exchange", "history"],
|
||||||
queryFn: getExchangeHistory,
|
queryFn: getExchangeHistory,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSellHistory(itemCode: string) {
|
|
||||||
return useQuery<GEHistoryEntry[]>({
|
|
||||||
queryKey: ["exchange", "sell-history", itemCode],
|
|
||||||
queryFn: () => getSellHistory(itemCode),
|
|
||||||
enabled: !!itemCode,
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePriceHistory(itemCode: string) {
|
export function usePriceHistory(itemCode: string) {
|
||||||
return useQuery<PricePoint[]>({
|
return useQuery<PricePoint[]>({
|
||||||
queryKey: ["exchange", "prices", itemCode],
|
queryKey: ["exchange", "prices", itemCode],
|
||||||
|
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getPipelines,
|
|
||||||
getPipeline,
|
|
||||||
getPipelineStatuses,
|
|
||||||
getPipelineLogs,
|
|
||||||
createPipeline,
|
|
||||||
updatePipeline,
|
|
||||||
deletePipeline,
|
|
||||||
startPipeline,
|
|
||||||
stopPipeline,
|
|
||||||
pausePipeline,
|
|
||||||
resumePipeline,
|
|
||||||
} from "@/lib/api-client";
|
|
||||||
import type {
|
|
||||||
PipelineConfig,
|
|
||||||
PipelineRun,
|
|
||||||
PipelineStatus,
|
|
||||||
AutomationLog,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
export function usePipelines() {
|
|
||||||
return useQuery<PipelineConfig[]>({
|
|
||||||
queryKey: ["pipelines"],
|
|
||||||
queryFn: getPipelines,
|
|
||||||
refetchInterval: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePipeline(id: number) {
|
|
||||||
return useQuery<{ config: PipelineConfig; runs: PipelineRun[] }>({
|
|
||||||
queryKey: ["pipeline", id],
|
|
||||||
queryFn: () => getPipeline(id),
|
|
||||||
refetchInterval: 5000,
|
|
||||||
enabled: id > 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePipelineStatuses() {
|
|
||||||
return useQuery<PipelineStatus[]>({
|
|
||||||
queryKey: ["pipelineStatuses"],
|
|
||||||
queryFn: getPipelineStatuses,
|
|
||||||
refetchInterval: 3000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePipelineLogs(id: number) {
|
|
||||||
return useQuery<AutomationLog[]>({
|
|
||||||
queryKey: ["pipelineLogs", id],
|
|
||||||
queryFn: () => getPipelineLogs(id),
|
|
||||||
refetchInterval: 3000,
|
|
||||||
enabled: id > 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreatePipeline() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
stages: Record<string, unknown>[];
|
|
||||||
loop?: boolean;
|
|
||||||
max_loops?: number;
|
|
||||||
}) => createPipeline(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdatePipeline() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
id,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
id: number;
|
|
||||||
data: Partial<PipelineConfig>;
|
|
||||||
}) => updatePipeline(id, data),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["pipeline", variables.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeletePipeline() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: number) => deletePipeline(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useControlPipeline() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
id,
|
|
||||||
action,
|
|
||||||
}: {
|
|
||||||
id: number;
|
|
||||||
action: "start" | "stop" | "pause" | "resume";
|
|
||||||
}) => {
|
|
||||||
switch (action) {
|
|
||||||
case "start":
|
|
||||||
await startPipeline(id);
|
|
||||||
return;
|
|
||||||
case "stop":
|
|
||||||
return stopPipeline(id);
|
|
||||||
case "pause":
|
|
||||||
return pausePipeline(id);
|
|
||||||
case "resume":
|
|
||||||
return resumePipeline(id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["pipeline", variables.id],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pipelineStatuses"] });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["pipelineLogs", variables.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getWorkflows,
|
|
||||||
getWorkflow,
|
|
||||||
getWorkflowStatuses,
|
|
||||||
getWorkflowLogs,
|
|
||||||
createWorkflow,
|
|
||||||
updateWorkflow,
|
|
||||||
deleteWorkflow,
|
|
||||||
startWorkflow,
|
|
||||||
stopWorkflow,
|
|
||||||
pauseWorkflow,
|
|
||||||
resumeWorkflow,
|
|
||||||
} from "@/lib/api-client";
|
|
||||||
import type {
|
|
||||||
WorkflowConfig,
|
|
||||||
WorkflowRun,
|
|
||||||
WorkflowStatus,
|
|
||||||
AutomationLog,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
export function useWorkflows() {
|
|
||||||
return useQuery<WorkflowConfig[]>({
|
|
||||||
queryKey: ["workflows"],
|
|
||||||
queryFn: getWorkflows,
|
|
||||||
refetchInterval: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflow(id: number) {
|
|
||||||
return useQuery<{ config: WorkflowConfig; runs: WorkflowRun[] }>({
|
|
||||||
queryKey: ["workflow", id],
|
|
||||||
queryFn: () => getWorkflow(id),
|
|
||||||
refetchInterval: 5000,
|
|
||||||
enabled: id > 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowStatuses() {
|
|
||||||
return useQuery<WorkflowStatus[]>({
|
|
||||||
queryKey: ["workflowStatuses"],
|
|
||||||
queryFn: getWorkflowStatuses,
|
|
||||||
refetchInterval: 3000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowLogs(id: number) {
|
|
||||||
return useQuery<AutomationLog[]>({
|
|
||||||
queryKey: ["workflowLogs", id],
|
|
||||||
queryFn: () => getWorkflowLogs(id),
|
|
||||||
refetchInterval: 3000,
|
|
||||||
enabled: id > 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWorkflow() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: {
|
|
||||||
name: string;
|
|
||||||
character_name: string;
|
|
||||||
description?: string;
|
|
||||||
steps: Record<string, unknown>[];
|
|
||||||
loop?: boolean;
|
|
||||||
max_loops?: number;
|
|
||||||
}) => createWorkflow(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateWorkflow() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
id,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
id: number;
|
|
||||||
data: Partial<WorkflowConfig>;
|
|
||||||
}) => updateWorkflow(id, data),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["workflow", variables.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteWorkflow() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: number) => deleteWorkflow(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useControlWorkflow() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
id,
|
|
||||||
action,
|
|
||||||
}: {
|
|
||||||
id: number;
|
|
||||||
action: "start" | "stop" | "pause" | "resume";
|
|
||||||
}) => {
|
|
||||||
switch (action) {
|
|
||||||
case "start":
|
|
||||||
await startWorkflow(id);
|
|
||||||
return;
|
|
||||||
case "stop":
|
|
||||||
return stopWorkflow(id);
|
|
||||||
case "pause":
|
|
||||||
return pauseWorkflow(id);
|
|
||||||
case "resume":
|
|
||||||
return resumeWorkflow(id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["workflow", variables.id],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["workflowStatuses"] });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["workflowLogs", variables.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -10,49 +10,17 @@ import type {
|
||||||
AutomationRun,
|
AutomationRun,
|
||||||
AutomationLog,
|
AutomationLog,
|
||||||
AutomationStatus,
|
AutomationStatus,
|
||||||
WorkflowConfig,
|
|
||||||
WorkflowRun,
|
|
||||||
WorkflowStatus,
|
|
||||||
PipelineConfig,
|
|
||||||
PipelineRun,
|
|
||||||
PipelineStatus,
|
|
||||||
GEOrder,
|
GEOrder,
|
||||||
GEHistoryEntry,
|
|
||||||
PricePoint,
|
PricePoint,
|
||||||
ActiveGameEvent,
|
GameEvent,
|
||||||
HistoricalEvent,
|
|
||||||
ActionLog,
|
ActionLog,
|
||||||
PaginatedLogs,
|
|
||||||
AnalyticsData,
|
AnalyticsData,
|
||||||
PaginatedErrors,
|
|
||||||
ErrorStats,
|
|
||||||
AppError,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
const STORAGE_KEY = "artifacts-api-token";
|
|
||||||
|
|
||||||
/** Read the user's API token from localStorage (browser-only). */
|
|
||||||
function getStoredToken(): string | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
return localStorage.getItem(STORAGE_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build headers including the per-user API token when available. */
|
|
||||||
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = { ...extra };
|
|
||||||
const token = getStoredToken();
|
|
||||||
if (token) {
|
|
||||||
headers["X-API-Token"] = token;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchApi<T>(path: string): Promise<T> {
|
async function fetchApi<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`);
|
||||||
headers: authHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||||
|
|
@ -64,7 +32,7 @@ async function fetchApi<T>(path: string): Promise<T> {
|
||||||
async function postApi<T>(path: string, body?: unknown): Promise<T> {
|
async function postApi<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: { "Content-Type": "application/json" },
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -85,7 +53,7 @@ async function postApi<T>(path: string, body?: unknown): Promise<T> {
|
||||||
async function putApi<T>(path: string, body?: unknown): Promise<T> {
|
async function putApi<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: { "Content-Type": "application/json" },
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -106,7 +74,6 @@ async function putApi<T>(path: string, body?: unknown): Promise<T> {
|
||||||
async function deleteApi(path: string): Promise<void> {
|
async function deleteApi(path: string): Promise<void> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: authHeaders(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -142,7 +109,6 @@ export async function setAuthToken(token: string): Promise<SetTokenResponse> {
|
||||||
export async function clearAuthToken(): Promise<AuthStatus> {
|
export async function clearAuthToken(): Promise<AuthStatus> {
|
||||||
const response = await fetch(`${API_URL}/api/auth/token`, {
|
const response = await fetch(`${API_URL}/api/auth/token`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: authHeaders(),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API error: ${response.status}`);
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
|
@ -251,143 +217,6 @@ export function getAutomationLogs(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Workflow API ----------
|
|
||||||
|
|
||||||
export function getWorkflows(): Promise<WorkflowConfig[]> {
|
|
||||||
return fetchApi<WorkflowConfig[]>("/api/workflows");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWorkflow(
|
|
||||||
id: number
|
|
||||||
): Promise<{ config: WorkflowConfig; runs: WorkflowRun[] }> {
|
|
||||||
return fetchApi<{ config: WorkflowConfig; runs: WorkflowRun[] }>(
|
|
||||||
`/api/workflows/${id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWorkflow(data: {
|
|
||||||
name: string;
|
|
||||||
character_name: string;
|
|
||||||
description?: string;
|
|
||||||
steps: Record<string, unknown>[];
|
|
||||||
loop?: boolean;
|
|
||||||
max_loops?: number;
|
|
||||||
}): Promise<WorkflowConfig> {
|
|
||||||
return postApi<WorkflowConfig>("/api/workflows", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateWorkflow(
|
|
||||||
id: number,
|
|
||||||
data: Partial<WorkflowConfig>
|
|
||||||
): Promise<WorkflowConfig> {
|
|
||||||
return putApi<WorkflowConfig>(`/api/workflows/${id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteWorkflow(id: number): Promise<void> {
|
|
||||||
return deleteApi(`/api/workflows/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startWorkflow(id: number): Promise<WorkflowRun> {
|
|
||||||
return postApi<WorkflowRun>(`/api/workflows/${id}/start`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopWorkflow(id: number): Promise<void> {
|
|
||||||
return postApi(`/api/workflows/${id}/stop`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pauseWorkflow(id: number): Promise<void> {
|
|
||||||
return postApi(`/api/workflows/${id}/pause`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resumeWorkflow(id: number): Promise<void> {
|
|
||||||
return postApi(`/api/workflows/${id}/resume`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWorkflowStatuses(): Promise<WorkflowStatus[]> {
|
|
||||||
return fetchApi<WorkflowStatus[]>("/api/workflows/status/all");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWorkflowStatus(id: number): Promise<WorkflowStatus> {
|
|
||||||
return fetchApi<WorkflowStatus>(`/api/workflows/${id}/status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWorkflowLogs(
|
|
||||||
id: number,
|
|
||||||
limit: number = 100
|
|
||||||
): Promise<AutomationLog[]> {
|
|
||||||
return fetchApi<AutomationLog[]>(
|
|
||||||
`/api/workflows/${id}/logs?limit=${limit}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Pipeline API ----------
|
|
||||||
|
|
||||||
export function getPipelines(): Promise<PipelineConfig[]> {
|
|
||||||
return fetchApi<PipelineConfig[]>("/api/pipelines");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPipeline(
|
|
||||||
id: number
|
|
||||||
): Promise<{ config: PipelineConfig; runs: PipelineRun[] }> {
|
|
||||||
return fetchApi<{ config: PipelineConfig; runs: PipelineRun[] }>(
|
|
||||||
`/api/pipelines/${id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPipeline(data: {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
stages: Record<string, unknown>[];
|
|
||||||
loop?: boolean;
|
|
||||||
max_loops?: number;
|
|
||||||
}): Promise<PipelineConfig> {
|
|
||||||
return postApi<PipelineConfig>("/api/pipelines", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updatePipeline(
|
|
||||||
id: number,
|
|
||||||
data: Partial<PipelineConfig>
|
|
||||||
): Promise<PipelineConfig> {
|
|
||||||
return putApi<PipelineConfig>(`/api/pipelines/${id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deletePipeline(id: number): Promise<void> {
|
|
||||||
return deleteApi(`/api/pipelines/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startPipeline(id: number): Promise<PipelineRun> {
|
|
||||||
return postApi<PipelineRun>(`/api/pipelines/${id}/start`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopPipeline(id: number): Promise<void> {
|
|
||||||
return postApi(`/api/pipelines/${id}/stop`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pausePipeline(id: number): Promise<void> {
|
|
||||||
return postApi(`/api/pipelines/${id}/pause`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resumePipeline(id: number): Promise<void> {
|
|
||||||
return postApi(`/api/pipelines/${id}/resume`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPipelineStatuses(): Promise<PipelineStatus[]> {
|
|
||||||
return fetchApi<PipelineStatus[]>("/api/pipelines/status/all");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPipelineStatus(id: number): Promise<PipelineStatus> {
|
|
||||||
return fetchApi<PipelineStatus>(`/api/pipelines/${id}/status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPipelineLogs(
|
|
||||||
id: number,
|
|
||||||
limit: number = 100
|
|
||||||
): Promise<AutomationLog[]> {
|
|
||||||
return fetchApi<AutomationLog[]>(
|
|
||||||
`/api/pipelines/${id}/logs?limit=${limit}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Grand Exchange API ----------
|
// ---------- Grand Exchange API ----------
|
||||||
|
|
||||||
export async function getExchangeOrders(): Promise<GEOrder[]> {
|
export async function getExchangeOrders(): Promise<GEOrder[]> {
|
||||||
|
|
@ -395,20 +224,8 @@ export async function getExchangeOrders(): Promise<GEOrder[]> {
|
||||||
return res.orders;
|
return res.orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMyOrders(): Promise<GEOrder[]> {
|
export async function getExchangeHistory(): Promise<GEOrder[]> {
|
||||||
const res = await fetchApi<{ orders: GEOrder[] }>("/api/exchange/my-orders");
|
const res = await fetchApi<{ history: GEOrder[] }>("/api/exchange/history");
|
||||||
return res.orders;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getExchangeHistory(): Promise<GEHistoryEntry[]> {
|
|
||||||
const res = await fetchApi<{ history: GEHistoryEntry[] }>("/api/exchange/history");
|
|
||||||
return res.history;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSellHistory(itemCode: string): Promise<GEHistoryEntry[]> {
|
|
||||||
const res = await fetchApi<{ history: GEHistoryEntry[] }>(
|
|
||||||
`/api/exchange/sell-history/${encodeURIComponent(itemCode)}`
|
|
||||||
);
|
|
||||||
return res.history;
|
return res.history;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -421,37 +238,24 @@ export async function getPriceHistory(itemCode: string): Promise<PricePoint[]> {
|
||||||
|
|
||||||
// ---------- Events API ----------
|
// ---------- Events API ----------
|
||||||
|
|
||||||
export async function getEvents(): Promise<ActiveGameEvent[]> {
|
export async function getEvents(): Promise<GameEvent[]> {
|
||||||
const res = await fetchApi<{ events: ActiveGameEvent[] }>("/api/events");
|
const res = await fetchApi<{ events: GameEvent[] }>("/api/events");
|
||||||
return res.events;
|
return res.events;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventHistory(): Promise<HistoricalEvent[]> {
|
export async function getEventHistory(): Promise<GameEvent[]> {
|
||||||
const res = await fetchApi<{ events: HistoricalEvent[] }>("/api/events/history");
|
const res = await fetchApi<{ events: GameEvent[] }>("/api/events/history");
|
||||||
return res.events;
|
return res.events;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Logs & Analytics API ----------
|
// ---------- Logs & Analytics API ----------
|
||||||
|
|
||||||
export async function getLogs(filters?: {
|
export async function getLogs(characterName?: string): Promise<ActionLog[]> {
|
||||||
character?: string;
|
|
||||||
type?: string;
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
}): Promise<PaginatedLogs> {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.character) params.set("character", filters.character);
|
if (characterName) params.set("character", characterName);
|
||||||
if (filters?.type) params.set("type", filters.type);
|
|
||||||
if (filters?.page) params.set("page", filters.page.toString());
|
|
||||||
if (filters?.size) params.set("size", filters.size.toString());
|
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const data = await fetchApi<PaginatedLogs>(`/api/logs${qs ? `?${qs}` : ""}`);
|
const data = await fetchApi<ActionLog[] | { logs?: ActionLog[] }>(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||||
return {
|
return Array.isArray(data) ? data : (data?.logs ?? []);
|
||||||
logs: data.logs ?? [],
|
|
||||||
total: data.total ?? 0,
|
|
||||||
page: data.page ?? 1,
|
|
||||||
pages: data.pages ?? 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnalytics(
|
export function getAnalytics(
|
||||||
|
|
@ -477,39 +281,3 @@ export function executeAction(
|
||||||
{ action, params }
|
{ action, params }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- App Errors API ----------
|
|
||||||
|
|
||||||
export async function getAppErrors(filters?: {
|
|
||||||
severity?: string;
|
|
||||||
source?: string;
|
|
||||||
resolved?: string;
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
}): Promise<PaginatedErrors> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters?.severity) params.set("severity", filters.severity);
|
|
||||||
if (filters?.source) params.set("source", filters.source);
|
|
||||||
if (filters?.resolved) params.set("resolved", filters.resolved);
|
|
||||||
if (filters?.page) params.set("page", filters.page.toString());
|
|
||||||
if (filters?.size) params.set("size", filters.size.toString());
|
|
||||||
const qs = params.toString();
|
|
||||||
return fetchApi<PaginatedErrors>(`/api/errors${qs ? `?${qs}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getErrorStats(): Promise<ErrorStats> {
|
|
||||||
return fetchApi<ErrorStats>("/api/errors/stats");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveError(id: number): Promise<AppError> {
|
|
||||||
return postApi<AppError>(`/api/errors/${id}/resolve`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reportError(report: {
|
|
||||||
error_type: string;
|
|
||||||
message: string;
|
|
||||||
stack_trace?: string;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
}): Promise<void> {
|
|
||||||
return postApi("/api/errors/report", report);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ export const NAV_ITEMS = [
|
||||||
{ href: "/exchange", label: "Exchange", icon: "ArrowLeftRight" },
|
{ href: "/exchange", label: "Exchange", icon: "ArrowLeftRight" },
|
||||||
{ href: "/events", label: "Events", icon: "Zap" },
|
{ href: "/events", label: "Events", icon: "Zap" },
|
||||||
{ href: "/logs", label: "Logs", icon: "ScrollText" },
|
{ href: "/logs", label: "Logs", icon: "ScrollText" },
|
||||||
{ href: "/errors", label: "Errors", icon: "AlertTriangle" },
|
|
||||||
{ href: "/analytics", label: "Analytics", icon: "BarChart3" },
|
{ href: "/analytics", label: "Analytics", icon: "BarChart3" },
|
||||||
{ href: "/settings", label: "Settings", icon: "Settings" },
|
{ href: "/settings", label: "Settings", icon: "Settings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -251,183 +251,17 @@ export interface LevelingConfig {
|
||||||
target_skill?: string;
|
target_skill?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Workflow Types ----------
|
|
||||||
|
|
||||||
export interface TransitionCondition {
|
|
||||||
type:
|
|
||||||
| "strategy_complete"
|
|
||||||
| "loops_completed"
|
|
||||||
| "inventory_full"
|
|
||||||
| "inventory_item_count"
|
|
||||||
| "bank_item_count"
|
|
||||||
| "skill_level"
|
|
||||||
| "gold_amount"
|
|
||||||
| "actions_count"
|
|
||||||
| "timer";
|
|
||||||
operator: ">=" | "<=" | "==" | ">" | "<";
|
|
||||||
value: number;
|
|
||||||
item_code: string;
|
|
||||||
skill: string;
|
|
||||||
seconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowStep {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
strategy_type:
|
|
||||||
| "combat"
|
|
||||||
| "gathering"
|
|
||||||
| "crafting"
|
|
||||||
| "trading"
|
|
||||||
| "task"
|
|
||||||
| "leveling";
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
transition: TransitionCondition | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowConfig {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
character_name: string;
|
|
||||||
description: string;
|
|
||||||
steps: WorkflowStep[];
|
|
||||||
loop: boolean;
|
|
||||||
max_loops: number;
|
|
||||||
enabled: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowRun {
|
|
||||||
id: number;
|
|
||||||
workflow_id: number;
|
|
||||||
status: "running" | "paused" | "stopped" | "completed" | "error";
|
|
||||||
current_step_index: number;
|
|
||||||
current_step_id: string;
|
|
||||||
loop_count: number;
|
|
||||||
total_actions_count: number;
|
|
||||||
step_actions_count: number;
|
|
||||||
started_at: string;
|
|
||||||
stopped_at: string | null;
|
|
||||||
error_message: string | null;
|
|
||||||
step_history: Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowStatus {
|
|
||||||
workflow_id: number;
|
|
||||||
character_name: string;
|
|
||||||
status: string;
|
|
||||||
run_id: number | null;
|
|
||||||
current_step_index: number;
|
|
||||||
current_step_id: string;
|
|
||||||
total_steps: number;
|
|
||||||
loop_count: number;
|
|
||||||
total_actions_count: number;
|
|
||||||
step_actions_count: number;
|
|
||||||
strategy_state: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Pipeline Types ----------
|
|
||||||
|
|
||||||
export interface CharacterStep {
|
|
||||||
id: string;
|
|
||||||
character_name: string;
|
|
||||||
strategy_type:
|
|
||||||
| "combat"
|
|
||||||
| "gathering"
|
|
||||||
| "crafting"
|
|
||||||
| "trading"
|
|
||||||
| "task"
|
|
||||||
| "leveling";
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
transition: TransitionCondition | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PipelineStage {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
character_steps: CharacterStep[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PipelineConfig {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
stages: PipelineStage[];
|
|
||||||
loop: boolean;
|
|
||||||
max_loops: number;
|
|
||||||
enabled: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PipelineRun {
|
|
||||||
id: number;
|
|
||||||
pipeline_id: number;
|
|
||||||
status: "running" | "paused" | "stopped" | "completed" | "error";
|
|
||||||
current_stage_index: number;
|
|
||||||
current_stage_id: string;
|
|
||||||
loop_count: number;
|
|
||||||
total_actions_count: number;
|
|
||||||
character_states: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
status: string;
|
|
||||||
step_id: string;
|
|
||||||
actions_count: number;
|
|
||||||
strategy_state: string;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
stage_history: Record<string, unknown>[];
|
|
||||||
started_at: string;
|
|
||||||
stopped_at: string | null;
|
|
||||||
error_message: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PipelineCharacterState {
|
|
||||||
character_name: string;
|
|
||||||
status: string;
|
|
||||||
step_id: string;
|
|
||||||
actions_count: number;
|
|
||||||
strategy_state: string;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PipelineStatus {
|
|
||||||
pipeline_id: number;
|
|
||||||
status: string;
|
|
||||||
run_id: number | null;
|
|
||||||
current_stage_index: number;
|
|
||||||
current_stage_id: string;
|
|
||||||
total_stages: number;
|
|
||||||
loop_count: number;
|
|
||||||
total_actions_count: number;
|
|
||||||
character_states: PipelineCharacterState[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Grand Exchange Types ----------
|
// ---------- Grand Exchange Types ----------
|
||||||
|
|
||||||
export interface GEOrder {
|
export interface GEOrder {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
type: "buy" | "sell";
|
type: "buy" | "sell";
|
||||||
account?: string | null;
|
|
||||||
price: number;
|
price: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GEHistoryEntry {
|
|
||||||
order_id: string;
|
|
||||||
seller: string;
|
|
||||||
buyer: string;
|
|
||||||
code: string;
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
sold_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PricePoint {
|
export interface PricePoint {
|
||||||
item_code: string;
|
item_code: string;
|
||||||
buy_price: number;
|
buy_price: number;
|
||||||
|
|
@ -438,26 +272,6 @@ export interface PricePoint {
|
||||||
|
|
||||||
// ---------- Event Types ----------
|
// ---------- Event Types ----------
|
||||||
|
|
||||||
export interface ActiveGameEvent {
|
|
||||||
name: string;
|
|
||||||
code: string;
|
|
||||||
content: { type: string; code: string };
|
|
||||||
maps: { map_id: number; x: number; y: number; layer: string; skin: string }[];
|
|
||||||
duration: number;
|
|
||||||
rate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoricalEvent {
|
|
||||||
id: number;
|
|
||||||
event_type: string;
|
|
||||||
event_data: Record<string, unknown>;
|
|
||||||
character_name?: string;
|
|
||||||
map_x?: number;
|
|
||||||
map_y?: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use ActiveGameEvent or HistoricalEvent */
|
|
||||||
export interface GameEvent {
|
export interface GameEvent {
|
||||||
id?: string;
|
id?: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -474,14 +288,6 @@ export interface ActionLog {
|
||||||
details: Record<string, unknown>;
|
details: Record<string, unknown>;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
cooldown?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedLogs {
|
|
||||||
logs: ActionLog[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pages: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeSeriesPoint {
|
export interface TimeSeriesPoint {
|
||||||
|
|
@ -495,33 +301,3 @@ export interface AnalyticsData {
|
||||||
gold_history: TimeSeriesPoint[];
|
gold_history: TimeSeriesPoint[];
|
||||||
actions_per_hour: number;
|
actions_per_hour: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- App Error Types ----------
|
|
||||||
|
|
||||||
export interface AppError {
|
|
||||||
id: number;
|
|
||||||
severity: string;
|
|
||||||
source: string;
|
|
||||||
error_type: string;
|
|
||||||
message: string;
|
|
||||||
stack_trace?: string | null;
|
|
||||||
context?: Record<string, unknown> | null;
|
|
||||||
correlation_id?: string | null;
|
|
||||||
resolved: boolean;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedErrors {
|
|
||||||
errors: AppError[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorStats {
|
|
||||||
total: number;
|
|
||||||
unresolved: number;
|
|
||||||
last_hour: number;
|
|
||||||
by_severity: Record<string, number>;
|
|
||||||
by_source: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue