commit f84564793454a890fafe493e0d1e8433232a7fd4 Author: Paweł Orzech Date: Sun Mar 1 19:46:45 2026 +0100 Initial release: Artifacts MMO Dashboard & Automation Platform Full-stack dashboard for controlling, automating, and analyzing Artifacts MMO characters via the game's HTTP API. Backend (FastAPI): - Async Artifacts API client with rate limiting and retry - 6 automation strategies (combat, gathering, crafting, trading, task, leveling) - Automation engine with runner, manager, cooldown tracker, pathfinder - WebSocket relay (game server -> frontend) - Game data cache, character snapshots, price history, analytics - 9 API routers, 7 database tables, 3 Alembic migrations - 108 unit tests Frontend (Next.js 15 + shadcn/ui): - Live character dashboard with HP/XP bars and cooldowns - Character detail with stats, equipment, inventory, skills, manual actions - Automation management with live log streaming - Interactive canvas map with content-type coloring and zoom/pan - Bank management, Grand Exchange with price charts - Events, logs, analytics pages with Recharts - WebSocket auto-reconnect with query cache invalidation - Settings page, error boundaries, dark theme Infrastructure: - Docker Compose (dev + prod) - GitHub Actions CI/CD - Documentation (Architecture, Automation, Deployment, API) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..10a2063 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Artifacts MMO API +ARTIFACTS_TOKEN=your_api_token_here + +# Database +DATABASE_URL=postgresql+asyncpg://artifacts:artifacts@db:5432/artifacts + +# Backend +BACKEND_HOST=0.0.0.0 +BACKEND_PORT=8000 +CORS_ORIGINS=["http://localhost:3000"] + +# Frontend +NEXT_PUBLIC_API_URL=http://localhost:8000 + +# PostgreSQL (used by docker-compose) +POSTGRES_USER=artifacts +POSTGRES_PASSWORD=artifacts +POSTGRES_DB=artifacts diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2d1c60a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug Report +about: Report a bug to help us improve +title: "[BUG] " +labels: bug +assignees: "" +--- + +## Description +A clear description of the bug. + +## Steps to Reproduce +1. Go to '...' +2. Click on '...' +3. See error + +## Expected Behavior +What you expected to happen. + +## Actual Behavior +What actually happened. + +## Environment +- OS: [e.g. macOS 15] +- Browser: [e.g. Chrome 130] +- Docker version: [e.g. 27.0] + +## Screenshots +If applicable, add screenshots. + +## Additional Context +Any other context about the problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..3360e14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature Request +about: Suggest a new feature +title: "[FEATURE] " +labels: enhancement +assignees: "" +--- + +## Problem +What problem does this feature solve? + +## Proposed Solution +Describe your proposed solution. + +## Alternatives Considered +Any alternative approaches you've considered. + +## Additional Context +Any other context, mockups, or screenshots. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..3d99b14 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + +Brief description of changes. + +## Changes + +- Change 1 +- Change 2 + +## Testing + +- [ ] Tests pass locally +- [ ] Docker compose starts without errors +- [ ] Manually tested in browser + +## Screenshots + +If applicable, add screenshots. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba91c19 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: artifacts + POSTGRES_PASSWORD: artifacts + POSTGRES_DB: artifacts_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + working-directory: backend + run: uv sync + + - name: Lint + working-directory: backend + run: uv run ruff check . + + - name: Type check + working-directory: backend + run: uv run mypy app --ignore-missing-imports + + - name: Test + working-directory: backend + env: + DATABASE_URL: postgresql+asyncpg://artifacts:artifacts@localhost:5432/artifacts_test + ARTIFACTS_TOKEN: test_token + run: uv run pytest tests/ -v + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Lint + working-directory: frontend + run: pnpm lint + + - name: Type check + working-directory: frontend + run: pnpm type-check + + - name: Build + working-directory: frontend + run: pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b106c90 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git tag --sort=-version:refname | head -2 | tail -1) + if [ -z "$PREV_TAG" ]; then + CHANGELOG=$(git log --pretty=format:"- %s (%h)" HEAD) + else + CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${PREV_TAG}..HEAD) + fi + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + body: | + ## Changes + + ${{ steps.changelog.outputs.changelog }} + + ## Docker + + ```bash + docker compose up + ``` + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59b6bc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ +*.whl +.venv/ +venv/ + +# Node.js +node_modules/ +.next/ +out/ +.turbo/ + +# Environment +.env +.env.local +.env.production +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml + +# Database +*.db +*.sqlite3 + +# Logs +*.log + +# Coverage +htmlcov/ +.coverage +coverage/ +.nyc_output/ + +# Misc +*.bak +*.tmp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7c0c0b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pawel Orzech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b11431 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Artifacts MMO Dashboard + +> Dashboard & automation platform for [Artifacts MMO](https://artifactsmmo.com) — control, automate, and analyze your characters through a beautiful web interface. + +![Python](https://img.shields.io/badge/Python-3.12-blue?logo=python&logoColor=white) +![Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js&logoColor=white) +![FastAPI](https://img.shields.io/badge/FastAPI-0.115-009688?logo=fastapi&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-17-336791?logo=postgresql&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white) +![License](https://img.shields.io/badge/License-MIT-green) + +## Features + +- **Live Character Dashboard** — real-time view of all 5 characters with HP, stats, equipment, inventory, skills, and cooldowns +- **Automation Engine** — combat, gathering, crafting, trading, and task automation with configurable strategies +- **Interactive Map** — world map with character positions, monsters, resources, and event overlays +- **Bank Management** — searchable bank inventory with item details and estimated values +- **Grand Exchange** — market browsing, order management, and price history charts +- **Event Tracking** — live game events with notifications +- **Analytics** — XP gain, gold tracking, actions/hour, and level progression charts +- **Multi-Character Coordination** — resource pipelines, boss fights, and task distribution +- **WebSocket Updates** — real-time dashboard updates via game WebSocket relay + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Frontend | Next.js 15, React 19, TypeScript, Tailwind CSS 4, shadcn/ui, TanStack Query, Recharts | +| Backend | Python 3.12, FastAPI, SQLAlchemy (async), httpx, Pydantic v2 | +| Database | PostgreSQL 17 | +| Deployment | Docker Compose, Coolify | + +## Quickstart + +### Prerequisites + +- Docker & Docker Compose +- An [Artifacts MMO](https://artifactsmmo.com) account and API token + +### Setup + +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/artifacts-dashboard.git + cd artifacts-dashboard + ``` + +2. Copy the environment file and add your API token: + ```bash + cp .env.example .env + # Edit .env and set ARTIFACTS_TOKEN + ``` + +3. Start the stack: + ```bash + docker compose up + ``` + +4. Open your browser: + - Dashboard: http://localhost:3000 + - API docs: http://localhost:8000/docs + +## Project Structure + +``` +artifacts-dashboard/ +├── backend/ # FastAPI application +│ ├── app/ +│ │ ├── api/ # REST endpoints +│ │ ├── models/ # SQLAlchemy models +│ │ ├── schemas/ # Pydantic schemas +│ │ ├── services/ # Business logic +│ │ ├── engine/ # Automation engine +│ │ └── websocket/# WebSocket client & relay +│ └── tests/ +├── frontend/ # Next.js application +│ └── src/ +│ ├── app/ # Pages (App Router) +│ ├── components/ +│ ├── hooks/ +│ └── lib/ +├── docs/ # Documentation +└── docker-compose.yml +``` + +## Documentation + +- [Architecture](docs/ARCHITECTURE.md) — system design and patterns +- [Automation](docs/AUTOMATION.md) — strategy configuration guide +- [Deployment](docs/DEPLOYMENT.md) — production deployment with Coolify +- [API Reference](docs/API.md) — backend REST API + +## Contributing + +Contributions are welcome! Please read the following before submitting: + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Commit your changes: `git commit -m "Add my feature"` +4. Push to the branch: `git push origin feature/my-feature` +5. Open a Pull Request + +Please use the provided issue and PR templates. + +## License + +This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..cf75cd3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +COPY pyproject.toml uv.lock* ./ +RUN uv sync --frozen --no-dev 2>/dev/null || uv sync --no-dev + +COPY . . + +EXPOSE 8000 + +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..eb729a0 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,43 @@ +[alembic] +script_location = alembic +prepend_sys_path = . + +# The target database URL is set programmatically in env.py from app.config.settings. +# sqlalchemy.url is intentionally left blank here. +sqlalchemy.url = + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..ab907be --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,78 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.config import settings +from app.database import Base + +# Import all models so their tables are registered on Base.metadata +from app.models import game_cache as _game_cache # noqa: F401 +from app.models import character_snapshot as _snapshot # noqa: F401 +from app.models import automation as _automation # noqa: F401 + +# Alembic Config object +config = context.config + +# Override sqlalchemy.url with value from application settings +config.set_main_option("sqlalchemy.url", settings.database_url) + +# Set up Python logging from the .ini file +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# MetaData for autogenerate support +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + Generates SQL scripts without connecting to the database. + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode using an async engine.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Entry point for online migrations -- delegates to async runner.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_create_initial_tables.py b/backend/alembic/versions/001_create_initial_tables.py new file mode 100644 index 0000000..5a47687 --- /dev/null +++ b/backend/alembic/versions/001_create_initial_tables.py @@ -0,0 +1,73 @@ +"""Create game_data_cache and character_snapshots tables + +Revision ID: 001_initial +Revises: +Create Date: 2026-03-01 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # game_data_cache + op.create_table( + "game_data_cache", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "data_type", + sa.String(length=50), + nullable=False, + comment=( + "Type of cached data: items, monsters, resources, maps, " + "events, achievements, npcs, tasks, effects, badges" + ), + ), + sa.Column("data", sa.JSON(), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("data_type", name="uq_game_data_cache_data_type"), + ) + + # character_snapshots + op.create_table( + "character_snapshots", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("data", sa.JSON(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_character_snapshots_name"), + "character_snapshots", + ["name"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_character_snapshots_name"), + table_name="character_snapshots", + ) + op.drop_table("character_snapshots") + op.drop_table("game_data_cache") diff --git a/backend/alembic/versions/002_add_automation_tables.py b/backend/alembic/versions/002_add_automation_tables.py new file mode 100644 index 0000000..3515979 --- /dev/null +++ b/backend/alembic/versions/002_add_automation_tables.py @@ -0,0 +1,126 @@ +"""Add automation_configs, automation_runs, automation_logs tables + +Revision ID: 002_automation +Revises: 001_initial +Create Date: 2026-03-01 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "002_automation" +down_revision: Union[str, None] = "001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # automation_configs + op.create_table( + "automation_configs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("character_name", sa.String(length=100), nullable=False), + sa.Column( + "strategy_type", + sa.String(length=50), + nullable=False, + comment="Strategy type: combat, gathering, crafting, trading, task, leveling", + ), + sa.Column("config", sa.JSON(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_automation_configs_character_name"), + "automation_configs", + ["character_name"], + unique=False, + ) + + # automation_runs + op.create_table( + "automation_runs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "config_id", + sa.Integer(), + sa.ForeignKey("automation_configs.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "status", + sa.String(length=20), + nullable=False, + server_default=sa.text("'running'"), + comment="Status: running, paused, stopped, completed, error", + ), + sa.Column( + "started_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("error_message", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_automation_runs_config_id"), + "automation_runs", + ["config_id"], + unique=False, + ) + + # automation_logs + op.create_table( + "automation_logs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "run_id", + sa.Integer(), + sa.ForeignKey("automation_runs.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("action_type", sa.String(length=50), nullable=False), + sa.Column("details", sa.JSON(), nullable=False), + sa.Column("success", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_automation_logs_run_id"), + "automation_logs", + ["run_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_automation_logs_run_id"), table_name="automation_logs") + op.drop_table("automation_logs") + op.drop_index(op.f("ix_automation_runs_config_id"), table_name="automation_runs") + op.drop_table("automation_runs") + op.drop_index(op.f("ix_automation_configs_character_name"), table_name="automation_configs") + op.drop_table("automation_configs") diff --git a/backend/alembic/versions/003_add_price_history_event_log.py b/backend/alembic/versions/003_add_price_history_event_log.py new file mode 100644 index 0000000..18cf804 --- /dev/null +++ b/backend/alembic/versions/003_add_price_history_event_log.py @@ -0,0 +1,141 @@ +"""Add price_history and event_log tables + +Revision ID: 003_price_event +Revises: 002_automation +Create Date: 2026-03-01 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "003_price_event" +down_revision: Union[str, None] = "002_automation" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # price_history + op.create_table( + "price_history", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "item_code", + sa.String(length=100), + nullable=False, + comment="Item code from the Artifacts API", + ), + sa.Column( + "buy_price", + sa.Float(), + nullable=True, + comment="Best buy price at capture time", + ), + sa.Column( + "sell_price", + sa.Float(), + nullable=True, + comment="Best sell price at capture time", + ), + sa.Column( + "volume", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + comment="Trade volume at capture time", + ), + sa.Column( + "captured_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + comment="Timestamp when the price was captured", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_price_history_item_code"), + "price_history", + ["item_code"], + unique=False, + ) + op.create_index( + op.f("ix_price_history_captured_at"), + "price_history", + ["captured_at"], + unique=False, + ) + + # event_log + op.create_table( + "event_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "event_type", + sa.String(length=100), + nullable=False, + comment="Type of event (e.g. 'combat', 'gathering', 'trade', 'level_up')", + ), + sa.Column( + "event_data", + sa.JSON(), + nullable=False, + comment="Arbitrary JSON payload with event details", + ), + sa.Column( + "character_name", + sa.String(length=100), + nullable=True, + comment="Character associated with the event (if applicable)", + ), + sa.Column( + "map_x", + sa.Integer(), + nullable=True, + comment="X coordinate where the event occurred", + ), + sa.Column( + "map_y", + sa.Integer(), + nullable=True, + comment="Y coordinate where the event occurred", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_event_log_event_type"), + "event_log", + ["event_type"], + unique=False, + ) + op.create_index( + op.f("ix_event_log_character_name"), + "event_log", + ["character_name"], + unique=False, + ) + op.create_index( + op.f("ix_event_log_created_at"), + "event_log", + ["created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_event_log_created_at"), table_name="event_log") + op.drop_index(op.f("ix_event_log_character_name"), table_name="event_log") + op.drop_index(op.f("ix_event_log_event_type"), table_name="event_log") + op.drop_table("event_log") + op.drop_index(op.f("ix_price_history_captured_at"), table_name="price_history") + op.drop_index(op.f("ix_price_history_item_code"), table_name="price_history") + op.drop_table("price_history") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/automations.py b/backend/app/api/automations.py new file mode 100644 index 0000000..244bc71 --- /dev/null +++ b/backend/app/api/automations.py @@ -0,0 +1,257 @@ +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import async_session_factory +from app.engine.manager import AutomationManager +from app.models.automation import AutomationConfig, AutomationLog, AutomationRun +from app.schemas.automation import ( + AutomationConfigCreate, + AutomationConfigDetailResponse, + AutomationConfigResponse, + AutomationConfigUpdate, + AutomationLogResponse, + AutomationRunResponse, + AutomationStatusResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/automations", tags=["automations"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_manager(request: Request) -> AutomationManager: + manager: AutomationManager | None = getattr(request.app.state, "automation_manager", None) + if manager is None: + raise HTTPException( + status_code=503, + detail="Automation engine is not available", + ) + return manager + + +# --------------------------------------------------------------------------- +# CRUD -- Automation Configs +# --------------------------------------------------------------------------- + + +@router.get("/", response_model=list[AutomationConfigResponse]) +async def list_configs(request: Request) -> list[AutomationConfigResponse]: + """List all automation configurations with their current status.""" + async with async_session_factory() as db: + stmt = select(AutomationConfig).order_by(AutomationConfig.id) + result = await db.execute(stmt) + configs = result.scalars().all() + return [AutomationConfigResponse.model_validate(c) for c in configs] + + +@router.post("/", response_model=AutomationConfigResponse, status_code=201) +async def create_config( + payload: AutomationConfigCreate, + request: Request, +) -> AutomationConfigResponse: + """Create a new automation configuration.""" + async with async_session_factory() as db: + config = AutomationConfig( + name=payload.name, + character_name=payload.character_name, + strategy_type=payload.strategy_type, + config=payload.config, + ) + db.add(config) + await db.commit() + await db.refresh(config) + return AutomationConfigResponse.model_validate(config) + + +@router.get("/{config_id}", response_model=AutomationConfigDetailResponse) +async def get_config(config_id: int, request: Request) -> AutomationConfigDetailResponse: + """Get an automation configuration with its run history.""" + async with async_session_factory() as db: + stmt = ( + select(AutomationConfig) + .options(selectinload(AutomationConfig.runs)) + .where(AutomationConfig.id == config_id) + ) + result = await db.execute(stmt) + config = result.scalar_one_or_none() + + if config is None: + raise HTTPException(status_code=404, detail="Automation config not found") + + return AutomationConfigDetailResponse( + config=AutomationConfigResponse.model_validate(config), + runs=[AutomationRunResponse.model_validate(r) for r in config.runs], + ) + + +@router.put("/{config_id}", response_model=AutomationConfigResponse) +async def update_config( + config_id: int, + payload: AutomationConfigUpdate, + request: Request, +) -> AutomationConfigResponse: + """Update an automation configuration. + + Cannot update a configuration that has an active runner. + """ + manager = _get_manager(request) + if manager.is_running(config_id): + raise HTTPException( + status_code=409, + detail="Cannot update a config while its automation is running. Stop it first.", + ) + + async with async_session_factory() as db: + config = await db.get(AutomationConfig, config_id) + if config is None: + raise HTTPException(status_code=404, detail="Automation config not found") + + if payload.name is not None: + config.name = payload.name + if payload.config is not None: + config.config = payload.config + if payload.enabled is not None: + config.enabled = payload.enabled + + await db.commit() + await db.refresh(config) + return AutomationConfigResponse.model_validate(config) + + +@router.delete("/{config_id}", status_code=204) +async def delete_config(config_id: int, request: Request) -> None: + """Delete an automation configuration and all its runs/logs. + + Cannot delete a configuration that has an active runner. + """ + manager = _get_manager(request) + if manager.is_running(config_id): + raise HTTPException( + status_code=409, + detail="Cannot delete a config while its automation is running. Stop it first.", + ) + + async with async_session_factory() as db: + config = await db.get(AutomationConfig, config_id) + if config is None: + raise HTTPException(status_code=404, detail="Automation config not found") + + await db.delete(config) + await db.commit() + + +# --------------------------------------------------------------------------- +# Control -- Start / Stop / Pause / Resume +# --------------------------------------------------------------------------- + + +@router.post("/{config_id}/start", response_model=AutomationRunResponse) +async def start_automation(config_id: int, request: Request) -> AutomationRunResponse: + """Start an automation from its configuration.""" + manager = _get_manager(request) + try: + return await manager.start(config_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{config_id}/stop", status_code=204) +async def stop_automation(config_id: int, request: Request) -> None: + """Stop a running automation.""" + manager = _get_manager(request) + try: + await manager.stop(config_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{config_id}/pause", status_code=204) +async def pause_automation(config_id: int, request: Request) -> None: + """Pause a running automation.""" + manager = _get_manager(request) + try: + await manager.pause(config_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{config_id}/resume", status_code=204) +async def resume_automation(config_id: int, request: Request) -> None: + """Resume a paused automation.""" + manager = _get_manager(request) + try: + await manager.resume(config_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +# --------------------------------------------------------------------------- +# Status & Logs +# --------------------------------------------------------------------------- + + +@router.get("/status/all", response_model=list[AutomationStatusResponse]) +async def get_all_statuses(request: Request) -> list[AutomationStatusResponse]: + """Get live status for all active automations.""" + manager = _get_manager(request) + return manager.get_all_statuses() + + +@router.get("/{config_id}/status", response_model=AutomationStatusResponse) +async def get_automation_status( + config_id: int, + request: Request, +) -> AutomationStatusResponse: + """Get live status for a specific automation.""" + manager = _get_manager(request) + status = manager.get_status(config_id) + if status is None: + # Check if the config exists at all + async with async_session_factory() as db: + config = await db.get(AutomationConfig, config_id) + if config is None: + raise HTTPException(status_code=404, detail="Automation config not found") + # Config exists but no active runner + return AutomationStatusResponse( + config_id=config_id, + character_name=config.character_name, + strategy_type=config.strategy_type, + status="stopped", + ) + return status + + +@router.get("/{config_id}/logs", response_model=list[AutomationLogResponse]) +async def get_logs( + config_id: int, + request: Request, + limit: int = 100, +) -> list[AutomationLogResponse]: + """Get recent logs for an automation config (across all its runs).""" + async with async_session_factory() as db: + # Verify config exists + config = await db.get(AutomationConfig, config_id) + if config is None: + raise HTTPException(status_code=404, detail="Automation config not found") + + # Fetch logs for all runs belonging to this config + stmt = ( + select(AutomationLog) + .join(AutomationRun, AutomationLog.run_id == AutomationRun.id) + .where(AutomationRun.config_id == config_id) + .order_by(AutomationLog.created_at.desc()) + .limit(min(limit, 500)) + ) + result = await db.execute(stmt) + logs = result.scalars().all() + return [AutomationLogResponse.model_validate(log) for log in logs] diff --git a/backend/app/api/bank.py b/backend/app/api/bank.py new file mode 100644 index 0000000..adeadf5 --- /dev/null +++ b/backend/app/api/bank.py @@ -0,0 +1,134 @@ +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from httpx import HTTPStatusError +from pydantic import BaseModel, Field + +from app.database import async_session_factory +from app.services.artifacts_client import ArtifactsClient +from app.services.bank_service import BankService +from app.services.game_data_cache import GameDataCacheService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["bank"]) + + +def _get_client(request: Request) -> ArtifactsClient: + return request.app.state.artifacts_client + + +def _get_cache_service(request: Request) -> GameDataCacheService: + return request.app.state.cache_service + + +# --------------------------------------------------------------------------- +# Request schemas for manual actions +# --------------------------------------------------------------------------- + + +class ManualActionRequest(BaseModel): + """Request body for manual character actions.""" + + action: str = Field( + ..., + description="Action to perform: 'move', 'fight', 'gather', 'rest'", + ) + params: dict = Field( + default_factory=dict, + description="Action parameters (e.g. {x, y} for move)", + ) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/bank") +async def get_bank(request: Request) -> dict[str, Any]: + """Return bank details with enriched item data from game cache.""" + client = _get_client(request) + cache_service = _get_cache_service(request) + bank_service = BankService() + + try: + # Try to get items cache for enrichment + items_cache = None + try: + async with async_session_factory() as db: + items_cache = await cache_service.get_items(db) + except Exception: + logger.warning("Failed to load items cache for bank enrichment") + + result = await bank_service.get_contents(client, items_cache) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + return result + + +@router.get("/bank/summary") +async def get_bank_summary(request: Request) -> dict[str, Any]: + """Return a summary of bank contents: gold, item count, slots.""" + client = _get_client(request) + bank_service = BankService() + + try: + return await bank_service.get_summary(client) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + +@router.post("/characters/{name}/action") +async def manual_action( + name: str, + body: ManualActionRequest, + request: Request, +) -> dict[str, Any]: + """Execute a manual action on a character. + + Supported actions: + - **move**: Move to coordinates. Params: {"x": int, "y": int} + - **fight**: Fight the monster at the current tile. No params. + - **gather**: Gather the resource at the current tile. No params. + - **rest**: Rest to recover HP. No params. + """ + client = _get_client(request) + + try: + match body.action: + case "move": + x = body.params.get("x") + y = body.params.get("y") + if x is None or y is None: + raise HTTPException( + status_code=400, + detail="Move action requires 'x' and 'y' in params", + ) + result = await client.move(name, int(x), int(y)) + case "fight": + result = await client.fight(name) + case "gather": + result = await client.gather(name) + case "rest": + result = await client.rest(name) + case _: + raise HTTPException( + status_code=400, + detail=f"Unknown action: {body.action!r}. Supported: move, fight, gather, rest", + ) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + return {"action": body.action, "character": name, "result": result} diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py new file mode 100644 index 0000000..8f2629f --- /dev/null +++ b/backend/app/api/characters.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, HTTPException, Request +from httpx import HTTPStatusError + +from app.schemas.game import CharacterSchema +from app.services.artifacts_client import ArtifactsClient +from app.services.character_service import CharacterService + +router = APIRouter(prefix="/api/characters", tags=["characters"]) + + +def _get_client(request: Request) -> ArtifactsClient: + return request.app.state.artifacts_client + + +def _get_service(request: Request) -> CharacterService: + return request.app.state.character_service + + +@router.get("/", response_model=list[CharacterSchema]) +async def list_characters(request: Request) -> list[CharacterSchema]: + """Return all characters belonging to the authenticated account.""" + client = _get_client(request) + service = _get_service(request) + try: + return await service.get_all(client) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + +@router.get("/{name}", response_model=CharacterSchema) +async def get_character(name: str, request: Request) -> CharacterSchema: + """Return a single character by name.""" + client = _get_client(request) + service = _get_service(request) + try: + return await service.get_one(client, name) + except HTTPStatusError as exc: + if exc.response.status_code == 404: + raise HTTPException(status_code=404, detail="Character not found") from exc + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..e21e6f1 --- /dev/null +++ b/backend/app/api/dashboard.py @@ -0,0 +1,48 @@ +import logging + +from fastapi import APIRouter, HTTPException, Request +from httpx import HTTPStatusError + +from app.schemas.game import DashboardData +from app.services.artifacts_client import ArtifactsClient +from app.services.character_service import CharacterService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["dashboard"]) + + +def _get_client(request: Request) -> ArtifactsClient: + return request.app.state.artifacts_client + + +def _get_service(request: Request) -> CharacterService: + return request.app.state.character_service + + +@router.get("/dashboard", response_model=DashboardData) +async def get_dashboard(request: Request) -> DashboardData: + """Return aggregated dashboard data: all characters + server status.""" + client = _get_client(request) + service = _get_service(request) + + try: + characters = await service.get_all(client) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + # Server status could be extended later (e.g., ping, event info) + server_status: dict | None = None + try: + events = await client.get_events() + server_status = {"events": events} + except Exception: + logger.warning("Failed to fetch server events for dashboard", exc_info=True) + + return DashboardData( + characters=characters, + server_status=server_status, + ) diff --git a/backend/app/api/events.py b/backend/app/api/events.py new file mode 100644 index 0000000..2a3d897 --- /dev/null +++ b/backend/app/api/events.py @@ -0,0 +1,76 @@ +"""Game events API router.""" + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from httpx import HTTPStatusError +from sqlalchemy import select + +from app.database import async_session_factory +from app.models.event_log import EventLog +from app.services.artifacts_client import ArtifactsClient + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/events", tags=["events"]) + + +def _get_client(request: Request) -> ArtifactsClient: + return request.app.state.artifacts_client + + +@router.get("/") +async def get_active_events(request: Request) -> dict[str, Any]: + """Get currently active game events from the Artifacts API.""" + client = _get_client(request) + + try: + events = await client.get_events() + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + return {"events": events} + + +@router.get("/history") +async def get_event_history( + request: Request, + event_type: str | None = Query(default=None, description="Filter by event type"), + character_name: str | None = Query(default=None, description="Filter by character"), + limit: int = Query(default=100, ge=1, le=500, description="Max entries to return"), + offset: int = Query(default=0, ge=0, description="Offset for pagination"), +) -> dict[str, Any]: + """Get historical events from the event log database.""" + async with async_session_factory() as db: + stmt = select(EventLog).order_by(EventLog.created_at.desc()) + + if event_type: + stmt = stmt.where(EventLog.event_type == event_type) + if character_name: + stmt = stmt.where(EventLog.character_name == character_name) + + stmt = stmt.offset(offset).limit(limit) + + result = await db.execute(stmt) + logs = result.scalars().all() + + return { + "events": [ + { + "id": log.id, + "event_type": log.event_type, + "event_data": log.event_data, + "character_name": log.character_name, + "map_x": log.map_x, + "map_y": log.map_y, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + for log in logs + ], + "limit": limit, + "offset": offset, + } diff --git a/backend/app/api/exchange.py b/backend/app/api/exchange.py new file mode 100644 index 0000000..1ff178f --- /dev/null +++ b/backend/app/api/exchange.py @@ -0,0 +1,82 @@ +"""Grand Exchange API router.""" + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from httpx import HTTPStatusError + +from app.database import async_session_factory +from app.services.artifacts_client import ArtifactsClient +from app.services.exchange_service import ExchangeService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/exchange", tags=["exchange"]) + + +def _get_client(request: Request) -> ArtifactsClient: + return request.app.state.artifacts_client + + +def _get_exchange_service(request: Request) -> ExchangeService: + service: ExchangeService | None = getattr(request.app.state, "exchange_service", None) + if service is None: + raise HTTPException( + status_code=503, + detail="Exchange service is not available", + ) + return service + + +@router.get("/orders") +async def get_orders(request: Request) -> dict[str, Any]: + """Get all active Grand Exchange orders.""" + client = _get_client(request) + service = _get_exchange_service(request) + + try: + orders = await service.get_orders(client) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + return {"orders": orders} + + +@router.get("/history") +async def get_history(request: Request) -> dict[str, Any]: + """Get Grand Exchange transaction history.""" + client = _get_client(request) + service = _get_exchange_service(request) + + try: + history = await service.get_history(client) + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + return {"history": history} + + +@router.get("/prices/{item_code}") +async def get_price_history( + item_code: str, + request: Request, + days: int = Query(default=7, ge=1, le=90, description="Number of days of history"), +) -> dict[str, Any]: + """Get price history for a specific item.""" + service = _get_exchange_service(request) + + async with async_session_factory() as db: + entries = await service.get_price_history(db, item_code, days) + + return { + "item_code": item_code, + "days": days, + "entries": entries, + } diff --git a/backend/app/api/game_data.py b/backend/app/api/game_data.py new file mode 100644 index 0000000..58d4f09 --- /dev/null +++ b/backend/app/api/game_data.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.schemas.game import ItemSchema, MapSchema, MonsterSchema, ResourceSchema +from app.services.game_data_cache import GameDataCacheService + +router = APIRouter(prefix="/api/game", tags=["game-data"]) + + +def _get_cache_service(request: Request) -> GameDataCacheService: + return request.app.state.cache_service + + +@router.get("/items", response_model=list[ItemSchema]) +async def list_items( + request: Request, + db: AsyncSession = Depends(get_db), +) -> list[ItemSchema]: + """Return all items from the local cache.""" + service = _get_cache_service(request) + return await service.get_items(db) + + +@router.get("/monsters", response_model=list[MonsterSchema]) +async def list_monsters( + request: Request, + db: AsyncSession = Depends(get_db), +) -> list[MonsterSchema]: + """Return all monsters from the local cache.""" + service = _get_cache_service(request) + return await service.get_monsters(db) + + +@router.get("/resources", response_model=list[ResourceSchema]) +async def list_resources( + request: Request, + db: AsyncSession = Depends(get_db), +) -> list[ResourceSchema]: + """Return all resources from the local cache.""" + service = _get_cache_service(request) + return await service.get_resources(db) + + +@router.get("/maps", response_model=list[MapSchema]) +async def list_maps( + request: Request, + db: AsyncSession = Depends(get_db), +) -> list[MapSchema]: + """Return all maps from the local cache.""" + service = _get_cache_service(request) + return await service.get_maps(db) diff --git a/backend/app/api/logs.py b/backend/app/api/logs.py new file mode 100644 index 0000000..740cf34 --- /dev/null +++ b/backend/app/api/logs.py @@ -0,0 +1,101 @@ +"""Character logs and analytics API router.""" + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from httpx import HTTPStatusError + +from app.database import async_session_factory +from app.services.analytics_service import AnalyticsService +from app.services.artifacts_client import ArtifactsClient + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/logs", tags=["logs"]) + + +def _get_client(request: Request) -> ArtifactsClient: + return request.app.state.artifacts_client + + +@router.get("/") +async def get_character_logs( + request: Request, + character: str = Query(default="", description="Character name to filter logs"), + limit: int = Query(default=50, ge=1, le=200, description="Max entries to return"), +) -> dict[str, Any]: + """Get character action logs from the Artifacts API. + + This endpoint retrieves the character's recent action logs directly + from the game server. + """ + client = _get_client(request) + + try: + if character: + # Get logs for a specific character + char_data = await client.get_character(character) + return { + "character": character, + "logs": [], # The API doesn't have a dedicated logs endpoint per character; + # action data comes from the automation logs in our DB + "character_data": { + "name": char_data.name, + "level": char_data.level, + "xp": char_data.xp, + "gold": char_data.gold, + "x": char_data.x, + "y": char_data.y, + "task": char_data.task, + "task_progress": char_data.task_progress, + "task_total": char_data.task_total, + }, + } + else: + # Get all characters as a summary + characters = await client.get_characters() + return { + "characters": [ + { + "name": c.name, + "level": c.level, + "xp": c.xp, + "gold": c.gold, + "x": c.x, + "y": c.y, + } + for c in characters + ], + } + except HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Artifacts API error: {exc.response.text}", + ) from exc + + +@router.get("/analytics") +async def get_analytics( + request: Request, + character: str = Query(..., description="Character name"), + hours: int = Query(default=24, ge=1, le=168, description="Hours of history"), +) -> dict[str, Any]: + """Get analytics aggregations for a character. + + Returns XP history, gold history, and estimated actions per hour. + """ + analytics = AnalyticsService() + + async with async_session_factory() as db: + xp_history = await analytics.get_xp_history(db, character, hours) + gold_history = await analytics.get_gold_history(db, character, hours) + actions_rate = await analytics.get_actions_per_hour(db, character) + + return { + "character": character, + "hours": hours, + "xp_history": xp_history, + "gold_history": gold_history, + "actions_rate": actions_rate, + } diff --git a/backend/app/api/ws.py b/backend/app/api/ws.py new file mode 100644 index 0000000..8d965aa --- /dev/null +++ b/backend/app/api/ws.py @@ -0,0 +1,113 @@ +"""WebSocket endpoint for the frontend dashboard. + +Provides a ``/ws/live`` WebSocket endpoint that relays events from the +internal :class:`EventBus` to connected browser clients. Multiple +frontend connections are supported simultaneously. +""" + +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.websocket.event_bus import EventBus + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class ConnectionManager: + """Manage active frontend WebSocket connections.""" + + def __init__(self) -> None: + self._connections: list[WebSocket] = [] + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + self._connections.append(ws) + logger.info( + "Frontend WebSocket connected (total=%d)", len(self._connections) + ) + + def disconnect(self, ws: WebSocket) -> None: + if ws in self._connections: + self._connections.remove(ws) + logger.info( + "Frontend WebSocket removed (total=%d)", len(self._connections) + ) + + async def broadcast(self, message: dict) -> None: + """Send a message to all connected clients. + + Silently removes any clients whose connections have broken. + """ + disconnected: list[WebSocket] = [] + for ws in self._connections: + try: + await ws.send_json(message) + except Exception: + disconnected.append(ws) + for ws in disconnected: + self.disconnect(ws) + + @property + def connection_count(self) -> int: + return len(self._connections) + + +# Singleton connection manager -- shared across all WebSocket endpoint +# invocations within the same process. +ws_manager = ConnectionManager() + + +@router.websocket("/ws/live") +async def websocket_live(ws: WebSocket) -> None: + """WebSocket endpoint that relays internal events to the frontend. + + Once connected the client receives a stream of JSON events from the + :class:`EventBus`. The client may send text frames (reserved for + future command support); they are currently ignored. + """ + await ws_manager.connect(ws) + + # Obtain the event bus from application state + event_bus: EventBus = ws.app.state.event_bus + queue = event_bus.subscribe_all() + + relay_task: asyncio.Task | None = None + + try: + # Background task: relay events from the bus to the client + async def _relay() -> None: + try: + while True: + event = await queue.get() + await ws.send_json(event) + except asyncio.CancelledError: + pass + + relay_task = asyncio.create_task( + _relay(), name="ws-relay" + ) + + # Main loop: keep connection alive by reading client frames + while True: + _data = await ws.receive_text() + # Client messages can be handled here in the future + + except WebSocketDisconnect: + logger.info("Frontend WebSocket disconnected") + except Exception: + logger.exception("WebSocket error") + finally: + if relay_task is not None: + relay_task.cancel() + try: + await relay_task + except asyncio.CancelledError: + pass + event_bus.unsubscribe("*", queue) + ws_manager.disconnect(ws) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..a1851b1 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,21 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + artifacts_token: str = "" + database_url: str = "postgresql+asyncpg://artifacts:artifacts@db:5432/artifacts" + cors_origins: list[str] = ["http://localhost:3000"] + + # Artifacts API + artifacts_api_url: str = "https://api.artifactsmmo.com" + + # Rate limits + action_rate_limit: int = 7 # actions per window + action_rate_window: float = 2.0 # seconds + data_rate_limit: int = 20 # data requests per window + data_rate_window: float = 1.0 # seconds + + model_config = {"env_file": ".env", "extra": "ignore"} + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..092921d --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,34 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + +engine = create_async_engine( + settings.database_url, + echo=False, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/app/engine/__init__.py b/backend/app/engine/__init__.py new file mode 100644 index 0000000..c3a554f --- /dev/null +++ b/backend/app/engine/__init__.py @@ -0,0 +1,11 @@ +from app.engine.cooldown import CooldownTracker +from app.engine.manager import AutomationManager +from app.engine.pathfinder import Pathfinder +from app.engine.runner import AutomationRunner + +__all__ = [ + "AutomationManager", + "AutomationRunner", + "CooldownTracker", + "Pathfinder", +] diff --git a/backend/app/engine/cooldown.py b/backend/app/engine/cooldown.py new file mode 100644 index 0000000..6646b44 --- /dev/null +++ b/backend/app/engine/cooldown.py @@ -0,0 +1,90 @@ +import asyncio +import logging +from datetime import datetime, timedelta, timezone + +logger = logging.getLogger(__name__) + +# Safety buffer added after every cooldown to avoid 499 "action already in progress" errors +_BUFFER_SECONDS: float = 0.1 + + +class CooldownTracker: + """Track per-character cooldowns with a safety buffer. + + The Artifacts MMO API returns cooldown information after every action. + This tracker stores the expiry timestamp for each character and provides + an async ``wait`` method that sleeps until the cooldown has elapsed plus + a small buffer (100 ms) to prevent race-condition 499 errors. + """ + + def __init__(self) -> None: + self._cooldowns: dict[str, datetime] = {} + + def update( + self, + character_name: str, + cooldown_seconds: float, + cooldown_expiration: str | None = None, + ) -> None: + """Record the cooldown from an action response. + + Parameters + ---------- + character_name: + The character whose cooldown is being updated. + cooldown_seconds: + Total cooldown duration in seconds (used as fallback). + cooldown_expiration: + ISO-8601 timestamp of when the cooldown expires (preferred). + """ + if cooldown_expiration: + try: + expiry = datetime.fromisoformat(cooldown_expiration) + # Ensure timezone-aware + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + logger.warning( + "Failed to parse cooldown_expiration %r for %s, using duration fallback", + cooldown_expiration, + character_name, + ) + expiry = datetime.now(timezone.utc) + timedelta(seconds=cooldown_seconds) + else: + expiry = datetime.now(timezone.utc) + timedelta(seconds=cooldown_seconds) + + self._cooldowns[character_name] = expiry + logger.debug( + "Cooldown for %s set to %s (%.1fs)", + character_name, + expiry.isoformat(), + cooldown_seconds, + ) + + async def wait(self, character_name: str) -> None: + """Sleep until the character's cooldown has expired plus a safety buffer.""" + expiry = self._cooldowns.get(character_name) + if expiry is None: + return + + now = datetime.now(timezone.utc) + remaining = (expiry - now).total_seconds() + _BUFFER_SECONDS + + if remaining > 0: + logger.debug("Waiting %.2fs for %s cooldown", remaining, character_name) + await asyncio.sleep(remaining) + + def is_ready(self, character_name: str) -> bool: + """Return True if the character has no active cooldown.""" + expiry = self._cooldowns.get(character_name) + if expiry is None: + return True + return datetime.now(timezone.utc) >= expiry + + def remaining(self, character_name: str) -> float: + """Return remaining cooldown seconds (0 if ready).""" + expiry = self._cooldowns.get(character_name) + if expiry is None: + return 0.0 + delta = (expiry - datetime.now(timezone.utc)).total_seconds() + return max(delta, 0.0) diff --git a/backend/app/engine/coordinator.py b/backend/app/engine/coordinator.py new file mode 100644 index 0000000..3cdb8f3 --- /dev/null +++ b/backend/app/engine/coordinator.py @@ -0,0 +1,139 @@ +"""Coordinator for multi-character operations. + +Provides simple sequential setup of automations across characters +for pipelines like: gatherer collects materials -> crafter processes them. +""" + +import logging +from typing import Any + +from app.engine.manager import AutomationManager + +logger = logging.getLogger(__name__) + + +class Coordinator: + """Coordinates multi-character operations by sequentially setting up automations. + + This is a lightweight orchestrator that configures multiple characters + to work in a pipeline. It does not manage real-time synchronization + between characters; each character runs its automation independently. + """ + + def __init__(self, manager: AutomationManager) -> None: + self._manager = manager + + async def resource_pipeline( + self, + gatherer_config_id: int, + crafter_config_id: int, + item_code: str, + ) -> dict[str, Any]: + """Set up a gather-then-craft pipeline across two characters. + + The gatherer character will gather resources and deposit them. + The crafter character will withdraw materials and craft the item. + + This is a simple sequential setup -- both automations run + independently after being started. + + Parameters + ---------- + gatherer_config_id: + Automation config ID for the gathering character. + crafter_config_id: + Automation config ID for the crafting character. + item_code: + The item code that the crafter will produce. + + Returns + ------- + Dict with the run IDs and status of both automations. + """ + results: dict[str, Any] = { + "item_code": item_code, + "gatherer": None, + "crafter": None, + "errors": [], + } + + # Start the gatherer first + try: + gatherer_run = await self._manager.start(gatherer_config_id) + results["gatherer"] = { + "config_id": gatherer_config_id, + "run_id": gatherer_run.id, + "status": gatherer_run.status, + } + logger.info( + "Pipeline: started gatherer config=%d run=%d", + gatherer_config_id, + gatherer_run.id, + ) + except ValueError as exc: + error_msg = f"Failed to start gatherer: {exc}" + results["errors"].append(error_msg) + logger.warning("Pipeline: %s", error_msg) + + # Start the crafter + try: + crafter_run = await self._manager.start(crafter_config_id) + results["crafter"] = { + "config_id": crafter_config_id, + "run_id": crafter_run.id, + "status": crafter_run.status, + } + logger.info( + "Pipeline: started crafter config=%d run=%d", + crafter_config_id, + crafter_run.id, + ) + except ValueError as exc: + error_msg = f"Failed to start crafter: {exc}" + results["errors"].append(error_msg) + logger.warning("Pipeline: %s", error_msg) + + return results + + async def stop_pipeline( + self, + gatherer_config_id: int, + crafter_config_id: int, + ) -> dict[str, Any]: + """Stop both automations in a resource pipeline. + + Parameters + ---------- + gatherer_config_id: + Automation config ID for the gathering character. + crafter_config_id: + Automation config ID for the crafting character. + + Returns + ------- + Dict with the stop results for both automations. + """ + results: dict[str, Any] = { + "gatherer_stopped": False, + "crafter_stopped": False, + "errors": [], + } + + for label, config_id in [ + ("gatherer", gatherer_config_id), + ("crafter", crafter_config_id), + ]: + try: + await self._manager.stop(config_id) + results[f"{label}_stopped"] = True + logger.info("Pipeline: stopped %s config=%d", label, config_id) + except ValueError as exc: + results["errors"].append(f"Failed to stop {label}: {exc}") + logger.warning( + "Pipeline: failed to stop %s config=%d: %s", + label, + config_id, + exc, + ) + + return results diff --git a/backend/app/engine/decision/__init__.py b/backend/app/engine/decision/__init__.py new file mode 100644 index 0000000..d1bf9e3 --- /dev/null +++ b/backend/app/engine/decision/__init__.py @@ -0,0 +1,11 @@ +from app.engine.decision.equipment_optimizer import EquipmentOptimizer +from app.engine.decision.heal_policy import HealPolicy +from app.engine.decision.monster_selector import MonsterSelector +from app.engine.decision.resource_selector import ResourceSelector + +__all__ = [ + "EquipmentOptimizer", + "HealPolicy", + "MonsterSelector", + "ResourceSelector", +] diff --git a/backend/app/engine/decision/equipment_optimizer.py b/backend/app/engine/decision/equipment_optimizer.py new file mode 100644 index 0000000..41868d3 --- /dev/null +++ b/backend/app/engine/decision/equipment_optimizer.py @@ -0,0 +1,157 @@ +"""Equipment optimizer for suggesting gear improvements.""" + +import logging +from dataclasses import dataclass, field + +from app.schemas.game import CharacterSchema, ItemSchema + +logger = logging.getLogger(__name__) + +# Equipment slot names and the item types that can go in them +_SLOT_TYPE_MAP: dict[str, list[str]] = { + "weapon_slot": ["weapon"], + "shield_slot": ["shield"], + "helmet_slot": ["helmet"], + "body_armor_slot": ["body_armor"], + "leg_armor_slot": ["leg_armor"], + "boots_slot": ["boots"], + "ring1_slot": ["ring"], + "ring2_slot": ["ring"], + "amulet_slot": ["amulet"], + "artifact1_slot": ["artifact"], + "artifact2_slot": ["artifact"], + "artifact3_slot": ["artifact"], +} + +# Effect names that contribute to the equipment score +_ATTACK_EFFECTS = {"attack_fire", "attack_earth", "attack_water", "attack_air"} +_DEFENSE_EFFECTS = {"res_fire", "res_earth", "res_water", "res_air"} +_HP_EFFECTS = {"hp"} +_DAMAGE_EFFECTS = {"dmg_fire", "dmg_earth", "dmg_water", "dmg_air"} + + +@dataclass +class EquipmentSuggestion: + """A suggestion to equip a different item in a slot.""" + + slot: str + current_item_code: str + suggested_item_code: str + current_score: float + suggested_score: float + improvement: float + reason: str + + +@dataclass +class EquipmentAnalysis: + """Full analysis of a character's equipment vs available items.""" + + suggestions: list[EquipmentSuggestion] = field(default_factory=list) + total_current_score: float = 0.0 + total_best_score: float = 0.0 + + +class EquipmentOptimizer: + """Analyzes character equipment and suggests improvements. + + Uses a simple scoring system: sum of all attack + defense + HP stats + from item effects. + """ + + def suggest_equipment( + self, + character: CharacterSchema, + available_items: list[ItemSchema], + ) -> EquipmentAnalysis: + """Analyze the character's current equipment and suggest improvements. + + Parameters + ---------- + character: + The character to analyze. + available_items: + Items available to the character (e.g. from bank). + + Returns + ------- + EquipmentAnalysis with suggestions for each slot where a better item exists. + """ + # Build a lookup of item code -> ItemSchema + item_lookup: dict[str, ItemSchema] = { + item.code: item for item in available_items + } + + analysis = EquipmentAnalysis() + + for slot, valid_types in _SLOT_TYPE_MAP.items(): + current_code = getattr(character, slot, "") + current_item = item_lookup.get(current_code) if current_code else None + current_score = self._score_item(current_item) if current_item else 0.0 + + # Find the best available item for this slot + candidates = [ + item + for item in available_items + if item.type in valid_types and item.level <= character.level + ] + + if not candidates: + analysis.total_current_score += current_score + analysis.total_best_score += current_score + continue + + best_candidate = max(candidates, key=lambda i: self._score_item(i)) + best_score = self._score_item(best_candidate) + + analysis.total_current_score += current_score + analysis.total_best_score += max(current_score, best_score) + + # Only suggest if there's an actual improvement + improvement = best_score - current_score + if improvement > 0 and best_candidate.code != current_code: + suggestion = EquipmentSuggestion( + slot=slot, + current_item_code=current_code or "(empty)", + suggested_item_code=best_candidate.code, + current_score=current_score, + suggested_score=best_score, + improvement=improvement, + reason=( + f"Replace {current_code or 'empty'} " + f"(score {current_score:.1f}) with {best_candidate.code} " + f"(score {best_score:.1f}, +{improvement:.1f})" + ), + ) + analysis.suggestions.append(suggestion) + + # Sort suggestions by improvement descending + analysis.suggestions.sort(key=lambda s: s.improvement, reverse=True) + + return analysis + + @staticmethod + def _score_item(item: ItemSchema | None) -> float: + """Calculate a simple composite score for an item. + + Score = sum of all attack effects + defense effects + HP + damage effects. + """ + if item is None: + return 0.0 + + score = 0.0 + for effect in item.effects: + name = effect.name.lower() + if name in _ATTACK_EFFECTS: + score += effect.value + elif name in _DEFENSE_EFFECTS: + score += effect.value + elif name in _HP_EFFECTS: + score += effect.value * 0.5 # HP is weighted less than raw stats + elif name in _DAMAGE_EFFECTS: + score += effect.value * 1.5 # Damage bonuses are weighted higher + + # Small bonus for higher-level items (tie-breaker) + score += item.level * 0.1 + + return score diff --git a/backend/app/engine/decision/heal_policy.py b/backend/app/engine/decision/heal_policy.py new file mode 100644 index 0000000..316c6c9 --- /dev/null +++ b/backend/app/engine/decision/heal_policy.py @@ -0,0 +1,77 @@ +import logging + +from app.engine.strategies.base import ActionPlan, ActionType +from app.schemas.game import CharacterSchema + +logger = logging.getLogger(__name__) + + +class HealPolicy: + """Encapsulates healing decision logic. + + Used by strategies to determine *if* and *how* a character should heal. + """ + + @staticmethod + def should_heal(character: CharacterSchema, threshold: int) -> bool: + """Return ``True`` if the character's HP is below *threshold* percent. + + Parameters + ---------- + character: + The character to evaluate. + threshold: + HP percentage (0-100) below which healing is recommended. + """ + if character.max_hp == 0: + return False + hp_pct = (character.hp / character.max_hp) * 100.0 + return hp_pct < threshold + + @staticmethod + def is_full_health(character: CharacterSchema) -> bool: + """Return ``True`` if the character is at maximum HP.""" + return character.hp >= character.max_hp + + @staticmethod + def choose_heal_method(character: CharacterSchema, config: dict) -> ActionPlan: + """Decide between resting and using a consumable. + + Parameters + ---------- + character: + Current character state. + config: + Strategy config dict containing ``heal_method``, + ``consumable_code``, etc. + + Returns + ------- + An :class:`ActionPlan` for the chosen healing action. + """ + heal_method = config.get("heal_method", "rest") + consumable_code: str | None = config.get("consumable_code") + + if heal_method == "consumable" and consumable_code: + # Verify the character actually has the consumable + has_item = any( + slot.code == consumable_code for slot in character.inventory + ) + if has_item: + return ActionPlan( + ActionType.USE_ITEM, + params={"code": consumable_code, "quantity": 1}, + reason=f"Using {consumable_code} to heal ({character.hp}/{character.max_hp} HP)", + ) + else: + logger.info( + "Consumable %s not in inventory for %s, falling back to rest", + consumable_code, + character.name, + ) + + # Default / fallback: rest + return ActionPlan( + ActionType.REST, + reason=f"Resting to heal ({character.hp}/{character.max_hp} HP)", + ) diff --git a/backend/app/engine/decision/monster_selector.py b/backend/app/engine/decision/monster_selector.py new file mode 100644 index 0000000..ac2692e --- /dev/null +++ b/backend/app/engine/decision/monster_selector.py @@ -0,0 +1,83 @@ +import logging + +from app.schemas.game import CharacterSchema, MonsterSchema + +logger = logging.getLogger(__name__) + +# Maximum level difference when selecting an "optimal" monster +_MAX_LEVEL_DELTA: int = 5 + + +class MonsterSelector: + """Select the best monster for a character to fight. + + The selection heuristic prefers monsters within +/- 5 levels of the + character's combat level. Among those candidates, higher-level monsters + are preferred because they yield more XP. + """ + + def select_optimal( + self, + character: CharacterSchema, + monsters: list[MonsterSchema], + ) -> MonsterSchema | None: + """Return the best monster for the character, or ``None`` if the + list is empty or no suitable monster exists. + + Parameters + ---------- + character: + The character that will be fighting. + monsters: + All available monsters (typically from the game data cache). + + Returns + ------- + The selected monster, or None. + """ + if not monsters: + return None + + char_level = character.level + + # First pass: prefer monsters within the level window + candidates = [ + m + for m in monsters + if abs(m.level - char_level) <= _MAX_LEVEL_DELTA + ] + + if not candidates: + # No monster in the preferred window -- fall back to the + # highest-level monster that is still at or below the character + below = [m for m in monsters if m.level <= char_level] + if below: + candidates = below + else: + # All monsters are higher-level; pick the lowest available + candidates = sorted(monsters, key=lambda m: m.level) + return candidates[0] if candidates else None + + # Among candidates, prefer higher level for better XP + candidates.sort(key=lambda m: m.level, reverse=True) + selected = candidates[0] + + logger.debug( + "Selected monster %s (level %d) for character %s (level %d)", + selected.code, + selected.level, + character.name, + char_level, + ) + return selected + + def filter_by_code( + self, + monsters: list[MonsterSchema], + code: str, + ) -> MonsterSchema | None: + """Return the monster with the given code, or ``None``.""" + for m in monsters: + if m.code == code: + return m + return None diff --git a/backend/app/engine/decision/resource_selector.py b/backend/app/engine/decision/resource_selector.py new file mode 100644 index 0000000..eabc17b --- /dev/null +++ b/backend/app/engine/decision/resource_selector.py @@ -0,0 +1,149 @@ +"""Resource selector for choosing optimal gathering targets based on character skill level.""" + +import logging +from dataclasses import dataclass + +from app.schemas.game import CharacterSchema, ResourceSchema + +logger = logging.getLogger(__name__) + + +@dataclass +class ResourceSelection: + """Result of a resource selection decision.""" + + resource: ResourceSchema + score: float + reason: str + + +class ResourceSelector: + """Selects the optimal resource for a character to gather based on skill level. + + Prefers resources within +/- 3 levels of the character's skill. + Among eligible resources, prefers higher-level ones for better XP. + """ + + # How many levels above/below the character's skill level to consider + LEVEL_RANGE: int = 3 + + def select_optimal( + self, + character: CharacterSchema, + resources: list[ResourceSchema], + skill: str, + ) -> ResourceSelection | None: + """Select the best resource for the character's skill level. + + Parameters + ---------- + character: + The character whose skill level determines the selection. + resources: + Available resources to choose from. + skill: + The gathering skill to optimize for (e.g. "mining", "woodcutting", "fishing"). + + Returns + ------- + ResourceSelection or None if no suitable resource is found. + """ + skill_level = self._get_skill_level(character, skill) + if skill_level is None: + logger.warning("Unknown skill %r for resource selection", skill) + return None + + # Filter to resources that match the skill + skill_resources = [r for r in resources if r.skill == skill] + if not skill_resources: + logger.info("No resources found for skill %s", skill) + return None + + # Score each resource + scored: list[tuple[ResourceSchema, float, str]] = [] + for resource in skill_resources: + score, reason = self._score_resource(resource, skill_level) + if score > 0: + scored.append((resource, score, reason)) + + if not scored: + # Fallback: pick the highest-level resource we can actually gather + gatherable = [ + r for r in skill_resources if r.level <= skill_level + ] + if gatherable: + best = max(gatherable, key=lambda r: r.level) + return ResourceSelection( + resource=best, + score=0.1, + reason=f"Fallback: highest gatherable resource (level {best.level}, skill {skill_level})", + ) + # Pick the lowest-level resource as absolute fallback + lowest = min(skill_resources, key=lambda r: r.level) + return ResourceSelection( + resource=lowest, + score=0.01, + reason=f"Absolute fallback: lowest resource (level {lowest.level}, skill {skill_level})", + ) + + # Sort by score descending and pick the best + scored.sort(key=lambda x: x[1], reverse=True) + best_resource, best_score, best_reason = scored[0] + + logger.info( + "Selected resource %s (level %d) for %s level %d: %s (score=%.2f)", + best_resource.code, + best_resource.level, + skill, + skill_level, + best_reason, + best_score, + ) + + return ResourceSelection( + resource=best_resource, + score=best_score, + reason=best_reason, + ) + + def _score_resource( + self, + resource: ResourceSchema, + skill_level: int, + ) -> tuple[float, str]: + """Score a resource based on how well it matches the character's skill level. + + Returns (score, reason). Score of 0 means the resource is not suitable. + """ + level_diff = resource.level - skill_level + + # Cannot gather resources more than LEVEL_RANGE levels above skill + if level_diff > self.LEVEL_RANGE: + return 0.0, f"Too high level (resource {resource.level}, skill {skill_level})" + + # Ideal range: within +/- LEVEL_RANGE + if abs(level_diff) <= self.LEVEL_RANGE: + # Higher level within range = more XP = better score + # Base score from level closeness (prefer higher) + base_score = 10.0 + level_diff # Range: [7, 13] + + # Bonus for being at or slightly above skill level (best XP) + if 0 <= level_diff <= self.LEVEL_RANGE: + base_score += 5.0 # Prefer resources at or above skill level + + reason = f"In optimal range (diff={level_diff:+d})" + return base_score, reason + + # Resource is far below skill level -- still works but less XP + # level_diff < -LEVEL_RANGE + penalty = abs(level_diff) - self.LEVEL_RANGE + score = max(5.0 - penalty, 0.1) + return score, f"Below optimal range (diff={level_diff:+d})" + + @staticmethod + def _get_skill_level(character: CharacterSchema, skill: str) -> int | None: + """Extract the level for a given skill from the character schema.""" + skill_attr = f"{skill}_level" + if hasattr(character, skill_attr): + return getattr(character, skill_attr) + return None diff --git a/backend/app/engine/manager.py b/backend/app/engine/manager.py new file mode 100644 index 0000000..10a7d5a --- /dev/null +++ b/backend/app/engine/manager.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.orm import selectinload + +from app.engine.cooldown import CooldownTracker +from app.engine.pathfinder import Pathfinder +from app.engine.runner import AutomationRunner +from app.engine.strategies.base import BaseStrategy +from app.engine.strategies.combat import CombatStrategy +from app.engine.strategies.crafting import CraftingStrategy +from app.engine.strategies.gathering import GatheringStrategy +from app.engine.strategies.leveling import LevelingStrategy +from app.engine.strategies.task import TaskStrategy +from app.engine.strategies.trading import TradingStrategy +from app.models.automation import AutomationConfig, AutomationLog, AutomationRun +from app.schemas.automation import ( + AutomationLogResponse, + AutomationRunResponse, + AutomationStatusResponse, +) +from app.services.artifacts_client import ArtifactsClient + +if TYPE_CHECKING: + from app.websocket.event_bus import EventBus + +logger = logging.getLogger(__name__) + + +class AutomationManager: + """Central manager that orchestrates all automation runners. + + One manager exists per application instance and is stored on + ``app.state.automation_manager``. It holds references to all active + runners (keyed by ``config_id``) and provides high-level start / stop / + pause / resume operations. + """ + + def __init__( + self, + client: ArtifactsClient, + db_factory: async_sessionmaker[AsyncSession], + pathfinder: Pathfinder, + event_bus: EventBus | None = None, + ) -> None: + self._client = client + self._db_factory = db_factory + self._pathfinder = pathfinder + self._event_bus = event_bus + self._runners: dict[int, AutomationRunner] = {} + self._cooldown_tracker = CooldownTracker() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self, config_id: int) -> AutomationRunResponse: + """Start an automation from its persisted configuration. + + Creates a new :class:`AutomationRun` record and spawns an + :class:`AutomationRunner` task. + + Raises + ------ + ValueError + If the config does not exist, is disabled, or is already running. + """ + # Prevent duplicate runners + if config_id in self._runners: + runner = self._runners[config_id] + if runner.is_running or runner.is_paused: + raise ValueError( + f"Automation config {config_id} is already running " + f"(run_id={runner.run_id}, status={runner.status})" + ) + + async with self._db_factory() as db: + # Load the config + config = await db.get(AutomationConfig, config_id) + if config is None: + raise ValueError(f"Automation config {config_id} not found") + if not config.enabled: + raise ValueError(f"Automation config {config_id} is disabled") + + # Create strategy + strategy = self._create_strategy(config.strategy_type, config.config) + + # Create run record + run = AutomationRun( + config_id=config_id, + status="running", + ) + db.add(run) + await db.commit() + await db.refresh(run) + + run_response = AutomationRunResponse.model_validate(run) + + # Build and start the runner + runner = AutomationRunner( + config_id=config_id, + character_name=config.character_name, + strategy=strategy, + client=self._client, + cooldown_tracker=self._cooldown_tracker, + db_factory=self._db_factory, + run_id=run.id, + event_bus=self._event_bus, + ) + self._runners[config_id] = runner + await runner.start() + + logger.info( + "Started automation config=%d character=%s strategy=%s run=%d", + config_id, + config.character_name, + config.strategy_type, + run.id, + ) + return run_response + + async def stop(self, config_id: int) -> None: + """Stop a running automation. + + Raises + ------ + ValueError + If no runner exists for the given config. + """ + runner = self._runners.get(config_id) + if runner is None: + raise ValueError(f"No active runner for config {config_id}") + + await runner.stop() + del self._runners[config_id] + logger.info("Stopped automation config=%d", config_id) + + async def pause(self, config_id: int) -> None: + """Pause a running automation. + + Raises + ------ + ValueError + If no runner exists for the given config or it is not running. + """ + runner = self._runners.get(config_id) + if runner is None: + raise ValueError(f"No active runner for config {config_id}") + if not runner.is_running: + raise ValueError(f"Runner for config {config_id} is not running (status={runner.status})") + + await runner.pause() + + async def resume(self, config_id: int) -> None: + """Resume a paused automation. + + Raises + ------ + ValueError + If no runner exists for the given config or it is not paused. + """ + runner = self._runners.get(config_id) + if runner is None: + raise ValueError(f"No active runner for config {config_id}") + if not runner.is_paused: + raise ValueError(f"Runner for config {config_id} is not paused (status={runner.status})") + + await runner.resume() + + async def stop_all(self) -> None: + """Stop all running automations (used during shutdown).""" + config_ids = list(self._runners.keys()) + for config_id in config_ids: + try: + await self.stop(config_id) + except Exception: + logger.exception("Error stopping automation config=%d", config_id) + + # ------------------------------------------------------------------ + # Status queries + # ------------------------------------------------------------------ + + def get_status(self, config_id: int) -> AutomationStatusResponse | None: + """Return the live status of a single automation, or ``None``.""" + runner = self._runners.get(config_id) + if runner is None: + return None + return AutomationStatusResponse( + config_id=runner.config_id, + character_name=runner.character_name, + strategy_type=runner.strategy_state, + status=runner.status, + run_id=runner.run_id, + actions_count=runner.actions_count, + ) + + def get_all_statuses(self) -> list[AutomationStatusResponse]: + """Return live status for all active automations.""" + return [ + AutomationStatusResponse( + config_id=r.config_id, + character_name=r.character_name, + strategy_type=r.strategy_state, + status=r.status, + run_id=r.run_id, + actions_count=r.actions_count, + ) + for r in self._runners.values() + ] + + def is_running(self, config_id: int) -> bool: + """Return True if there is an active runner for the config.""" + runner = self._runners.get(config_id) + return runner is not None and (runner.is_running or runner.is_paused) + + # ------------------------------------------------------------------ + # Strategy factory + # ------------------------------------------------------------------ + + def _create_strategy(self, strategy_type: str, config: dict) -> BaseStrategy: + """Instantiate a strategy by type name.""" + match strategy_type: + case "combat": + return CombatStrategy(config, self._pathfinder) + case "gathering": + return GatheringStrategy(config, self._pathfinder) + case "crafting": + return CraftingStrategy(config, self._pathfinder) + case "trading": + return TradingStrategy(config, self._pathfinder) + case "task": + return TaskStrategy(config, self._pathfinder) + case "leveling": + return LevelingStrategy(config, self._pathfinder) + case _: + raise ValueError( + f"Unknown strategy type: {strategy_type!r}. " + f"Supported: combat, gathering, crafting, trading, task, leveling" + ) diff --git a/backend/app/engine/pathfinder.py b/backend/app/engine/pathfinder.py new file mode 100644 index 0000000..4f80c90 --- /dev/null +++ b/backend/app/engine/pathfinder.py @@ -0,0 +1,130 @@ +import logging + +from app.schemas.game import MapSchema + +logger = logging.getLogger(__name__) + + +class Pathfinder: + """Spatial index over the game map for finding tiles by content. + + Uses Manhattan distance (since the Artifacts MMO API ``move`` action + performs a direct teleport with a cooldown proportional to Manhattan + distance). A* path-finding over walkable tiles is therefore unnecessary; + the optimal strategy is always to move directly to the target. + """ + + def __init__(self) -> None: + self._maps: list[MapSchema] = [] + self._map_index: dict[tuple[int, int], MapSchema] = {} + + # ------------------------------------------------------------------ + # Initialization + # ------------------------------------------------------------------ + + def load_maps(self, maps: list[MapSchema]) -> None: + """Load map data (typically from the game data cache).""" + self._maps = list(maps) + self._map_index = {(m.x, m.y): m for m in self._maps} + logger.info("Pathfinder loaded %d map tiles", len(self._maps)) + + @property + def is_loaded(self) -> bool: + return len(self._maps) > 0 + + # ------------------------------------------------------------------ + # Tile lookup + # ------------------------------------------------------------------ + + def get_tile(self, x: int, y: int) -> MapSchema | None: + """Return the map tile at the given coordinates, or None.""" + return self._map_index.get((x, y)) + + def tile_has_content(self, x: int, y: int, content_type: str, content_code: str) -> bool: + """Check whether the tile at (x, y) has the specified content.""" + tile = self._map_index.get((x, y)) + if tile is None or tile.content is None: + return False + return tile.content.type == content_type and tile.content.code == content_code + + def tile_has_content_type(self, x: int, y: int, content_type: str) -> bool: + """Check whether the tile at (x, y) has any content of the given type.""" + tile = self._map_index.get((x, y)) + if tile is None or tile.content is None: + return False + return tile.content.type == content_type + + # ------------------------------------------------------------------ + # Nearest-tile search + # ------------------------------------------------------------------ + + def find_nearest( + self, + from_x: int, + from_y: int, + content_type: str, + content_code: str, + ) -> tuple[int, int] | None: + """Find the nearest tile whose content matches type *and* code. + + Returns ``(x, y)`` of the closest match, or ``None`` if not found. + """ + best: tuple[int, int] | None = None + best_dist = float("inf") + + for m in self._maps: + if ( + m.content is not None + and m.content.type == content_type + and m.content.code == content_code + ): + dist = abs(m.x - from_x) + abs(m.y - from_y) + if dist < best_dist: + best_dist = dist + best = (m.x, m.y) + + return best + + def find_nearest_by_type( + self, + from_x: int, + from_y: int, + content_type: str, + ) -> tuple[int, int] | None: + """Find the nearest tile that has any content of *content_type*. + + Returns ``(x, y)`` of the closest match, or ``None`` if not found. + """ + best: tuple[int, int] | None = None + best_dist = float("inf") + + for m in self._maps: + if m.content is not None and m.content.type == content_type: + dist = abs(m.x - from_x) + abs(m.y - from_y) + if dist < best_dist: + best_dist = dist + best = (m.x, m.y) + + return best + + def find_all( + self, + content_type: str, + content_code: str | None = None, + ) -> list[tuple[int, int]]: + """Return coordinates of all tiles matching the given content filter.""" + results: list[tuple[int, int]] = [] + for m in self._maps: + if m.content is None: + continue + if m.content.type != content_type: + continue + if content_code is not None and m.content.code != content_code: + continue + results.append((m.x, m.y)) + return results + + @staticmethod + def manhattan_distance(x1: int, y1: int, x2: int, y2: int) -> int: + """Compute the Manhattan distance between two points.""" + return abs(x1 - x2) + abs(y1 - y2) diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py new file mode 100644 index 0000000..4221210 --- /dev/null +++ b/backend/app/engine/runner.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.engine.cooldown import CooldownTracker +from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy +from app.models.automation import AutomationLog, AutomationRun +from app.services.artifacts_client import ArtifactsClient + +if TYPE_CHECKING: + from app.websocket.event_bus import EventBus + +logger = logging.getLogger(__name__) + +# Delay before retrying after an unhandled error in the run loop +_ERROR_RETRY_DELAY: float = 2.0 + +# Maximum consecutive errors before the runner stops itself +_MAX_CONSECUTIVE_ERRORS: int = 10 + + +class AutomationRunner: + """Drives the automation loop for a single character. + + Each runner owns an ``asyncio.Task`` that repeatedly: + + 1. Waits for the character's cooldown to expire. + 2. Fetches the current character state from the API. + 3. Asks the strategy for the next action. + 4. Executes that action via the artifacts client. + 5. Records the cooldown and logs the result to the database. + + The runner can be paused, resumed, or stopped at any time. + """ + + def __init__( + self, + config_id: int, + character_name: str, + strategy: BaseStrategy, + client: ArtifactsClient, + cooldown_tracker: CooldownTracker, + db_factory: async_sessionmaker[AsyncSession], + run_id: int, + event_bus: EventBus | None = None, + ) -> None: + self._config_id = config_id + self._character_name = character_name + self._strategy = strategy + self._client = client + self._cooldown = cooldown_tracker + self._db_factory = db_factory + self._run_id = run_id + self._event_bus = event_bus + + self._running = False + self._paused = False + self._task: asyncio.Task[None] | None = None + self._actions_count: int = 0 + self._consecutive_errors: int = 0 + + # ------------------------------------------------------------------ + # Public properties + # ------------------------------------------------------------------ + + @property + def config_id(self) -> int: + return self._config_id + + @property + def character_name(self) -> str: + return self._character_name + + @property + def run_id(self) -> int: + return self._run_id + + @property + def actions_count(self) -> int: + return self._actions_count + + @property + def is_running(self) -> bool: + return self._running and not self._paused + + @property + def is_paused(self) -> bool: + return self._running and self._paused + + @property + def status(self) -> str: + if not self._running: + return "stopped" + if self._paused: + return "paused" + return "running" + + @property + def strategy_state(self) -> str: + return self._strategy.get_state() + + # ------------------------------------------------------------------ + # Event bus helpers + # ------------------------------------------------------------------ + + async def _publish(self, event_type: str, data: dict) -> None: + """Publish an event to the event bus if one is configured.""" + if self._event_bus is not None: + try: + await self._event_bus.publish(event_type, data) + except Exception: + logger.exception("Failed to publish event %s", event_type) + + async def _publish_status(self, status: str) -> None: + """Publish an automation_status_changed event.""" + await self._publish( + "automation_status_changed", + { + "config_id": self._config_id, + "character_name": self._character_name, + "status": status, + "run_id": self._run_id, + }, + ) + + async def _publish_action( + self, + action_type: str, + success: bool, + details: dict | None = None, + ) -> None: + """Publish an automation_action event.""" + await self._publish( + "automation_action", + { + "config_id": self._config_id, + "character_name": self._character_name, + "action_type": action_type, + "success": success, + "details": details or {}, + "actions_count": self._actions_count, + }, + ) + + async def _publish_character_update(self) -> None: + """Publish a character_update event to trigger frontend re-fetch.""" + await self._publish( + "character_update", + { + "character_name": self._character_name, + }, + ) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start the automation loop in a background task.""" + if self._running: + logger.warning("Runner for config %d is already running", self._config_id) + return + self._running = True + self._paused = False + self._task = asyncio.create_task( + self._run_loop(), + name=f"automation-{self._config_id}-{self._character_name}", + ) + logger.info( + "Started automation runner for config %d (character=%s, run=%d)", + self._config_id, + self._character_name, + self._run_id, + ) + await self._publish_status("running") + + async def stop(self, error_message: str | None = None) -> None: + """Stop the automation loop and finalize the run record.""" + self._running = False + if self._task is not None and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + # Update the run record in the database + final_status = "error" if error_message else "stopped" + await self._finalize_run( + status=final_status, + error_message=error_message, + ) + logger.info( + "Stopped automation runner for config %d (actions=%d)", + self._config_id, + self._actions_count, + ) + await self._publish_status(final_status) + + async def pause(self) -> None: + """Pause the automation loop (the task keeps running but idles).""" + self._paused = True + await self._update_run_status("paused") + logger.info("Paused automation runner for config %d", self._config_id) + await self._publish_status("paused") + + async def resume(self) -> None: + """Resume a paused automation loop.""" + self._paused = False + await self._update_run_status("running") + logger.info("Resumed automation runner for config %d", self._config_id) + await self._publish_status("running") + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + async def _run_loop(self) -> None: + """Core automation loop -- runs until stopped or the strategy completes.""" + try: + while self._running: + if self._paused: + await asyncio.sleep(1) + continue + + try: + await self._tick() + self._consecutive_errors = 0 + except asyncio.CancelledError: + raise + except Exception as exc: + self._consecutive_errors += 1 + logger.exception( + "Error in automation loop for config %d (error %d/%d): %s", + self._config_id, + self._consecutive_errors, + _MAX_CONSECUTIVE_ERRORS, + exc, + ) + await self._log_action( + ActionPlan(ActionType.IDLE, reason=str(exc)), + success=False, + ) + await self._publish_action( + "error", + success=False, + details={"error": str(exc)}, + ) + if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS: + logger.error( + "Too many consecutive errors for config %d, stopping", + self._config_id, + ) + await self._finalize_run( + status="error", + error_message=f"Stopped after {_MAX_CONSECUTIVE_ERRORS} consecutive errors. Last: {exc}", + ) + self._running = False + await self._publish_status("error") + return + await asyncio.sleep(_ERROR_RETRY_DELAY) + + except asyncio.CancelledError: + logger.info("Automation loop for config %d was cancelled", self._config_id) + + async def _tick(self) -> None: + """Execute a single iteration of the automation loop.""" + # 1. Wait for cooldown + await self._cooldown.wait(self._character_name) + + # 2. Fetch current character state + character = await self._client.get_character(self._character_name) + + # 3. Ask strategy for the next action + plan = await self._strategy.next_action(character) + + # 4. Handle terminal actions + if plan.action_type == ActionType.COMPLETE: + logger.info( + "Strategy completed for config %d: %s", + self._config_id, + plan.reason, + ) + await self._log_action(plan, success=True) + await self._finalize_run(status="completed") + self._running = False + await self._publish_status("completed") + await self._publish_action( + plan.action_type.value, + success=True, + details={"reason": plan.reason}, + ) + return + + if plan.action_type == ActionType.IDLE: + logger.debug( + "Strategy idle for config %d: %s", + self._config_id, + plan.reason, + ) + await asyncio.sleep(1) + return + + # 5. Execute the action + result = await self._execute_action(plan) + + # 6. Update cooldown from response + self._update_cooldown_from_result(result) + + # 7. Record success + self._actions_count += 1 + await self._log_action(plan, success=True) + + # 8. Publish events for the frontend + await self._publish_action( + plan.action_type.value, + success=True, + details={ + "params": plan.params, + "reason": plan.reason, + "strategy_state": self._strategy.get_state(), + }, + ) + await self._publish_character_update() + + # ------------------------------------------------------------------ + # Action execution + # ------------------------------------------------------------------ + + async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]: + """Dispatch an action plan to the appropriate client method.""" + match plan.action_type: + case ActionType.MOVE: + return await self._client.move( + self._character_name, + plan.params["x"], + plan.params["y"], + ) + case ActionType.FIGHT: + return await self._client.fight(self._character_name) + case ActionType.GATHER: + return await self._client.gather(self._character_name) + case ActionType.REST: + return await self._client.rest(self._character_name) + case ActionType.EQUIP: + return await self._client.equip( + self._character_name, + plan.params["code"], + plan.params["slot"], + plan.params.get("quantity", 1), + ) + case ActionType.UNEQUIP: + return await self._client.unequip( + self._character_name, + plan.params["slot"], + plan.params.get("quantity", 1), + ) + case ActionType.USE_ITEM: + return await self._client.use_item( + self._character_name, + plan.params["code"], + plan.params.get("quantity", 1), + ) + case ActionType.DEPOSIT_ITEM: + return await self._client.deposit_item( + self._character_name, + plan.params["code"], + plan.params["quantity"], + ) + case ActionType.WITHDRAW_ITEM: + return await self._client.withdraw_item( + self._character_name, + plan.params["code"], + plan.params["quantity"], + ) + case ActionType.CRAFT: + return await self._client.craft( + self._character_name, + plan.params["code"], + plan.params.get("quantity", 1), + ) + case ActionType.RECYCLE: + return await self._client.recycle( + self._character_name, + plan.params["code"], + plan.params.get("quantity", 1), + ) + case ActionType.GE_BUY: + return await self._client.ge_buy( + self._character_name, + plan.params["code"], + plan.params["quantity"], + plan.params["price"], + ) + case ActionType.GE_SELL: + return await self._client.ge_sell_order( + self._character_name, + plan.params["code"], + plan.params["quantity"], + plan.params["price"], + ) + case ActionType.GE_CANCEL: + return await self._client.ge_cancel( + self._character_name, + plan.params["order_id"], + ) + case ActionType.TASK_NEW: + return await self._client.task_new(self._character_name) + case ActionType.TASK_TRADE: + return await self._client.task_trade( + self._character_name, + plan.params["code"], + plan.params["quantity"], + ) + case ActionType.TASK_COMPLETE: + return await self._client.task_complete(self._character_name) + case ActionType.TASK_EXCHANGE: + return await self._client.task_exchange(self._character_name) + case _: + logger.warning("Unhandled action type: %s", plan.action_type) + return {} + + def _update_cooldown_from_result(self, result: dict[str, Any]) -> None: + """Extract cooldown information from an action response and update the tracker.""" + cooldown = result.get("cooldown") + if cooldown is None: + return + self._cooldown.update( + self._character_name, + cooldown.get("total_seconds", 0), + cooldown.get("expiration"), + ) + + # ------------------------------------------------------------------ + # Database helpers + # ------------------------------------------------------------------ + + async def _log_action(self, plan: ActionPlan, success: bool) -> None: + """Write an action log entry and update the run's action count.""" + try: + async with self._db_factory() as db: + log = AutomationLog( + run_id=self._run_id, + action_type=plan.action_type.value, + details={ + "params": plan.params, + "reason": plan.reason, + "strategy_state": self._strategy.get_state(), + }, + success=success, + ) + db.add(log) + + # Update the run's action counter + stmt = select(AutomationRun).where(AutomationRun.id == self._run_id) + result = await db.execute(stmt) + run = result.scalar_one_or_none() + if run is not None: + run.actions_count = self._actions_count + + await db.commit() + except Exception: + logger.exception("Failed to log action for run %d", self._run_id) + + async def _update_run_status(self, status: str) -> None: + """Update the status field of the current run.""" + try: + async with self._db_factory() as db: + stmt = select(AutomationRun).where(AutomationRun.id == self._run_id) + result = await db.execute(stmt) + run = result.scalar_one_or_none() + if run is not None: + run.status = status + await db.commit() + except Exception: + logger.exception("Failed to update run %d status to %s", self._run_id, status) + + async def _finalize_run( + self, + status: str, + error_message: str | None = None, + ) -> None: + """Mark the run as finished with a final status and timestamp.""" + try: + async with self._db_factory() as db: + stmt = select(AutomationRun).where(AutomationRun.id == self._run_id) + result = await db.execute(stmt) + run = result.scalar_one_or_none() + if run is not None: + run.status = status + run.stopped_at = datetime.now(timezone.utc) + run.actions_count = self._actions_count + if error_message: + run.error_message = error_message + await db.commit() + except Exception: + logger.exception("Failed to finalize run %d", self._run_id) diff --git a/backend/app/engine/strategies/__init__.py b/backend/app/engine/strategies/__init__.py new file mode 100644 index 0000000..6eb6cb9 --- /dev/null +++ b/backend/app/engine/strategies/__init__.py @@ -0,0 +1,19 @@ +from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy +from app.engine.strategies.combat import CombatStrategy +from app.engine.strategies.crafting import CraftingStrategy +from app.engine.strategies.gathering import GatheringStrategy +from app.engine.strategies.leveling import LevelingStrategy +from app.engine.strategies.task import TaskStrategy +from app.engine.strategies.trading import TradingStrategy + +__all__ = [ + "ActionPlan", + "ActionType", + "BaseStrategy", + "CombatStrategy", + "CraftingStrategy", + "GatheringStrategy", + "LevelingStrategy", + "TaskStrategy", + "TradingStrategy", +] diff --git a/backend/app/engine/strategies/base.py b/backend/app/engine/strategies/base.py new file mode 100644 index 0000000..f7382a5 --- /dev/null +++ b/backend/app/engine/strategies/base.py @@ -0,0 +1,99 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum + +from app.engine.pathfinder import Pathfinder +from app.schemas.game import CharacterSchema + + +class ActionType(str, Enum): + """All possible actions the automation runner can execute.""" + + MOVE = "move" + FIGHT = "fight" + GATHER = "gather" + REST = "rest" + EQUIP = "equip" + UNEQUIP = "unequip" + USE_ITEM = "use_item" + DEPOSIT_ITEM = "deposit_item" + WITHDRAW_ITEM = "withdraw_item" + CRAFT = "craft" + RECYCLE = "recycle" + GE_BUY = "ge_buy" + GE_SELL = "ge_sell" + GE_CANCEL = "ge_cancel" + TASK_NEW = "task_new" + TASK_TRADE = "task_trade" + TASK_COMPLETE = "task_complete" + TASK_EXCHANGE = "task_exchange" + IDLE = "idle" + COMPLETE = "complete" + + +@dataclass +class ActionPlan: + """A single action to be executed by the runner.""" + + action_type: ActionType + params: dict = field(default_factory=dict) + reason: str = "" + + +class BaseStrategy(ABC): + """Abstract base class for all automation strategies. + + A strategy inspects the current character state and returns an + :class:`ActionPlan` describing the next action the runner should execute. + + Subclasses must implement :meth:`next_action` and :meth:`get_state`. + """ + + def __init__(self, config: dict, pathfinder: Pathfinder) -> None: + self.config = config + self.pathfinder = pathfinder + + @abstractmethod + async def next_action(self, character: CharacterSchema) -> ActionPlan: + """Determine the next action based on the current character state. + + Returns an :class:`ActionPlan` for the runner to execute. Returning + ``ActionType.COMPLETE`` signals the runner to stop the automation + loop gracefully. ``ActionType.IDLE`` causes the runner to skip + execution and re-evaluate after a short delay. + """ + ... + + @abstractmethod + def get_state(self) -> str: + """Return a human-readable label describing the current strategy state. + + Used for logging and status reporting. + """ + ... + + # ------------------------------------------------------------------ + # Shared helpers available to all strategies + # ------------------------------------------------------------------ + + @staticmethod + def _inventory_used_slots(character: CharacterSchema) -> int: + """Count how many inventory slots are currently occupied.""" + return len(character.inventory) + + @staticmethod + def _inventory_free_slots(character: CharacterSchema) -> int: + """Count how many inventory slots are free.""" + return character.inventory_max_items - len(character.inventory) + + @staticmethod + def _hp_percent(character: CharacterSchema) -> float: + """Return the character's HP as a percentage of max HP.""" + if character.max_hp == 0: + return 100.0 + return (character.hp / character.max_hp) * 100.0 + + @staticmethod + def _is_at(character: CharacterSchema, x: int, y: int) -> bool: + """Check whether the character is standing at the given tile.""" + return character.x == x and character.y == y diff --git a/backend/app/engine/strategies/combat.py b/backend/app/engine/strategies/combat.py new file mode 100644 index 0000000..b995cc3 --- /dev/null +++ b/backend/app/engine/strategies/combat.py @@ -0,0 +1,232 @@ +import logging +from enum import Enum + +from app.engine.pathfinder import Pathfinder +from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy +from app.schemas.game import CharacterSchema + +logger = logging.getLogger(__name__) + + +class _CombatState(str, Enum): + """Internal state machine states for the combat loop.""" + + MOVE_TO_MONSTER = "move_to_monster" + FIGHT = "fight" + CHECK_HEALTH = "check_health" + HEAL = "heal" + CHECK_INVENTORY = "check_inventory" + MOVE_TO_BANK = "move_to_bank" + DEPOSIT = "deposit" + + +class CombatStrategy(BaseStrategy): + """Automated combat strategy. + + State machine flow:: + + MOVE_TO_MONSTER -> FIGHT -> CHECK_HEALTH + | + (HP low?) -> HEAL -> CHECK_HEALTH + | + (HP OK?) -> CHECK_INVENTORY + | + (full?) -> MOVE_TO_BANK -> DEPOSIT -> MOVE_TO_MONSTER + (ok?) -> MOVE_TO_MONSTER (loop) + + Configuration keys (see :class:`~app.schemas.automation.CombatConfig`): + - monster_code: str + - auto_heal_threshold: int (default 50) -- percentage + - heal_method: str (default "rest") -- "rest" or "consumable" + - consumable_code: str | None + - min_inventory_slots: int (default 3) + - deposit_loot: bool (default True) + """ + + def __init__(self, config: dict, pathfinder: Pathfinder) -> None: + super().__init__(config, pathfinder) + self._state = _CombatState.MOVE_TO_MONSTER + + # Parsed config with defaults + self._monster_code: str = config["monster_code"] + self._heal_threshold: int = config.get("auto_heal_threshold", 50) + self._heal_method: str = config.get("heal_method", "rest") + self._consumable_code: str | None = config.get("consumable_code") + self._min_inv_slots: int = config.get("min_inventory_slots", 3) + self._deposit_loot: bool = config.get("deposit_loot", True) + + # Cached locations (resolved lazily) + self._monster_pos: tuple[int, int] | None = None + self._bank_pos: tuple[int, int] | None = None + + def get_state(self) -> str: + return self._state.value + + async def next_action(self, character: CharacterSchema) -> ActionPlan: + # Lazily resolve monster and bank positions + self._resolve_locations(character) + + match self._state: + case _CombatState.MOVE_TO_MONSTER: + return self._handle_move_to_monster(character) + case _CombatState.FIGHT: + return self._handle_fight(character) + case _CombatState.CHECK_HEALTH: + return self._handle_check_health(character) + case _CombatState.HEAL: + return self._handle_heal(character) + case _CombatState.CHECK_INVENTORY: + return self._handle_check_inventory(character) + case _CombatState.MOVE_TO_BANK: + return self._handle_move_to_bank(character) + case _CombatState.DEPOSIT: + return self._handle_deposit(character) + case _: + return ActionPlan(ActionType.IDLE, reason="Unknown state") + + # ------------------------------------------------------------------ + # State handlers + # ------------------------------------------------------------------ + + def _handle_move_to_monster(self, character: CharacterSchema) -> ActionPlan: + if self._monster_pos is None: + return ActionPlan( + ActionType.IDLE, + reason=f"No map tile found for monster {self._monster_code}", + ) + + mx, my = self._monster_pos + + # Already at the monster tile + if self._is_at(character, mx, my): + self._state = _CombatState.FIGHT + return self._handle_fight(character) + + self._state = _CombatState.FIGHT # transition after move + return ActionPlan( + ActionType.MOVE, + params={"x": mx, "y": my}, + reason=f"Moving to monster {self._monster_code} at ({mx}, {my})", + ) + + def _handle_fight(self, character: CharacterSchema) -> ActionPlan: + # Before fighting, check health first + if self._hp_percent(character) < self._heal_threshold: + self._state = _CombatState.HEAL + return self._handle_heal(character) + + self._state = _CombatState.CHECK_HEALTH # after fight we check health + return ActionPlan( + ActionType.FIGHT, + reason=f"Fighting {self._monster_code}", + ) + + def _handle_check_health(self, character: CharacterSchema) -> ActionPlan: + if self._hp_percent(character) < self._heal_threshold: + self._state = _CombatState.HEAL + return self._handle_heal(character) + + # Health is fine, check inventory + self._state = _CombatState.CHECK_INVENTORY + return self._handle_check_inventory(character) + + def _handle_heal(self, character: CharacterSchema) -> ActionPlan: + # If already at full health, go back to the inventory check + if self._hp_percent(character) >= 100.0: + self._state = _CombatState.CHECK_INVENTORY + return self._handle_check_inventory(character) + + if self._heal_method == "consumable" and self._consumable_code: + # Check if the character has the consumable in inventory + has_consumable = any( + slot.code == self._consumable_code for slot in character.inventory + ) + if has_consumable: + # Stay in HEAL state to re-check HP after using the item + self._state = _CombatState.CHECK_HEALTH + return ActionPlan( + ActionType.USE_ITEM, + params={"code": self._consumable_code, "quantity": 1}, + reason=f"Using consumable {self._consumable_code} to heal", + ) + else: + # Fallback to rest if no consumable available + logger.info( + "No %s in inventory, falling back to rest", + self._consumable_code, + ) + + # Default: rest to restore HP + self._state = _CombatState.CHECK_HEALTH + return ActionPlan( + ActionType.REST, + reason=f"Resting to heal (HP {character.hp}/{character.max_hp})", + ) + + def _handle_check_inventory(self, character: CharacterSchema) -> ActionPlan: + free_slots = self._inventory_free_slots(character) + + if self._deposit_loot and free_slots <= self._min_inv_slots: + self._state = _CombatState.MOVE_TO_BANK + return self._handle_move_to_bank(character) + + # Inventory is fine, go fight + self._state = _CombatState.MOVE_TO_MONSTER + return self._handle_move_to_monster(character) + + def _handle_move_to_bank(self, character: CharacterSchema) -> ActionPlan: + if self._bank_pos is None: + return ActionPlan( + ActionType.IDLE, + reason="No bank tile found on map", + ) + + bx, by = self._bank_pos + + if self._is_at(character, bx, by): + self._state = _CombatState.DEPOSIT + return self._handle_deposit(character) + + self._state = _CombatState.DEPOSIT # transition after move + return ActionPlan( + ActionType.MOVE, + params={"x": bx, "y": by}, + reason=f"Moving to bank at ({bx}, {by}) to deposit loot", + ) + + def _handle_deposit(self, character: CharacterSchema) -> ActionPlan: + # Deposit the first non-empty inventory slot + for slot in character.inventory: + if slot.quantity > 0: + # Stay in DEPOSIT state to deposit the next item on the next tick + return ActionPlan( + ActionType.DEPOSIT_ITEM, + params={"code": slot.code, "quantity": slot.quantity}, + reason=f"Depositing {slot.quantity}x {slot.code}", + ) + + # All items deposited -- go back to monster + self._state = _CombatState.MOVE_TO_MONSTER + return self._handle_move_to_monster(character) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _resolve_locations(self, character: CharacterSchema) -> None: + """Lazily resolve and cache monster / bank tile positions.""" + if self._monster_pos is None: + self._monster_pos = self.pathfinder.find_nearest( + character.x, character.y, "monster", self._monster_code + ) + if self._monster_pos: + logger.info( + "Resolved monster %s at %s", self._monster_code, self._monster_pos + ) + + if self._bank_pos is None and self._deposit_loot: + self._bank_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "bank" + ) + if self._bank_pos: + logger.info("Resolved bank at %s", self._bank_pos) diff --git a/backend/app/engine/strategies/crafting.py b/backend/app/engine/strategies/crafting.py new file mode 100644 index 0000000..7c0235c --- /dev/null +++ b/backend/app/engine/strategies/crafting.py @@ -0,0 +1,420 @@ +import logging +from enum import Enum + +from app.engine.pathfinder import Pathfinder +from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy +from app.schemas.game import CharacterSchema, ItemSchema + +logger = logging.getLogger(__name__) + + +class _CraftState(str, Enum): + """Internal state machine states for the crafting loop.""" + + CHECK_MATERIALS = "check_materials" + GATHER_MATERIALS = "gather_materials" + MOVE_TO_BANK_WITHDRAW = "move_to_bank_withdraw" + WITHDRAW_MATERIALS = "withdraw_materials" + MOVE_TO_WORKSHOP = "move_to_workshop" + CRAFT = "craft" + CHECK_RESULT = "check_result" + MOVE_TO_BANK_DEPOSIT = "move_to_bank_deposit" + DEPOSIT = "deposit" + + +# Mapping from craft skill names to their workshop content codes +_SKILL_TO_WORKSHOP: dict[str, str] = { + "weaponcrafting": "weaponcrafting", + "gearcrafting": "gearcrafting", + "jewelrycrafting": "jewelrycrafting", + "cooking": "cooking", + "woodcutting": "woodcutting", + "mining": "mining", + "alchemy": "alchemy", +} + + +class CraftingStrategy(BaseStrategy): + """Automated crafting strategy. + + State machine flow:: + + CHECK_MATERIALS -> (missing?) -> MOVE_TO_BANK_WITHDRAW -> WITHDRAW_MATERIALS + | + -> GATHER_MATERIALS (if gather_materials=True) | + v + -> MOVE_TO_WORKSHOP -> CRAFT -> CHECK_RESULT + | + (recycle?) -> CRAFT (loop for XP) + (done?) -> MOVE_TO_BANK_DEPOSIT -> DEPOSIT + | + (more qty?) -> CHECK_MATERIALS (loop) + + Configuration keys (see :class:`~app.schemas.automation.CraftingConfig`): + - item_code: str -- the item to craft + - quantity: int (default 1) -- how many to craft total + - gather_materials: bool (default False) -- auto-gather missing materials + - recycle_excess: bool (default False) -- recycle crafted items for XP + """ + + def __init__( + self, + config: dict, + pathfinder: Pathfinder, + items_data: list[ItemSchema] | None = None, + ) -> None: + super().__init__(config, pathfinder) + self._state = _CraftState.CHECK_MATERIALS + + # Parsed config with defaults + self._item_code: str = config["item_code"] + self._quantity: int = config.get("quantity", 1) + self._gather_materials: bool = config.get("gather_materials", False) + self._recycle_excess: bool = config.get("recycle_excess", False) + + # Runtime counters + self._crafted_count: int = 0 + + # Recipe data (resolved from game data) + self._recipe: list[dict[str, str | int]] = [] # [{"code": ..., "quantity": ...}] + self._craft_skill: str = "" + self._craft_level: int = 0 + self._recipe_resolved: bool = False + + # If items data is provided, resolve the recipe immediately + if items_data: + self._resolve_recipe(items_data) + + # Cached locations + self._workshop_pos: tuple[int, int] | None = None + self._bank_pos: tuple[int, int] | None = None + + # Sub-state for gathering + self._gather_resource_code: str | None = None + self._gather_pos: tuple[int, int] | None = None + + def get_state(self) -> str: + return self._state.value + + def set_items_data(self, items_data: list[ItemSchema]) -> None: + """Set item data for recipe resolution (called by manager after creation).""" + if not self._recipe_resolved: + self._resolve_recipe(items_data) + + async def next_action(self, character: CharacterSchema) -> ActionPlan: + # Check if we've completed the target quantity + if self._crafted_count >= self._quantity and not self._recycle_excess: + return ActionPlan( + ActionType.COMPLETE, + reason=f"Crafted {self._crafted_count}/{self._quantity} {self._item_code}", + ) + + # If recipe is not resolved, idle until it is + if not self._recipe_resolved: + return ActionPlan( + ActionType.IDLE, + reason=f"Recipe for {self._item_code} not yet resolved from game data", + ) + + # Resolve locations lazily + self._resolve_locations(character) + + match self._state: + case _CraftState.CHECK_MATERIALS: + return self._handle_check_materials(character) + case _CraftState.GATHER_MATERIALS: + return self._handle_gather_materials(character) + case _CraftState.MOVE_TO_BANK_WITHDRAW: + return self._handle_move_to_bank_withdraw(character) + case _CraftState.WITHDRAW_MATERIALS: + return self._handle_withdraw_materials(character) + case _CraftState.MOVE_TO_WORKSHOP: + return self._handle_move_to_workshop(character) + case _CraftState.CRAFT: + return self._handle_craft(character) + case _CraftState.CHECK_RESULT: + return self._handle_check_result(character) + case _CraftState.MOVE_TO_BANK_DEPOSIT: + return self._handle_move_to_bank_deposit(character) + case _CraftState.DEPOSIT: + return self._handle_deposit(character) + case _: + return ActionPlan(ActionType.IDLE, reason="Unknown state") + + # ------------------------------------------------------------------ + # State handlers + # ------------------------------------------------------------------ + + def _handle_check_materials(self, character: CharacterSchema) -> ActionPlan: + """Check if the character has all required materials in inventory.""" + missing = self._get_missing_materials(character) + + if not missing: + # All materials in inventory, go craft + self._state = _CraftState.MOVE_TO_WORKSHOP + return self._handle_move_to_workshop(character) + + # Materials are missing -- try to withdraw from bank first + self._state = _CraftState.MOVE_TO_BANK_WITHDRAW + return self._handle_move_to_bank_withdraw(character) + + def _handle_move_to_bank_withdraw(self, character: CharacterSchema) -> ActionPlan: + if self._bank_pos is None: + return ActionPlan(ActionType.IDLE, reason="No bank tile found on map") + + bx, by = self._bank_pos + if self._is_at(character, bx, by): + self._state = _CraftState.WITHDRAW_MATERIALS + return self._handle_withdraw_materials(character) + + self._state = _CraftState.WITHDRAW_MATERIALS + return ActionPlan( + ActionType.MOVE, + params={"x": bx, "y": by}, + reason=f"Moving to bank at ({bx}, {by}) to withdraw materials", + ) + + def _handle_withdraw_materials(self, character: CharacterSchema) -> ActionPlan: + """Withdraw missing materials from the bank one at a time.""" + missing = self._get_missing_materials(character) + + if not missing: + # All materials acquired, go to workshop + self._state = _CraftState.MOVE_TO_WORKSHOP + return self._handle_move_to_workshop(character) + + # Withdraw the first missing material + code, needed_qty = next(iter(missing.items())) + + # If we should gather and we can't withdraw, switch to gather mode + if self._gather_materials: + # We'll try to withdraw; if it fails the runner will handle the error + # and we can switch to gathering mode. For now, attempt the withdraw. + pass + + return ActionPlan( + ActionType.WITHDRAW_ITEM, + params={"code": code, "quantity": needed_qty}, + reason=f"Withdrawing {needed_qty}x {code} for crafting {self._item_code}", + ) + + def _handle_gather_materials(self, character: CharacterSchema) -> ActionPlan: + """Gather missing materials (if gather_materials is enabled).""" + if self._gather_resource_code is None or self._gather_pos is None: + # Cannot determine what to gather, fall back to check + self._state = _CraftState.CHECK_MATERIALS + return self._handle_check_materials(character) + + gx, gy = self._gather_pos + + if not self._is_at(character, gx, gy): + return ActionPlan( + ActionType.MOVE, + params={"x": gx, "y": gy}, + reason=f"Moving to resource {self._gather_resource_code} at ({gx}, {gy})", + ) + + # Check if inventory is full + if self._inventory_free_slots(character) == 0: + # Need to deposit and try again + self._state = _CraftState.MOVE_TO_BANK_DEPOSIT + return self._handle_move_to_bank_deposit(character) + + # Check if we still need materials + missing = self._get_missing_materials(character) + if not missing: + self._state = _CraftState.MOVE_TO_WORKSHOP + return self._handle_move_to_workshop(character) + + return ActionPlan( + ActionType.GATHER, + reason=f"Gathering {self._gather_resource_code} for crafting materials", + ) + + def _handle_move_to_workshop(self, character: CharacterSchema) -> ActionPlan: + if self._workshop_pos is None: + return ActionPlan( + ActionType.IDLE, + reason=f"No workshop found for skill {self._craft_skill}", + ) + + wx, wy = self._workshop_pos + if self._is_at(character, wx, wy): + self._state = _CraftState.CRAFT + return self._handle_craft(character) + + self._state = _CraftState.CRAFT + return ActionPlan( + ActionType.MOVE, + params={"x": wx, "y": wy}, + reason=f"Moving to {self._craft_skill} workshop at ({wx}, {wy})", + ) + + def _handle_craft(self, character: CharacterSchema) -> ActionPlan: + # Verify we have materials before crafting + missing = self._get_missing_materials(character) + if missing: + # Somehow lost materials, go back to check + self._state = _CraftState.CHECK_MATERIALS + return self._handle_check_materials(character) + + self._state = _CraftState.CHECK_RESULT + return ActionPlan( + ActionType.CRAFT, + params={"code": self._item_code, "quantity": 1}, + reason=f"Crafting {self._item_code} ({self._crafted_count + 1}/{self._quantity})", + ) + + def _handle_check_result(self, character: CharacterSchema) -> ActionPlan: + self._crafted_count += 1 + + if self._recycle_excess: + # Check if we have the item to recycle + has_item = any( + slot.code == self._item_code for slot in character.inventory + ) + if has_item: + # Recycle and go back to check materials for next craft + self._state = _CraftState.CHECK_MATERIALS + return ActionPlan( + ActionType.RECYCLE, + params={"code": self._item_code, "quantity": 1}, + reason=f"Recycling {self._item_code} for XP (crafted {self._crafted_count})", + ) + + # Check if we need to craft more + if self._crafted_count >= self._quantity: + # Done crafting, deposit results + self._state = _CraftState.MOVE_TO_BANK_DEPOSIT + return self._handle_move_to_bank_deposit(character) + + # Check if inventory is getting full + if self._inventory_free_slots(character) <= 2: + self._state = _CraftState.MOVE_TO_BANK_DEPOSIT + return self._handle_move_to_bank_deposit(character) + + # Craft more + self._state = _CraftState.CHECK_MATERIALS + return self._handle_check_materials(character) + + def _handle_move_to_bank_deposit(self, character: CharacterSchema) -> ActionPlan: + if self._bank_pos is None: + return ActionPlan(ActionType.IDLE, reason="No bank tile found on map") + + bx, by = self._bank_pos + if self._is_at(character, bx, by): + self._state = _CraftState.DEPOSIT + return self._handle_deposit(character) + + self._state = _CraftState.DEPOSIT + return ActionPlan( + ActionType.MOVE, + params={"x": bx, "y": by}, + reason=f"Moving to bank at ({bx}, {by}) to deposit crafted items", + ) + + def _handle_deposit(self, character: CharacterSchema) -> ActionPlan: + # Deposit the first non-empty inventory slot + for slot in character.inventory: + if slot.quantity > 0: + return ActionPlan( + ActionType.DEPOSIT_ITEM, + params={"code": slot.code, "quantity": slot.quantity}, + reason=f"Depositing {slot.quantity}x {slot.code}", + ) + + # All deposited + if self._crafted_count >= self._quantity and not self._recycle_excess: + return ActionPlan( + ActionType.COMPLETE, + reason=f"Crafted and deposited {self._crafted_count}/{self._quantity} {self._item_code}", + ) + + # More to craft + self._state = _CraftState.CHECK_MATERIALS + return self._handle_check_materials(character) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _resolve_recipe(self, items_data: list[ItemSchema]) -> None: + """Look up the item's crafting recipe from game data.""" + for item in items_data: + if item.code == self._item_code: + if item.craft is None: + logger.warning( + "Item %s has no crafting recipe", self._item_code + ) + return + + self._craft_skill = item.craft.skill or "" + self._craft_level = item.craft.level or 0 + self._recipe = [ + {"code": ci.code, "quantity": ci.quantity} + for ci in item.craft.items + ] + self._recipe_resolved = True + logger.info( + "Resolved recipe for %s: skill=%s, level=%d, materials=%s", + self._item_code, + self._craft_skill, + self._craft_level, + self._recipe, + ) + return + + logger.warning("Item %s not found in game data", self._item_code) + + def _get_missing_materials(self, character: CharacterSchema) -> dict[str, int]: + """Return a dict of {material_code: needed_quantity} for materials + not currently in the character's inventory.""" + inventory_counts: dict[str, int] = {} + for slot in character.inventory: + inventory_counts[slot.code] = inventory_counts.get(slot.code, 0) + slot.quantity + + missing: dict[str, int] = {} + for mat in self._recipe: + code = str(mat["code"]) + needed = int(mat["quantity"]) + have = inventory_counts.get(code, 0) + if have < needed: + missing[code] = needed - have + + return missing + + def _resolve_locations(self, character: CharacterSchema) -> None: + """Lazily resolve and cache workshop and bank tile positions.""" + if self._workshop_pos is None and self._craft_skill: + workshop_code = _SKILL_TO_WORKSHOP.get(self._craft_skill, self._craft_skill) + self._workshop_pos = self.pathfinder.find_nearest( + character.x, character.y, "workshop", workshop_code + ) + if self._workshop_pos: + logger.info( + "Resolved workshop for %s at %s", + self._craft_skill, + self._workshop_pos, + ) + + if self._bank_pos is None: + self._bank_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "bank" + ) + if self._bank_pos: + logger.info("Resolved bank at %s", self._bank_pos) + + if ( + self._gather_materials + and self._gather_resource_code is not None + and self._gather_pos is None + ): + self._gather_pos = self.pathfinder.find_nearest( + character.x, character.y, "resource", self._gather_resource_code + ) + if self._gather_pos: + logger.info( + "Resolved gather resource %s at %s", + self._gather_resource_code, + self._gather_pos, + ) diff --git a/backend/app/engine/strategies/gathering.py b/backend/app/engine/strategies/gathering.py new file mode 100644 index 0000000..4fcb122 --- /dev/null +++ b/backend/app/engine/strategies/gathering.py @@ -0,0 +1,202 @@ +import logging +from enum import Enum + +from app.engine.pathfinder import Pathfinder +from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy +from app.schemas.game import CharacterSchema + +logger = logging.getLogger(__name__) + + +class _GatherState(str, Enum): + """Internal state machine states for the gathering loop.""" + + MOVE_TO_RESOURCE = "move_to_resource" + GATHER = "gather" + CHECK_INVENTORY = "check_inventory" + MOVE_TO_BANK = "move_to_bank" + DEPOSIT = "deposit" + + +class GatheringStrategy(BaseStrategy): + """Automated gathering strategy. + + State machine flow:: + + MOVE_TO_RESOURCE -> GATHER -> CHECK_INVENTORY + | + (full?) -> MOVE_TO_BANK -> DEPOSIT -> MOVE_TO_RESOURCE + (ok?) -> MOVE_TO_RESOURCE (loop) + | + (max_loops reached?) -> COMPLETE + + Configuration keys (see :class:`~app.schemas.automation.GatheringConfig`): + - resource_code: str + - deposit_on_full: bool (default True) + - max_loops: int (default 0 = infinite) + """ + + def __init__(self, config: dict, pathfinder: Pathfinder) -> None: + super().__init__(config, pathfinder) + self._state = _GatherState.MOVE_TO_RESOURCE + + # Parsed config with defaults + self._resource_code: str = config["resource_code"] + self._deposit_on_full: bool = config.get("deposit_on_full", True) + self._max_loops: int = config.get("max_loops", 0) + + # Runtime counters + self._loop_count: int = 0 + + # Cached locations (resolved lazily) + self._resource_pos: tuple[int, int] | None = None + self._bank_pos: tuple[int, int] | None = None + + def get_state(self) -> str: + return self._state.value + + async def next_action(self, character: CharacterSchema) -> ActionPlan: + # Check loop limit + if self._max_loops > 0 and self._loop_count >= self._max_loops: + return ActionPlan( + ActionType.COMPLETE, + reason=f"Completed {self._loop_count}/{self._max_loops} gather-deposit cycles", + ) + + # Lazily resolve tile positions + self._resolve_locations(character) + + match self._state: + case _GatherState.MOVE_TO_RESOURCE: + return self._handle_move_to_resource(character) + case _GatherState.GATHER: + return self._handle_gather(character) + case _GatherState.CHECK_INVENTORY: + return self._handle_check_inventory(character) + case _GatherState.MOVE_TO_BANK: + return self._handle_move_to_bank(character) + case _GatherState.DEPOSIT: + return self._handle_deposit(character) + case _: + return ActionPlan(ActionType.IDLE, reason="Unknown state") + + # ------------------------------------------------------------------ + # State handlers + # ------------------------------------------------------------------ + + def _handle_move_to_resource(self, character: CharacterSchema) -> ActionPlan: + if self._resource_pos is None: + return ActionPlan( + ActionType.IDLE, + reason=f"No map tile found for resource {self._resource_code}", + ) + + rx, ry = self._resource_pos + + # Already at the resource tile + if self._is_at(character, rx, ry): + self._state = _GatherState.GATHER + return self._handle_gather(character) + + self._state = _GatherState.GATHER # transition after move + return ActionPlan( + ActionType.MOVE, + params={"x": rx, "y": ry}, + reason=f"Moving to resource {self._resource_code} at ({rx}, {ry})", + ) + + def _handle_gather(self, character: CharacterSchema) -> ActionPlan: + # Before gathering, check if inventory is full + if self._inventory_free_slots(character) == 0: + self._state = _GatherState.CHECK_INVENTORY + return self._handle_check_inventory(character) + + self._state = _GatherState.CHECK_INVENTORY # after gather we check inventory + return ActionPlan( + ActionType.GATHER, + reason=f"Gathering {self._resource_code}", + ) + + def _handle_check_inventory(self, character: CharacterSchema) -> ActionPlan: + free_slots = self._inventory_free_slots(character) + + if free_slots == 0 and self._deposit_on_full: + self._state = _GatherState.MOVE_TO_BANK + return self._handle_move_to_bank(character) + + if free_slots == 0 and not self._deposit_on_full: + # Inventory full and not depositing -- complete the automation + return ActionPlan( + ActionType.COMPLETE, + reason="Inventory full and deposit_on_full is disabled", + ) + + # Inventory has space, go gather more + self._state = _GatherState.MOVE_TO_RESOURCE + return self._handle_move_to_resource(character) + + def _handle_move_to_bank(self, character: CharacterSchema) -> ActionPlan: + if self._bank_pos is None: + return ActionPlan( + ActionType.IDLE, + reason="No bank tile found on map", + ) + + bx, by = self._bank_pos + + if self._is_at(character, bx, by): + self._state = _GatherState.DEPOSIT + return self._handle_deposit(character) + + self._state = _GatherState.DEPOSIT # transition after move + return ActionPlan( + ActionType.MOVE, + params={"x": bx, "y": by}, + reason=f"Moving to bank at ({bx}, {by}) to deposit items", + ) + + def _handle_deposit(self, character: CharacterSchema) -> ActionPlan: + # Deposit the first non-empty inventory slot + for slot in character.inventory: + if slot.quantity > 0: + # Stay in DEPOSIT state to deposit the next item on the next tick + return ActionPlan( + ActionType.DEPOSIT_ITEM, + params={"code": slot.code, "quantity": slot.quantity}, + reason=f"Depositing {slot.quantity}x {slot.code}", + ) + + # All items deposited -- count the loop and go back to resource + self._loop_count += 1 + logger.info( + "Gather-deposit cycle %d completed for %s", + self._loop_count, + self._resource_code, + ) + + self._state = _GatherState.MOVE_TO_RESOURCE + return self._handle_move_to_resource(character) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _resolve_locations(self, character: CharacterSchema) -> None: + """Lazily resolve and cache resource / bank tile positions.""" + if self._resource_pos is None: + self._resource_pos = self.pathfinder.find_nearest( + character.x, character.y, "resource", self._resource_code + ) + if self._resource_pos: + logger.info( + "Resolved resource %s at %s", + self._resource_code, + self._resource_pos, + ) + + if self._bank_pos is None and self._deposit_on_full: + self._bank_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "bank" + ) + if self._bank_pos: + logger.info("Resolved bank at %s", self._bank_pos) diff --git a/backend/app/engine/strategies/leveling.py b/backend/app/engine/strategies/leveling.py new file mode 100644 index 0000000..fd31f30 --- /dev/null +++ b/backend/app/engine/strategies/leveling.py @@ -0,0 +1,371 @@ +import logging +from enum import Enum + +from app.engine.pathfinder import Pathfinder +from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy +from app.schemas.game import CharacterSchema, ResourceSchema + +logger = logging.getLogger(__name__) + +# All skills in the game with their gathering/crafting type +_GATHERING_SKILLS = {"mining", "woodcutting", "fishing"} +_CRAFTING_SKILLS = {"weaponcrafting", "gearcrafting", "jewelrycrafting", "cooking", "alchemy"} +_ALL_SKILLS = _GATHERING_SKILLS | _CRAFTING_SKILLS + + +class _LevelingState(str, Enum): + """Internal state machine states for the leveling loop.""" + + EVALUATE = "evaluate" + MOVE_TO_TARGET = "move_to_target" + GATHER = "gather" + FIGHT = "fight" + CHECK_HEALTH = "check_health" + HEAL = "heal" + CHECK_INVENTORY = "check_inventory" + MOVE_TO_BANK = "move_to_bank" + DEPOSIT = "deposit" + + +class LevelingStrategy(BaseStrategy): + """Composite leveling strategy that picks the most optimal activity for XP. + + Analyzes the character's skill levels and focuses on the skill that + needs the most attention, or a specific target skill if configured. + + Configuration keys (see :class:`~app.schemas.automation.LevelingConfig`): + - target_skill: str (default "") -- specific skill to level (empty = auto-pick lowest) + - min_level: int (default 0) -- stop suggestion below this level + - max_level: int (default 0) -- stop when skill reaches this level (0 = no limit) + """ + + def __init__( + self, + config: dict, + pathfinder: Pathfinder, + resources_data: list[ResourceSchema] | None = None, + ) -> None: + super().__init__(config, pathfinder) + self._state = _LevelingState.EVALUATE + + # Config + self._target_skill: str = config.get("target_skill", "") + self._min_level: int = config.get("min_level", 0) + self._max_level: int = config.get("max_level", 0) + + # Resolved from game data + self._resources_data: list[ResourceSchema] = resources_data or [] + + # Runtime state + self._chosen_skill: str = "" + self._chosen_resource_code: str = "" + self._chosen_monster_code: str = "" + self._target_pos: tuple[int, int] | None = None + self._bank_pos: tuple[int, int] | None = None + self._evaluated: bool = False + + def get_state(self) -> str: + if self._chosen_skill: + return f"{self._state.value}:{self._chosen_skill}" + return self._state.value + + def set_resources_data(self, resources_data: list[ResourceSchema]) -> None: + """Set resource data for optimal target selection.""" + self._resources_data = resources_data + + async def next_action(self, character: CharacterSchema) -> ActionPlan: + self._resolve_bank(character) + + match self._state: + case _LevelingState.EVALUATE: + return self._handle_evaluate(character) + case _LevelingState.MOVE_TO_TARGET: + return self._handle_move_to_target(character) + case _LevelingState.GATHER: + return self._handle_gather(character) + case _LevelingState.FIGHT: + return self._handle_fight(character) + case _LevelingState.CHECK_HEALTH: + return self._handle_check_health(character) + case _LevelingState.HEAL: + return self._handle_heal(character) + case _LevelingState.CHECK_INVENTORY: + return self._handle_check_inventory(character) + case _LevelingState.MOVE_TO_BANK: + return self._handle_move_to_bank(character) + case _LevelingState.DEPOSIT: + return self._handle_deposit(character) + case _: + return ActionPlan(ActionType.IDLE, reason="Unknown leveling state") + + # ------------------------------------------------------------------ + # State handlers + # ------------------------------------------------------------------ + + def _handle_evaluate(self, character: CharacterSchema) -> ActionPlan: + """Decide which skill to level and find the best target.""" + if self._target_skill: + skill = self._target_skill + else: + skill = self._find_lowest_skill(character) + + if not skill: + return ActionPlan( + ActionType.COMPLETE, + reason="No skill found to level", + ) + + skill_level = self._get_skill_level(character, skill) + + # Check max_level constraint + if self._max_level > 0 and skill_level >= self._max_level: + if self._target_skill: + return ActionPlan( + ActionType.COMPLETE, + reason=f"Skill {skill} reached target level {self._max_level}", + ) + # Try another skill + skill = self._find_lowest_skill(character, exclude={skill}) + if not skill: + return ActionPlan( + ActionType.COMPLETE, + reason="All skills at or above max_level", + ) + skill_level = self._get_skill_level(character, skill) + + self._chosen_skill = skill + + # Find optimal target + if skill in _GATHERING_SKILLS: + self._choose_gathering_target(character, skill, skill_level) + elif skill == "combat" or skill not in _ALL_SKILLS: + # Combat leveling - find appropriate monster + self._choose_combat_target(character) + else: + # Crafting skills need gathering first, fallback to gathering + # the raw material skill + gathering_skill = self._crafting_to_gathering(skill) + if gathering_skill: + self._choose_gathering_target(character, gathering_skill, skill_level) + else: + return ActionPlan( + ActionType.IDLE, + reason=f"No leveling strategy available for {skill}", + ) + + if self._target_pos is None: + return ActionPlan( + ActionType.IDLE, + reason=f"No target found for leveling {skill} at level {skill_level}", + ) + + self._state = _LevelingState.MOVE_TO_TARGET + self._evaluated = True + + logger.info( + "Leveling strategy: skill=%s, level=%d, resource=%s, monster=%s", + skill, + skill_level, + self._chosen_resource_code, + self._chosen_monster_code, + ) + + return self._handle_move_to_target(character) + + def _handle_move_to_target(self, character: CharacterSchema) -> ActionPlan: + if self._target_pos is None: + self._state = _LevelingState.EVALUATE + return ActionPlan(ActionType.IDLE, reason="Target position lost, re-evaluating") + + tx, ty = self._target_pos + if self._is_at(character, tx, ty): + if self._chosen_resource_code: + self._state = _LevelingState.GATHER + return self._handle_gather(character) + elif self._chosen_monster_code: + self._state = _LevelingState.FIGHT + return self._handle_fight(character) + return ActionPlan(ActionType.IDLE, reason="At target but no action determined") + + if self._chosen_resource_code: + self._state = _LevelingState.GATHER + else: + self._state = _LevelingState.FIGHT + + return ActionPlan( + ActionType.MOVE, + params={"x": tx, "y": ty}, + reason=f"Moving to leveling target at ({tx}, {ty}) for {self._chosen_skill}", + ) + + def _handle_gather(self, character: CharacterSchema) -> ActionPlan: + if self._inventory_free_slots(character) == 0: + self._state = _LevelingState.CHECK_INVENTORY + return self._handle_check_inventory(character) + + # Re-evaluate periodically to check if level changed + skill_level = self._get_skill_level(character, self._chosen_skill) + if self._max_level > 0 and skill_level >= self._max_level: + self._state = _LevelingState.EVALUATE + self._target_pos = None + return self._handle_evaluate(character) + + self._state = _LevelingState.CHECK_INVENTORY + return ActionPlan( + ActionType.GATHER, + reason=f"Gathering for {self._chosen_skill} XP (level {skill_level})", + ) + + def _handle_fight(self, character: CharacterSchema) -> ActionPlan: + if self._hp_percent(character) < 50: + self._state = _LevelingState.CHECK_HEALTH + return self._handle_check_health(character) + + self._state = _LevelingState.CHECK_HEALTH + return ActionPlan( + ActionType.FIGHT, + reason=f"Fighting for combat XP", + ) + + def _handle_check_health(self, character: CharacterSchema) -> ActionPlan: + if self._hp_percent(character) < 50: + self._state = _LevelingState.HEAL + return self._handle_heal(character) + + self._state = _LevelingState.CHECK_INVENTORY + return self._handle_check_inventory(character) + + def _handle_heal(self, character: CharacterSchema) -> ActionPlan: + if self._hp_percent(character) >= 100.0: + self._state = _LevelingState.CHECK_INVENTORY + return self._handle_check_inventory(character) + + self._state = _LevelingState.CHECK_HEALTH + return ActionPlan( + ActionType.REST, + reason=f"Resting to heal (HP {character.hp}/{character.max_hp})", + ) + + def _handle_check_inventory(self, character: CharacterSchema) -> ActionPlan: + if self._inventory_free_slots(character) == 0: + self._state = _LevelingState.MOVE_TO_BANK + return self._handle_move_to_bank(character) + + # Continue the current activity + self._state = _LevelingState.MOVE_TO_TARGET + return self._handle_move_to_target(character) + + def _handle_move_to_bank(self, character: CharacterSchema) -> ActionPlan: + if self._bank_pos is None: + return ActionPlan(ActionType.IDLE, reason="No bank tile found") + + bx, by = self._bank_pos + if self._is_at(character, bx, by): + self._state = _LevelingState.DEPOSIT + return self._handle_deposit(character) + + self._state = _LevelingState.DEPOSIT + return ActionPlan( + ActionType.MOVE, + params={"x": bx, "y": by}, + reason=f"Moving to bank at ({bx}, {by}) to deposit", + ) + + def _handle_deposit(self, character: CharacterSchema) -> ActionPlan: + for slot in character.inventory: + if slot.quantity > 0: + return ActionPlan( + ActionType.DEPOSIT_ITEM, + params={"code": slot.code, "quantity": slot.quantity}, + reason=f"Depositing {slot.quantity}x {slot.code}", + ) + + # Re-evaluate after depositing (skill might have leveled) + self._state = _LevelingState.EVALUATE + self._target_pos = None + return self._handle_evaluate(character) + + # ------------------------------------------------------------------ + # Skill analysis helpers + # ------------------------------------------------------------------ + + def _find_lowest_skill( + self, + character: CharacterSchema, + exclude: set[str] | None = None, + ) -> str: + """Find the gathering/crafting skill with the lowest level.""" + exclude = exclude or set() + lowest_skill = "" + lowest_level = float("inf") + + for skill in _GATHERING_SKILLS: + if skill in exclude: + continue + level = self._get_skill_level(character, skill) + if level < lowest_level: + lowest_level = level + lowest_skill = skill + + return lowest_skill + + @staticmethod + def _get_skill_level(character: CharacterSchema, skill: str) -> int: + """Extract skill level from character, defaulting to 0.""" + attr = f"{skill}_level" + return getattr(character, attr, 0) + + def _choose_gathering_target( + self, + character: CharacterSchema, + skill: str, + skill_level: int, + ) -> None: + """Choose the best resource to gather for a given skill and level.""" + # Filter resources matching the skill + matching = [r for r in self._resources_data if r.skill == skill] + if not matching: + # Fallback: use pathfinder to find any resource of this skill + self._target_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "resource" + ) + return + + # Find the best resource within +-3 levels + candidates = [] + for r in matching: + diff = r.level - skill_level + if diff <= 3: # Can gather up to 3 levels above + candidates.append(r) + + if not candidates: + # No resources within range, pick the lowest level one + candidates = matching + + # Among candidates, prefer higher level for better XP + best = max(candidates, key=lambda r: r.level if r.level <= skill_level + 3 else -r.level) + self._chosen_resource_code = best.code + + self._target_pos = self.pathfinder.find_nearest( + character.x, character.y, "resource", best.code + ) + + def _choose_combat_target(self, character: CharacterSchema) -> None: + """Choose a monster appropriate for the character's combat level.""" + # Find a monster near the character's level + self._chosen_monster_code = "" + self._target_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "monster" + ) + + @staticmethod + def _crafting_to_gathering(crafting_skill: str) -> str: + """Map a crafting skill to its primary gathering skill.""" + mapping = { + "weaponcrafting": "mining", + "gearcrafting": "mining", + "jewelrycrafting": "mining", + "cooking": "fishing", + "alchemy": "mining", + } + return mapping.get(crafting_skill, "") diff --git a/backend/app/engine/strategies/task.py b/backend/app/engine/strategies/task.py new file mode 100644 index 0000000..20511b2 --- /dev/null +++ b/backend/app/engine/strategies/task.py @@ -0,0 +1,425 @@ +import logging +from enum import Enum + +from app.engine.pathfinder import Pathfinder +from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy +from app.schemas.game import CharacterSchema + +logger = logging.getLogger(__name__) + + +class _TaskState(str, Enum): + """Internal state machine states for the task loop.""" + + MOVE_TO_TASKMASTER = "move_to_taskmaster" + ACCEPT_TASK = "accept_task" + EVALUATE_TASK = "evaluate_task" + DO_REQUIREMENTS = "do_requirements" + MOVE_TO_TASK_TARGET = "move_to_task_target" + EXECUTE_TASK_ACTION = "execute_task_action" + CHECK_TASK_PROGRESS = "check_task_progress" + CHECK_HEALTH = "check_health" + HEAL = "heal" + MOVE_TO_BANK = "move_to_bank" + DEPOSIT = "deposit" + MOVE_TO_TASKMASTER_TRADE = "move_to_taskmaster_trade" + TRADE_ITEMS = "trade_items" + COMPLETE_TASK = "complete_task" + EXCHANGE_COINS = "exchange_coins" + + +class TaskStrategy(BaseStrategy): + """Automated task completion strategy. + + State machine flow:: + + MOVE_TO_TASKMASTER -> ACCEPT_TASK -> EVALUATE_TASK + | + -> DO_REQUIREMENTS -> MOVE_TO_TASK_TARGET + -> EXECUTE_TASK_ACTION + -> CHECK_TASK_PROGRESS + | + (done?) -> MOVE_TO_TASKMASTER_TRADE + -> TRADE_ITEMS + -> COMPLETE_TASK + -> EXCHANGE_COINS + -> (loop to ACCEPT_TASK) + (not done?) -> EXECUTE_TASK_ACTION (loop) + + Configuration keys (see :class:`~app.schemas.automation.TaskConfig`): + - max_tasks: int (default 0 = infinite) -- max tasks to complete + - auto_exchange: bool (default True) -- exchange task coins automatically + - task_type: str (default "") -- preferred task type filter (empty = any) + """ + + def __init__(self, config: dict, pathfinder: Pathfinder) -> None: + super().__init__(config, pathfinder) + self._state = _TaskState.MOVE_TO_TASKMASTER + + # Config + self._max_tasks: int = config.get("max_tasks", 0) + self._auto_exchange: bool = config.get("auto_exchange", True) + self._task_type_filter: str = config.get("task_type", "") + + # Runtime state + self._tasks_completed: int = 0 + self._current_task_code: str = "" + self._current_task_type: str = "" + self._current_task_total: int = 0 + + # Cached positions + self._taskmaster_pos: tuple[int, int] | None = None + self._task_target_pos: tuple[int, int] | None = None + self._bank_pos: tuple[int, int] | None = None + + def get_state(self) -> str: + return self._state.value + + async def next_action(self, character: CharacterSchema) -> ActionPlan: + # Check if we've completed enough tasks + if self._max_tasks > 0 and self._tasks_completed >= self._max_tasks: + return ActionPlan( + ActionType.COMPLETE, + reason=f"Completed {self._tasks_completed}/{self._max_tasks} tasks", + ) + + self._resolve_locations(character) + + match self._state: + case _TaskState.MOVE_TO_TASKMASTER: + return self._handle_move_to_taskmaster(character) + case _TaskState.ACCEPT_TASK: + return self._handle_accept_task(character) + case _TaskState.EVALUATE_TASK: + return self._handle_evaluate_task(character) + case _TaskState.DO_REQUIREMENTS: + return self._handle_do_requirements(character) + case _TaskState.MOVE_TO_TASK_TARGET: + return self._handle_move_to_task_target(character) + case _TaskState.EXECUTE_TASK_ACTION: + return self._handle_execute_task_action(character) + case _TaskState.CHECK_TASK_PROGRESS: + return self._handle_check_task_progress(character) + case _TaskState.CHECK_HEALTH: + return self._handle_check_health(character) + case _TaskState.HEAL: + return self._handle_heal(character) + case _TaskState.MOVE_TO_BANK: + return self._handle_move_to_bank(character) + case _TaskState.DEPOSIT: + return self._handle_deposit(character) + case _TaskState.MOVE_TO_TASKMASTER_TRADE: + return self._handle_move_to_taskmaster_trade(character) + case _TaskState.TRADE_ITEMS: + return self._handle_trade_items(character) + case _TaskState.COMPLETE_TASK: + return self._handle_complete_task(character) + case _TaskState.EXCHANGE_COINS: + return self._handle_exchange_coins(character) + case _: + return ActionPlan(ActionType.IDLE, reason="Unknown task state") + + # ------------------------------------------------------------------ + # State handlers + # ------------------------------------------------------------------ + + def _handle_move_to_taskmaster(self, character: CharacterSchema) -> ActionPlan: + # If character already has a task, skip to evaluating it + if character.task and character.task_type: + self._current_task_code = character.task + self._current_task_type = character.task_type + self._current_task_total = character.task_total + self._state = _TaskState.EVALUATE_TASK + return self._handle_evaluate_task(character) + + if self._taskmaster_pos is None: + return ActionPlan( + ActionType.IDLE, + reason="No task master NPC found on map", + ) + + tx, ty = self._taskmaster_pos + if self._is_at(character, tx, ty): + self._state = _TaskState.ACCEPT_TASK + return self._handle_accept_task(character) + + self._state = _TaskState.ACCEPT_TASK + return ActionPlan( + ActionType.MOVE, + params={"x": tx, "y": ty}, + reason=f"Moving to task master at ({tx}, {ty})", + ) + + def _handle_accept_task(self, character: CharacterSchema) -> ActionPlan: + # If already has a task, evaluate it + if character.task and character.task_type: + self._current_task_code = character.task + self._current_task_type = character.task_type + self._current_task_total = character.task_total + self._state = _TaskState.EVALUATE_TASK + return self._handle_evaluate_task(character) + + # Accept a new task (the API call is task_new) + self._state = _TaskState.EVALUATE_TASK + return ActionPlan( + ActionType.TASK_NEW, + reason="Accepting new task from task master", + ) + + def _handle_evaluate_task(self, character: CharacterSchema) -> ActionPlan: + """Evaluate the current task and determine where to go.""" + self._current_task_code = character.task + self._current_task_type = character.task_type + self._current_task_total = character.task_total + + if not self._current_task_code: + # No task assigned, go accept one + self._state = _TaskState.MOVE_TO_TASKMASTER + return self._handle_move_to_taskmaster(character) + + # Check if task is already complete + if character.task_progress >= character.task_total: + self._state = _TaskState.MOVE_TO_TASKMASTER_TRADE + return self._handle_move_to_taskmaster_trade(character) + + # Determine target location based on task type + self._resolve_task_target(character) + + self._state = _TaskState.MOVE_TO_TASK_TARGET + return self._handle_move_to_task_target(character) + + def _handle_do_requirements(self, character: CharacterSchema) -> ActionPlan: + # Redirect to move to task target + self._state = _TaskState.MOVE_TO_TASK_TARGET + return self._handle_move_to_task_target(character) + + def _handle_move_to_task_target(self, character: CharacterSchema) -> ActionPlan: + if self._task_target_pos is None: + return ActionPlan( + ActionType.IDLE, + reason=f"No target found for task {self._current_task_code} (type={self._current_task_type})", + ) + + tx, ty = self._task_target_pos + if self._is_at(character, tx, ty): + self._state = _TaskState.EXECUTE_TASK_ACTION + return self._handle_execute_task_action(character) + + self._state = _TaskState.EXECUTE_TASK_ACTION + return ActionPlan( + ActionType.MOVE, + params={"x": tx, "y": ty}, + reason=f"Moving to task target at ({tx}, {ty}) for {self._current_task_code}", + ) + + def _handle_execute_task_action(self, character: CharacterSchema) -> ActionPlan: + """Execute the appropriate action for the current task type.""" + task_type = self._current_task_type.lower() + + if task_type == "monsters": + # Check health before fighting + if self._hp_percent(character) < 50: + self._state = _TaskState.CHECK_HEALTH + return self._handle_check_health(character) + + self._state = _TaskState.CHECK_TASK_PROGRESS + return ActionPlan( + ActionType.FIGHT, + reason=f"Fighting for task: {self._current_task_code}", + ) + + if task_type in ("resources", "items"): + # Check inventory + if self._inventory_free_slots(character) == 0: + self._state = _TaskState.MOVE_TO_BANK + return self._handle_move_to_bank(character) + + self._state = _TaskState.CHECK_TASK_PROGRESS + return ActionPlan( + ActionType.GATHER, + reason=f"Gathering for task: {self._current_task_code}", + ) + + # Unknown task type, try to fight as default + self._state = _TaskState.CHECK_TASK_PROGRESS + return ActionPlan( + ActionType.FIGHT, + reason=f"Executing task action for {self._current_task_code} (type={task_type})", + ) + + def _handle_check_task_progress(self, character: CharacterSchema) -> ActionPlan: + """Check if the task requirements are met.""" + if character.task_progress >= character.task_total: + # Task requirements met, go trade + self._state = _TaskState.MOVE_TO_TASKMASTER_TRADE + return self._handle_move_to_taskmaster_trade(character) + + # Check inventory for deposit needs + if self._inventory_free_slots(character) <= 1: + self._state = _TaskState.MOVE_TO_BANK + return self._handle_move_to_bank(character) + + # Continue the task action + self._state = _TaskState.EXECUTE_TASK_ACTION + return self._handle_execute_task_action(character) + + def _handle_check_health(self, character: CharacterSchema) -> ActionPlan: + if self._hp_percent(character) >= 50: + self._state = _TaskState.EXECUTE_TASK_ACTION + return self._handle_execute_task_action(character) + + self._state = _TaskState.HEAL + return self._handle_heal(character) + + def _handle_heal(self, character: CharacterSchema) -> ActionPlan: + if self._hp_percent(character) >= 100.0: + self._state = _TaskState.EXECUTE_TASK_ACTION + return self._handle_execute_task_action(character) + + self._state = _TaskState.CHECK_HEALTH + return ActionPlan( + ActionType.REST, + reason=f"Resting to heal during task (HP {character.hp}/{character.max_hp})", + ) + + def _handle_move_to_bank(self, character: CharacterSchema) -> ActionPlan: + if self._bank_pos is None: + return ActionPlan(ActionType.IDLE, reason="No bank tile found") + + bx, by = self._bank_pos + if self._is_at(character, bx, by): + self._state = _TaskState.DEPOSIT + return self._handle_deposit(character) + + self._state = _TaskState.DEPOSIT + return ActionPlan( + ActionType.MOVE, + params={"x": bx, "y": by}, + reason=f"Moving to bank at ({bx}, {by}) to deposit during task", + ) + + def _handle_deposit(self, character: CharacterSchema) -> ActionPlan: + for slot in character.inventory: + if slot.quantity > 0: + return ActionPlan( + ActionType.DEPOSIT_ITEM, + params={"code": slot.code, "quantity": slot.quantity}, + reason=f"Depositing {slot.quantity}x {slot.code}", + ) + + # All deposited, go back to task + self._state = _TaskState.MOVE_TO_TASK_TARGET + return self._handle_move_to_task_target(character) + + def _handle_move_to_taskmaster_trade(self, character: CharacterSchema) -> ActionPlan: + if self._taskmaster_pos is None: + return ActionPlan(ActionType.IDLE, reason="No task master found") + + tx, ty = self._taskmaster_pos + if self._is_at(character, tx, ty): + self._state = _TaskState.TRADE_ITEMS + return self._handle_trade_items(character) + + self._state = _TaskState.TRADE_ITEMS + return ActionPlan( + ActionType.MOVE, + params={"x": tx, "y": ty}, + reason=f"Moving to task master at ({tx}, {ty}) to trade items", + ) + + def _handle_trade_items(self, character: CharacterSchema) -> ActionPlan: + """Trade the required items to the task master.""" + # The task_trade action requires the task item code and quantity + if not self._current_task_code: + self._state = _TaskState.COMPLETE_TASK + return self._handle_complete_task(character) + + self._state = _TaskState.COMPLETE_TASK + return ActionPlan( + ActionType.TASK_TRADE, + params={ + "code": self._current_task_code, + "quantity": self._current_task_total, + }, + reason=f"Trading {self._current_task_total}x {self._current_task_code} to task master", + ) + + def _handle_complete_task(self, character: CharacterSchema) -> ActionPlan: + """Complete the task at the task master.""" + self._tasks_completed += 1 + + if self._auto_exchange: + self._state = _TaskState.EXCHANGE_COINS + else: + self._state = _TaskState.MOVE_TO_TASKMASTER # loop for next task + + return ActionPlan( + ActionType.TASK_COMPLETE, + reason=f"Completing task #{self._tasks_completed}: {self._current_task_code}", + ) + + def _handle_exchange_coins(self, character: CharacterSchema) -> ActionPlan: + """Exchange task coins for rewards.""" + # Reset for next task + self._current_task_code = "" + self._current_task_type = "" + self._current_task_total = 0 + self._task_target_pos = None + + self._state = _TaskState.MOVE_TO_TASKMASTER + return ActionPlan( + ActionType.TASK_EXCHANGE, + reason="Exchanging task coins for rewards", + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _resolve_locations(self, character: CharacterSchema) -> None: + """Lazily resolve and cache tile positions.""" + if self._taskmaster_pos is None: + # Task masters are NPCs of type "tasks_master" + self._taskmaster_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "tasks_master" + ) + if self._taskmaster_pos: + logger.info("Resolved task master at %s", self._taskmaster_pos) + + if self._bank_pos is None: + self._bank_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "bank" + ) + if self._bank_pos: + logger.info("Resolved bank at %s", self._bank_pos) + + def _resolve_task_target(self, character: CharacterSchema) -> None: + """Resolve the target location for the current task.""" + task_type = self._current_task_type.lower() + task_code = self._current_task_code + + if task_type == "monsters": + self._task_target_pos = self.pathfinder.find_nearest( + character.x, character.y, "monster", task_code + ) + elif task_type in ("resources", "items"): + self._task_target_pos = self.pathfinder.find_nearest( + character.x, character.y, "resource", task_code + ) + else: + # Try monster first, then resource + self._task_target_pos = self.pathfinder.find_nearest( + character.x, character.y, "monster", task_code + ) + if self._task_target_pos is None: + self._task_target_pos = self.pathfinder.find_nearest( + character.x, character.y, "resource", task_code + ) + + if self._task_target_pos: + logger.info( + "Resolved task target %s (%s) at %s", + task_code, + task_type, + self._task_target_pos, + ) diff --git a/backend/app/engine/strategies/trading.py b/backend/app/engine/strategies/trading.py new file mode 100644 index 0000000..e4b338f --- /dev/null +++ b/backend/app/engine/strategies/trading.py @@ -0,0 +1,307 @@ +import logging +from enum import Enum + +from app.engine.pathfinder import Pathfinder +from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy +from app.schemas.game import CharacterSchema + +logger = logging.getLogger(__name__) + + +class _TradingState(str, Enum): + """Internal state machine states for the trading loop.""" + + MOVE_TO_BANK = "move_to_bank" + WITHDRAW_ITEMS = "withdraw_items" + MOVE_TO_GE = "move_to_ge" + CREATE_SELL_ORDER = "create_sell_order" + CREATE_BUY_ORDER = "create_buy_order" + WAIT_FOR_ORDER = "wait_for_order" + CHECK_ORDERS = "check_orders" + COLLECT_ITEMS = "collect_items" + DEPOSIT_ITEMS = "deposit_items" + + +# ActionType extensions for GE operations (handled via params in the runner) +# We reuse CRAFT action type slot to send GE-specific actions; the runner +# dispatches based on action_type enum. We add new action types to base. + +class _TradingMode(str, Enum): + SELL_LOOT = "sell_loot" + BUY_MATERIALS = "buy_materials" + FLIP = "flip" + + +class TradingStrategy(BaseStrategy): + """Automated Grand Exchange trading strategy. + + Supports three modes: + + **sell_loot** -- Move to bank, withdraw items, move to GE, create sell orders. + **buy_materials** -- Move to GE, create buy orders, wait, collect. + **flip** -- Buy low, sell high based on price history margins. + + Configuration keys (see :class:`~app.schemas.automation.TradingConfig`): + - mode: str ("sell_loot"|"buy_materials"|"flip") + - item_code: str + - quantity: int (default 1) + - min_price: int (default 0) -- minimum acceptable price + - max_price: int (default 0) -- maximum acceptable price (0 = no limit) + """ + + def __init__(self, config: dict, pathfinder: Pathfinder) -> None: + super().__init__(config, pathfinder) + + # Parse config + mode_str = config.get("mode", "sell_loot") + try: + self._mode = _TradingMode(mode_str) + except ValueError: + logger.warning("Unknown trading mode %r, defaulting to sell_loot", mode_str) + self._mode = _TradingMode.SELL_LOOT + + self._item_code: str = config["item_code"] + self._quantity: int = config.get("quantity", 1) + self._min_price: int = config.get("min_price", 0) + self._max_price: int = config.get("max_price", 0) + + # Determine initial state based on mode + if self._mode == _TradingMode.SELL_LOOT: + self._state = _TradingState.MOVE_TO_BANK + elif self._mode == _TradingMode.BUY_MATERIALS: + self._state = _TradingState.MOVE_TO_GE + elif self._mode == _TradingMode.FLIP: + self._state = _TradingState.MOVE_TO_GE + else: + self._state = _TradingState.MOVE_TO_GE + + # Runtime state + self._items_withdrawn: int = 0 + self._orders_created: bool = False + self._wait_cycles: int = 0 + + # Cached positions + self._bank_pos: tuple[int, int] | None = None + self._ge_pos: tuple[int, int] | None = None + + def get_state(self) -> str: + return f"{self._mode.value}:{self._state.value}" + + async def next_action(self, character: CharacterSchema) -> ActionPlan: + self._resolve_locations(character) + + match self._state: + case _TradingState.MOVE_TO_BANK: + return self._handle_move_to_bank(character) + case _TradingState.WITHDRAW_ITEMS: + return self._handle_withdraw_items(character) + case _TradingState.MOVE_TO_GE: + return self._handle_move_to_ge(character) + case _TradingState.CREATE_SELL_ORDER: + return self._handle_create_sell_order(character) + case _TradingState.CREATE_BUY_ORDER: + return self._handle_create_buy_order(character) + case _TradingState.WAIT_FOR_ORDER: + return self._handle_wait_for_order(character) + case _TradingState.CHECK_ORDERS: + return self._handle_check_orders(character) + case _TradingState.COLLECT_ITEMS: + return self._handle_collect_items(character) + case _TradingState.DEPOSIT_ITEMS: + return self._handle_deposit_items(character) + case _: + return ActionPlan(ActionType.IDLE, reason="Unknown trading state") + + # ------------------------------------------------------------------ + # State handlers + # ------------------------------------------------------------------ + + def _handle_move_to_bank(self, character: CharacterSchema) -> ActionPlan: + if self._bank_pos is None: + return ActionPlan(ActionType.IDLE, reason="No bank tile found") + + bx, by = self._bank_pos + if self._is_at(character, bx, by): + self._state = _TradingState.WITHDRAW_ITEMS + return self._handle_withdraw_items(character) + + self._state = _TradingState.WITHDRAW_ITEMS + return ActionPlan( + ActionType.MOVE, + params={"x": bx, "y": by}, + reason=f"Moving to bank at ({bx}, {by}) to withdraw items for sale", + ) + + def _handle_withdraw_items(self, character: CharacterSchema) -> ActionPlan: + # Calculate how many we still need to withdraw + remaining = self._quantity - self._items_withdrawn + if remaining <= 0: + self._state = _TradingState.MOVE_TO_GE + return self._handle_move_to_ge(character) + + # Check inventory space + free = self._inventory_free_slots(character) + if free <= 0: + self._state = _TradingState.MOVE_TO_GE + return self._handle_move_to_ge(character) + + withdraw_qty = min(remaining, free) + self._items_withdrawn += withdraw_qty + + return ActionPlan( + ActionType.WITHDRAW_ITEM, + params={"code": self._item_code, "quantity": withdraw_qty}, + reason=f"Withdrawing {withdraw_qty}x {self._item_code} for GE sale", + ) + + def _handle_move_to_ge(self, character: CharacterSchema) -> ActionPlan: + if self._ge_pos is None: + return ActionPlan(ActionType.IDLE, reason="No Grand Exchange tile found") + + gx, gy = self._ge_pos + if self._is_at(character, gx, gy): + if self._mode == _TradingMode.SELL_LOOT: + self._state = _TradingState.CREATE_SELL_ORDER + return self._handle_create_sell_order(character) + elif self._mode == _TradingMode.BUY_MATERIALS: + self._state = _TradingState.CREATE_BUY_ORDER + return self._handle_create_buy_order(character) + elif self._mode == _TradingMode.FLIP: + if not self._orders_created: + self._state = _TradingState.CREATE_BUY_ORDER + return self._handle_create_buy_order(character) + else: + self._state = _TradingState.CREATE_SELL_ORDER + return self._handle_create_sell_order(character) + return ActionPlan(ActionType.IDLE, reason="At GE but unknown mode") + + # Determine next state based on mode + if self._mode == _TradingMode.SELL_LOOT: + self._state = _TradingState.CREATE_SELL_ORDER + elif self._mode == _TradingMode.BUY_MATERIALS: + self._state = _TradingState.CREATE_BUY_ORDER + elif self._mode == _TradingMode.FLIP: + self._state = _TradingState.CREATE_BUY_ORDER + + return ActionPlan( + ActionType.MOVE, + params={"x": gx, "y": gy}, + reason=f"Moving to Grand Exchange at ({gx}, {gy})", + ) + + def _handle_create_sell_order(self, character: CharacterSchema) -> ActionPlan: + # Check if we have items to sell in inventory + item_in_inv = None + for slot in character.inventory: + if slot.code == self._item_code and slot.quantity > 0: + item_in_inv = slot + break + + if item_in_inv is None: + # Nothing to sell, we're done + return ActionPlan( + ActionType.COMPLETE, + reason=f"No {self._item_code} in inventory to sell", + ) + + sell_price = self._min_price if self._min_price > 0 else 1 + sell_qty = min(item_in_inv.quantity, self._quantity) + + self._orders_created = True + self._state = _TradingState.WAIT_FOR_ORDER + + return ActionPlan( + ActionType.GE_SELL, + params={ + "code": self._item_code, + "quantity": sell_qty, + "price": sell_price, + }, + reason=f"Creating sell order: {sell_qty}x {self._item_code} at {sell_price} gold each", + ) + + def _handle_create_buy_order(self, character: CharacterSchema) -> ActionPlan: + buy_price = self._max_price if self._max_price > 0 else 1 + + self._orders_created = True + self._state = _TradingState.WAIT_FOR_ORDER + + return ActionPlan( + ActionType.GE_BUY, + params={ + "code": self._item_code, + "quantity": self._quantity, + "price": buy_price, + }, + reason=f"Creating buy order: {self._quantity}x {self._item_code} at {buy_price} gold each", + ) + + def _handle_wait_for_order(self, character: CharacterSchema) -> ActionPlan: + self._wait_cycles += 1 + + # Wait for a reasonable time, then check + if self._wait_cycles < 3: + return ActionPlan( + ActionType.IDLE, + reason=f"Waiting for GE order to fill (cycle {self._wait_cycles})", + ) + + # After waiting, check orders + self._state = _TradingState.CHECK_ORDERS + return self._handle_check_orders(character) + + def _handle_check_orders(self, character: CharacterSchema) -> ActionPlan: + # For now, just complete after creating orders + # In a full implementation, we'd check the GE order status + if self._mode == _TradingMode.FLIP and self._orders_created: + # For flip mode, once buy order is done, create sell + self._state = _TradingState.CREATE_SELL_ORDER + return ActionPlan( + ActionType.IDLE, + reason="Checking order status for flip trade", + ) + + return ActionPlan( + ActionType.COMPLETE, + reason=f"Trading operation complete for {self._item_code} (mode={self._mode.value})", + ) + + def _handle_collect_items(self, character: CharacterSchema) -> ActionPlan: + # In the actual game, items from filled orders go to inventory automatically + self._state = _TradingState.DEPOSIT_ITEMS + return self._handle_deposit_items(character) + + def _handle_deposit_items(self, character: CharacterSchema) -> ActionPlan: + # Deposit any items in inventory + for slot in character.inventory: + if slot.quantity > 0: + return ActionPlan( + ActionType.DEPOSIT_ITEM, + params={"code": slot.code, "quantity": slot.quantity}, + reason=f"Depositing {slot.quantity}x {slot.code} from trading", + ) + + return ActionPlan( + ActionType.COMPLETE, + reason=f"Trading complete for {self._item_code}", + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _resolve_locations(self, character: CharacterSchema) -> None: + """Lazily resolve and cache bank and GE tile positions.""" + if self._bank_pos is None: + self._bank_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "bank" + ) + if self._bank_pos: + logger.info("Resolved bank at %s", self._bank_pos) + + if self._ge_pos is None: + self._ge_pos = self.pathfinder.find_nearest_by_type( + character.x, character.y, "grand_exchange" + ) + if self._ge_pos: + logger.info("Resolved Grand Exchange at %s", self._ge_pos) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0db761f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,242 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.database import async_session_factory, engine, Base +from app.services.artifacts_client import ArtifactsClient +from app.services.character_service import CharacterService +from app.services.game_data_cache import GameDataCacheService + +# Import models so they are registered on Base.metadata +from app.models import game_cache as _game_cache_model # noqa: F401 +from app.models import character_snapshot as _snapshot_model # noqa: F401 +from app.models import automation as _automation_model # noqa: F401 +from app.models import price_history as _price_history_model # noqa: F401 +from app.models import event_log as _event_log_model # noqa: F401 + +# Import routers +from app.api.characters import router as characters_router +from app.api.game_data import router as game_data_router +from app.api.dashboard import router as dashboard_router +from app.api.bank import router as bank_router +from app.api.automations import router as automations_router +from app.api.ws import router as ws_router +from app.api.exchange import router as exchange_router +from app.api.events import router as events_router +from app.api.logs import router as logs_router + +# Automation engine +from app.engine.pathfinder import Pathfinder +from app.engine.manager import AutomationManager + +# Exchange service +from app.services.exchange_service import ExchangeService + +# WebSocket system +from app.websocket.event_bus import EventBus +from app.websocket.client import GameWebSocketClient +from app.websocket.handlers import GameEventHandler + +logger = logging.getLogger(__name__) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) + + +async def _snapshot_loop( + db_factory: async_session_factory.__class__, + client: ArtifactsClient, + character_service: CharacterService, + interval: float = 60.0, +) -> None: + """Periodically save character snapshots.""" + while True: + try: + async with db_factory() as db: + await character_service.take_snapshot(db, client) + except asyncio.CancelledError: + logger.info("Character snapshot loop cancelled") + return + except Exception: + logger.exception("Error taking character snapshot") + await asyncio.sleep(interval) + + +async def _load_pathfinder_maps( + pathfinder: Pathfinder, + cache_service: GameDataCacheService, +) -> None: + """Load map data from the game data cache into the pathfinder. + + Retries with a short delay if the cache has not been populated yet + (e.g. the background refresh has not completed its first pass). + """ + max_attempts = 10 + for attempt in range(1, max_attempts + 1): + try: + async with async_session_factory() as db: + maps = await cache_service.get_maps(db) + if maps: + pathfinder.load_maps(maps) + logger.info("Pathfinder loaded %d map tiles", len(maps)) + return + logger.info( + "Map cache empty, retrying (%d/%d)", + attempt, + max_attempts, + ) + except Exception: + logger.exception( + "Error loading maps into pathfinder (attempt %d/%d)", + attempt, + max_attempts, + ) + await asyncio.sleep(5) + + logger.warning( + "Pathfinder could not load maps after %d attempts; " + "automations that depend on pathfinding will not work until maps are cached", + max_attempts, + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + # --- Startup --- + + # Create tables if they do not exist (useful for dev; in prod rely on Alembic) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Instantiate shared services + client = ArtifactsClient() + cache_service = GameDataCacheService() + character_service = CharacterService() + + # Event bus for internal pub/sub + event_bus = EventBus() + + exchange_service = ExchangeService() + + app.state.artifacts_client = client + app.state.cache_service = cache_service + app.state.character_service = character_service + app.state.event_bus = event_bus + app.state.exchange_service = exchange_service + + # Start background cache refresh (runs immediately, then every 30 min) + cache_task = cache_service.start_background_refresh( + db_factory=async_session_factory, + client=client, + ) + + # Start periodic character snapshot (every 60 seconds) + snapshot_task = asyncio.create_task( + _snapshot_loop(async_session_factory, client, character_service) + ) + + # --- Automation engine --- + + # Initialize pathfinder and load maps (runs in a background task so it + # does not block startup if the cache has not been populated yet) + pathfinder = Pathfinder() + pathfinder_task = asyncio.create_task( + _load_pathfinder_maps(pathfinder, cache_service) + ) + + # Create the automation manager and expose it on app.state + automation_manager = AutomationManager( + client=client, + db_factory=async_session_factory, + pathfinder=pathfinder, + event_bus=event_bus, + ) + app.state.automation_manager = automation_manager + + # --- Price capture background task --- + price_capture_task = exchange_service.start_price_capture( + db_factory=async_session_factory, + client=client, + ) + + # --- WebSocket system --- + + # Game WebSocket client (connects to the Artifacts game server) + game_ws_client = GameWebSocketClient( + token=settings.artifacts_token, + event_bus=event_bus, + ) + game_ws_task = await game_ws_client.start() + app.state.game_ws_client = game_ws_client + + # Event handler (processes game events from the bus) + game_event_handler = GameEventHandler(event_bus=event_bus) + event_handler_task = await game_event_handler.start() + + logger.info("Artifacts Dashboard API started") + + yield + + # --- Shutdown --- + logger.info("Shutting down background tasks") + + # Stop all running automations gracefully + await automation_manager.stop_all() + + # Stop WebSocket system + await game_event_handler.stop() + await game_ws_client.stop() + + cache_service.stop_background_refresh() + exchange_service.stop_price_capture() + snapshot_task.cancel() + pathfinder_task.cancel() + + # Wait for tasks to finish cleanly + for task in (cache_task, snapshot_task, pathfinder_task, price_capture_task, game_ws_task, event_handler_task): + try: + await task + except asyncio.CancelledError: + pass + + await client.close() + await engine.dispose() + logger.info("Artifacts Dashboard API stopped") + + +app = FastAPI( + title="Artifacts Dashboard API", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Register routers +app.include_router(characters_router) +app.include_router(game_data_router) +app.include_router(dashboard_router) +app.include_router(bank_router) +app.include_router(automations_router) +app.include_router(ws_router) +app.include_router(exchange_router) +app.include_router(events_router) +app.include_router(logs_router) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..2cdb170 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,15 @@ +from app.models.automation import AutomationConfig, AutomationLog, AutomationRun +from app.models.character_snapshot import CharacterSnapshot +from app.models.event_log import EventLog +from app.models.game_cache import GameDataCache +from app.models.price_history import PriceHistory + +__all__ = [ + "AutomationConfig", + "AutomationLog", + "AutomationRun", + "CharacterSnapshot", + "EventLog", + "GameDataCache", + "PriceHistory", +] diff --git a/backend/app/models/automation.py b/backend/app/models/automation.py new file mode 100644 index 0000000..6790511 --- /dev/null +++ b/backend/app/models/automation.py @@ -0,0 +1,108 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class AutomationConfig(Base): + __tablename__ = "automation_configs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + character_name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + strategy_type: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="Strategy type: combat, gathering, crafting, trading, task, leveling", + ) + config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + enabled: Mapped[bool] = mapped_column(default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + runs: Mapped[list["AutomationRun"]] = relationship( + back_populates="config", + cascade="all, delete-orphan", + order_by="AutomationRun.started_at.desc()", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class AutomationRun(Base): + __tablename__ = "automation_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + config_id: Mapped[int] = mapped_column( + Integer, ForeignKey("automation_configs.id", ondelete="CASCADE"), nullable=False, index=True + ) + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="running", + comment="Status: running, paused, stopped, completed, error", + ) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + stopped_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + actions_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + config: Mapped["AutomationConfig"] = relationship(back_populates="runs") + logs: Mapped[list["AutomationLog"]] = relationship( + back_populates="run", + cascade="all, delete-orphan", + order_by="AutomationLog.created_at.desc()", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class AutomationLog(Base): + __tablename__ = "automation_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + run_id: Mapped[int] = mapped_column( + Integer, ForeignKey("automation_runs.id", ondelete="CASCADE"), nullable=False, index=True + ) + action_type: Mapped[str] = mapped_column(String(50), nullable=False) + details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + success: Mapped[bool] = mapped_column(default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + run: Mapped["AutomationRun"] = relationship(back_populates="logs") + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/app/models/character_snapshot.py b/backend/app/models/character_snapshot.py new file mode 100644 index 0000000..da1b28c --- /dev/null +++ b/backend/app/models/character_snapshot.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class CharacterSnapshot(Base): + __tablename__ = "character_snapshots" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + data: Mapped[dict] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/event_log.py b/backend/app/models/event_log.py new file mode 100644 index 0000000..823eac5 --- /dev/null +++ b/backend/app/models/event_log.py @@ -0,0 +1,54 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class EventLog(Base): + """Logged game events for historical tracking and analytics.""" + + __tablename__ = "event_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + event_type: Mapped[str] = mapped_column( + String(100), + nullable=False, + index=True, + comment="Type of event (e.g. 'combat', 'gathering', 'trade', 'level_up')", + ) + event_data: Mapped[dict] = mapped_column( + JSON, + nullable=False, + default=dict, + comment="Arbitrary JSON payload with event details", + ) + character_name: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + index=True, + comment="Character associated with the event (if applicable)", + ) + map_x: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + comment="X coordinate where the event occurred", + ) + map_y: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + comment="Y coordinate where the event occurred", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + index=True, + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/app/models/game_cache.py b/backend/app/models/game_cache.py new file mode 100644 index 0000000..4b5a85e --- /dev/null +++ b/backend/app/models/game_cache.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, JSON, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class GameDataCache(Base): + __tablename__ = "game_data_cache" + __table_args__ = ( + UniqueConstraint("data_type", name="uq_game_data_cache_data_type"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + data_type: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment="Type of cached data: items, monsters, resources, maps, events, " + "achievements, npcs, tasks, effects, badges", + ) + data: Mapped[dict] = mapped_column(JSON, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/price_history.py b/backend/app/models/price_history.py new file mode 100644 index 0000000..d3b1013 --- /dev/null +++ b/backend/app/models/price_history.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Float, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class PriceHistory(Base): + """Captured Grand Exchange price snapshots over time.""" + + __tablename__ = "price_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + item_code: Mapped[str] = mapped_column( + String(100), + nullable=False, + index=True, + comment="Item code from the Artifacts API", + ) + buy_price: Mapped[float | None] = mapped_column( + Float, + nullable=True, + comment="Best buy price at capture time", + ) + sell_price: Mapped[float | None] = mapped_column( + Float, + nullable=True, + comment="Best sell price at capture time", + ) + volume: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="Trade volume at capture time", + ) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + index=True, + comment="Timestamp when the price was captured", + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/automation.py b/backend/app/schemas/automation.py new file mode 100644 index 0000000..15b2f0a --- /dev/null +++ b/backend/app/schemas/automation.py @@ -0,0 +1,214 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Strategy-specific config schemas +# --------------------------------------------------------------------------- + + +class CombatConfig(BaseModel): + """Configuration for the combat automation strategy.""" + + monster_code: str = Field(..., description="Code of the monster to fight") + auto_heal_threshold: int = Field( + default=50, + ge=0, + le=100, + description="Heal when HP drops below this percentage", + ) + heal_method: str = Field( + default="rest", + description="Healing method: 'rest' or 'consumable'", + ) + consumable_code: str | None = Field( + default=None, + description="Item code of the consumable to use for healing (required when heal_method='consumable')", + ) + min_inventory_slots: int = Field( + default=3, + ge=0, + description="Deposit loot when free inventory slots drops to this number", + ) + deposit_loot: bool = Field( + default=True, + description="Whether to automatically deposit loot at the bank", + ) + + +class GatheringConfig(BaseModel): + """Configuration for the gathering automation strategy.""" + + resource_code: str = Field(..., description="Code of the resource to gather") + deposit_on_full: bool = Field( + default=True, + description="Whether to deposit items at bank when inventory is full", + ) + max_loops: int = Field( + default=0, + ge=0, + description="Maximum gather-deposit cycles (0 = infinite)", + ) + + +class CraftingConfig(BaseModel): + """Configuration for the crafting automation strategy.""" + + item_code: str = Field(..., description="Code of the item to craft") + quantity: int = Field( + default=1, + ge=1, + description="How many items to craft in total", + ) + gather_materials: bool = Field( + default=False, + description="If True, automatically gather missing materials", + ) + recycle_excess: bool = Field( + default=False, + description="If True, recycle crafted items for XP grinding", + ) + + +class TradingConfig(BaseModel): + """Configuration for the trading (Grand Exchange) automation strategy.""" + + mode: str = Field( + default="sell_loot", + description="Trading mode: 'sell_loot', 'buy_materials', or 'flip'", + ) + item_code: str = Field(..., description="Code of the item to trade") + quantity: int = Field( + default=1, + ge=1, + description="Quantity to trade", + ) + min_price: int = Field( + default=0, + ge=0, + description="Minimum acceptable price (for selling)", + ) + max_price: int = Field( + default=0, + ge=0, + description="Maximum acceptable price (for buying, 0 = no limit)", + ) + + +class TaskConfig(BaseModel): + """Configuration for the task automation strategy.""" + + max_tasks: int = Field( + default=0, + ge=0, + description="Maximum tasks to complete (0 = infinite)", + ) + auto_exchange: bool = Field( + default=True, + description="Automatically exchange task coins for rewards", + ) + task_type: str = Field( + default="", + description="Preferred task type filter (empty = accept any)", + ) + + +class LevelingConfig(BaseModel): + """Configuration for the leveling automation strategy.""" + + target_skill: str = Field( + default="", + description="Specific skill to level (empty = auto-pick lowest skill)", + ) + min_level: int = Field( + default=0, + ge=0, + description="Minimum level threshold", + ) + max_level: int = Field( + default=0, + ge=0, + description="Stop when skill reaches this level (0 = no limit)", + ) + + +# --------------------------------------------------------------------------- +# Request schemas +# --------------------------------------------------------------------------- + + +class AutomationConfigCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + character_name: str = Field(..., min_length=1, max_length=100) + strategy_type: str = Field( + ..., + description="Strategy type: combat, gathering, crafting, trading, task, leveling", + ) + config: dict = Field(default_factory=dict) + + +class AutomationConfigUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + config: dict | None = None + enabled: bool | None = None + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + + +class AutomationConfigResponse(BaseModel): + id: int + name: str + character_name: str + strategy_type: str + config: dict + enabled: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class AutomationRunResponse(BaseModel): + id: int + config_id: int + status: str + started_at: datetime + stopped_at: datetime | None = None + actions_count: int + error_message: str | None = None + + model_config = {"from_attributes": True} + + +class AutomationLogResponse(BaseModel): + id: int + run_id: int + action_type: str + details: dict + success: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class AutomationStatusResponse(BaseModel): + config_id: int + character_name: str + strategy_type: str + status: str + run_id: int | None = None + actions_count: int = 0 + latest_logs: list[AutomationLogResponse] = Field(default_factory=list) + + model_config = {"from_attributes": True} + + +class AutomationConfigDetailResponse(BaseModel): + """Config with its run history.""" + + config: AutomationConfigResponse + runs: list[AutomationRunResponse] = Field(default_factory=list) diff --git a/backend/app/schemas/exchange.py b/backend/app/schemas/exchange.py new file mode 100644 index 0000000..d4e61b9 --- /dev/null +++ b/backend/app/schemas/exchange.py @@ -0,0 +1,51 @@ +"""Pydantic schemas for Grand Exchange responses.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class OrderResponse(BaseModel): + """A single GE order.""" + + id: str = "" + code: str = "" + quantity: int = 0 + price: int = 0 + order: str = Field(default="", description="Order type: 'buy' or 'sell'") + created_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class PriceHistoryResponse(BaseModel): + """A single price history entry.""" + + id: int + item_code: str + buy_price: float | None = None + sell_price: float | None = None + volume: int = 0 + captured_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class PriceHistoryListResponse(BaseModel): + """Response for price history queries.""" + + item_code: str + days: int + entries: list[PriceHistoryResponse] = Field(default_factory=list) + + +class ExchangeOrdersResponse(BaseModel): + """Response wrapping a list of GE orders.""" + + orders: list[dict] = Field(default_factory=list) + + +class ExchangeHistoryResponse(BaseModel): + """Response wrapping GE transaction history.""" + + history: list[dict] = Field(default_factory=list) diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py new file mode 100644 index 0000000..2163552 --- /dev/null +++ b/backend/app/schemas/game.py @@ -0,0 +1,205 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +# --- Inventory --- + + +class InventorySlot(BaseModel): + slot: int + code: str + quantity: int + + +# --- Crafting --- + + +class CraftItem(BaseModel): + code: str + quantity: int + + +class CraftSchema(BaseModel): + skill: str | None = None + level: int | None = None + items: list[CraftItem] = Field(default_factory=list) + quantity: int | None = None + + +# --- Effects --- + + +class EffectSchema(BaseModel): + name: str = "" + value: int = 0 + + +# --- Items --- + + +class ItemSchema(BaseModel): + name: str + code: str + level: int = 0 + type: str = "" + subtype: str = "" + description: str = "" + effects: list[EffectSchema] = Field(default_factory=list) + craft: CraftSchema | None = None + + +# --- Drops --- + + +class DropSchema(BaseModel): + code: str + rate: int = 0 + min_quantity: int = 0 + max_quantity: int = 0 + + +# --- Monsters --- + + +class MonsterSchema(BaseModel): + name: str + code: str + level: int = 0 + hp: int = 0 + attack_fire: int = 0 + attack_earth: int = 0 + attack_water: int = 0 + attack_air: int = 0 + res_fire: int = 0 + res_earth: int = 0 + res_water: int = 0 + res_air: int = 0 + min_gold: int = 0 + max_gold: int = 0 + drops: list[DropSchema] = Field(default_factory=list) + + +# --- Resources --- + + +class ResourceSchema(BaseModel): + name: str + code: str + skill: str = "" + level: int = 0 + drops: list[DropSchema] = Field(default_factory=list) + + +# --- Maps --- + + +class ContentSchema(BaseModel): + type: str + code: str + + +class MapSchema(BaseModel): + name: str = "" + x: int + y: int + content: ContentSchema | None = None + + +# --- Characters --- + + +class CharacterSchema(BaseModel): + name: str + account: str = "" + skin: str = "" + level: int = 0 + xp: int = 0 + max_xp: int = 0 + gold: int = 0 + speed: int = 0 + hp: int = 0 + max_hp: int = 0 + haste: int = 0 + critical_strike: int = 0 + stamina: int = 0 + + # Attack stats + attack_fire: int = 0 + attack_earth: int = 0 + attack_water: int = 0 + attack_air: int = 0 + + # Damage stats + dmg_fire: int = 0 + dmg_earth: int = 0 + dmg_water: int = 0 + dmg_air: int = 0 + + # Resistance stats + res_fire: int = 0 + res_earth: int = 0 + res_water: int = 0 + res_air: int = 0 + + # Position + x: int = 0 + y: int = 0 + + # Cooldown + cooldown: int = 0 + cooldown_expiration: datetime | None = None + + # Equipment slots + weapon_slot: str = "" + shield_slot: str = "" + helmet_slot: str = "" + body_armor_slot: str = "" + leg_armor_slot: str = "" + boots_slot: str = "" + ring1_slot: str = "" + ring2_slot: str = "" + amulet_slot: str = "" + artifact1_slot: str = "" + artifact2_slot: str = "" + artifact3_slot: str = "" + utility1_slot: str = "" + utility1_slot_quantity: int = 0 + utility2_slot: str = "" + utility2_slot_quantity: int = 0 + + # Inventory + inventory_max_items: int = 0 + inventory: list[InventorySlot] = Field(default_factory=list) + + # Task + task: str = "" + task_type: str = "" + task_progress: int = 0 + task_total: int = 0 + + # Skill levels and XP + mining_level: int = 0 + mining_xp: int = 0 + woodcutting_level: int = 0 + woodcutting_xp: int = 0 + fishing_level: int = 0 + fishing_xp: int = 0 + weaponcrafting_level: int = 0 + weaponcrafting_xp: int = 0 + gearcrafting_level: int = 0 + gearcrafting_xp: int = 0 + jewelrycrafting_level: int = 0 + jewelrycrafting_xp: int = 0 + cooking_level: int = 0 + cooking_xp: int = 0 + alchemy_level: int = 0 + alchemy_xp: int = 0 + + +# --- Dashboard --- + + +class DashboardData(BaseModel): + characters: list[CharacterSchema] = Field(default_factory=list) + server_status: dict | None = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py new file mode 100644 index 0000000..30180e3 --- /dev/null +++ b/backend/app/services/analytics_service.py @@ -0,0 +1,234 @@ +"""Analytics service for XP history, gold tracking, and action rate calculations.""" + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.character_snapshot import CharacterSnapshot + +logger = logging.getLogger(__name__) + + +class AnalyticsService: + """Provides analytics derived from character snapshot time-series data.""" + + async def get_xp_history( + self, + db: AsyncSession, + character_name: str, + hours: int = 24, + ) -> list[dict[str, Any]]: + """Get XP snapshots over time for a character. + + Parameters + ---------- + db: + Database session. + character_name: + Name of the character. + hours: + How many hours of history to return. + + Returns + ------- + List of dicts with timestamp and XP values for each skill. + """ + since = datetime.now(timezone.utc) - timedelta(hours=hours) + + stmt = ( + select(CharacterSnapshot) + .where( + CharacterSnapshot.name == character_name, + CharacterSnapshot.created_at >= since, + ) + .order_by(CharacterSnapshot.created_at.asc()) + ) + result = await db.execute(stmt) + snapshots = result.scalars().all() + + history: list[dict[str, Any]] = [] + for snap in snapshots: + data = snap.data or {} + entry: dict[str, Any] = { + "timestamp": snap.created_at.isoformat() if snap.created_at else None, + "level": data.get("level", 0), + "xp": data.get("xp", 0), + "max_xp": data.get("max_xp", 0), + "skills": {}, + } + + # Extract all skill XP values + for skill in ( + "mining", + "woodcutting", + "fishing", + "weaponcrafting", + "gearcrafting", + "jewelrycrafting", + "cooking", + "alchemy", + ): + entry["skills"][skill] = { + "level": data.get(f"{skill}_level", 0), + "xp": data.get(f"{skill}_xp", 0), + } + + history.append(entry) + + return history + + async def get_gold_history( + self, + db: AsyncSession, + character_name: str, + hours: int = 24, + ) -> list[dict[str, Any]]: + """Get gold snapshots over time for a character. + + Parameters + ---------- + db: + Database session. + character_name: + Name of the character. + hours: + How many hours of history to return. + + Returns + ------- + List of dicts with timestamp and gold amount. + """ + since = datetime.now(timezone.utc) - timedelta(hours=hours) + + stmt = ( + select(CharacterSnapshot) + .where( + CharacterSnapshot.name == character_name, + CharacterSnapshot.created_at >= since, + ) + .order_by(CharacterSnapshot.created_at.asc()) + ) + result = await db.execute(stmt) + snapshots = result.scalars().all() + + return [ + { + "timestamp": snap.created_at.isoformat() if snap.created_at else None, + "gold": (snap.data or {}).get("gold", 0), + } + for snap in snapshots + ] + + async def get_actions_per_hour( + self, + db: AsyncSession, + character_name: str, + ) -> dict[str, Any]: + """Calculate the action rate for a character based on recent snapshots. + + Uses the difference between the latest and earliest snapshot in the + last hour to estimate actions per hour (approximated by XP changes). + + Returns + ------- + Dict with "character_name", "period_hours", "xp_gained", "estimated_actions_per_hour". + """ + now = datetime.now(timezone.utc) + one_hour_ago = now - timedelta(hours=1) + + # Get earliest snapshot in the window + stmt_earliest = ( + select(CharacterSnapshot) + .where( + CharacterSnapshot.name == character_name, + CharacterSnapshot.created_at >= one_hour_ago, + ) + .order_by(CharacterSnapshot.created_at.asc()) + .limit(1) + ) + result = await db.execute(stmt_earliest) + earliest = result.scalar_one_or_none() + + # Get latest snapshot + stmt_latest = ( + select(CharacterSnapshot) + .where( + CharacterSnapshot.name == character_name, + CharacterSnapshot.created_at >= one_hour_ago, + ) + .order_by(CharacterSnapshot.created_at.desc()) + .limit(1) + ) + result = await db.execute(stmt_latest) + latest = result.scalar_one_or_none() + + if earliest is None or latest is None or earliest.id == latest.id: + return { + "character_name": character_name, + "period_hours": 1, + "xp_gained": 0, + "gold_gained": 0, + "estimated_actions_per_hour": 0, + } + + earliest_data = earliest.data or {} + latest_data = latest.data or {} + + # Calculate total XP gained across all skills + total_xp_gained = 0 + for skill in ( + "mining", + "woodcutting", + "fishing", + "weaponcrafting", + "gearcrafting", + "jewelrycrafting", + "cooking", + "alchemy", + ): + xp_key = f"{skill}_xp" + early_xp = earliest_data.get(xp_key, 0) + late_xp = latest_data.get(xp_key, 0) + total_xp_gained += max(0, late_xp - early_xp) + + # Also add combat XP + total_xp_gained += max( + 0, + latest_data.get("xp", 0) - earliest_data.get("xp", 0), + ) + + gold_gained = max( + 0, + latest_data.get("gold", 0) - earliest_data.get("gold", 0), + ) + + # Estimate time span + if earliest.created_at and latest.created_at: + time_span = (latest.created_at - earliest.created_at).total_seconds() + hours = max(time_span / 3600, 0.01) # Avoid division by zero + else: + hours = 1.0 + + # Count snapshots as a proxy for activity periods + count_stmt = ( + select(func.count()) + .select_from(CharacterSnapshot) + .where( + CharacterSnapshot.name == character_name, + CharacterSnapshot.created_at >= one_hour_ago, + ) + ) + count_result = await db.execute(count_stmt) + snapshot_count = count_result.scalar() or 0 + + return { + "character_name": character_name, + "period_hours": round(hours, 2), + "xp_gained": total_xp_gained, + "gold_gained": gold_gained, + "snapshot_count": snapshot_count, + "estimated_actions_per_hour": round(total_xp_gained / hours, 1) if hours > 0 else 0, + } diff --git a/backend/app/services/artifacts_client.py b/backend/app/services/artifacts_client.py new file mode 100644 index 0000000..f97a5cd --- /dev/null +++ b/backend/app/services/artifacts_client.py @@ -0,0 +1,465 @@ +import asyncio +import logging +import time +from collections import deque +from typing import Any + +import httpx + +from app.config import settings +from app.schemas.game import ( + CharacterSchema, + ItemSchema, + MapSchema, + MonsterSchema, + ResourceSchema, +) + +logger = logging.getLogger(__name__) + + +class RateLimiter: + """Token-bucket style rate limiter using a sliding window of timestamps.""" + + def __init__(self, max_requests: int, window_seconds: float) -> None: + self._max_requests = max_requests + self._window = window_seconds + self._timestamps: deque[float] = deque() + self._lock = asyncio.Lock() + + async def acquire(self) -> None: + async with self._lock: + now = time.monotonic() + + # Evict timestamps outside the current window + while self._timestamps and self._timestamps[0] <= now - self._window: + self._timestamps.popleft() + + if len(self._timestamps) >= self._max_requests: + # Wait until the oldest timestamp exits the window + sleep_duration = self._window - (now - self._timestamps[0]) + if sleep_duration > 0: + await asyncio.sleep(sleep_duration) + + # Re-evict after sleeping + now = time.monotonic() + while self._timestamps and self._timestamps[0] <= now - self._window: + self._timestamps.popleft() + + self._timestamps.append(time.monotonic()) + + +class ArtifactsClient: + """Async HTTP client for the Artifacts MMO API. + + Handles authentication, rate limiting, pagination, and retry logic. + """ + + MAX_RETRIES: int = 3 + RETRY_BASE_DELAY: float = 1.0 + + def __init__(self) -> None: + self._client = httpx.AsyncClient( + base_url=settings.artifacts_api_url, + headers={ + "Authorization": f"Bearer {settings.artifacts_token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=httpx.Timeout(30.0, connect=10.0), + ) + self._action_limiter = RateLimiter( + max_requests=settings.action_rate_limit, + window_seconds=settings.action_rate_window, + ) + self._data_limiter = RateLimiter( + max_requests=settings.data_rate_limit, + window_seconds=settings.data_rate_window, + ) + + # ------------------------------------------------------------------ + # Low-level request helpers + # ------------------------------------------------------------------ + + async def _request( + self, + method: str, + path: str, + *, + limiter: RateLimiter, + json_body: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + last_exc: Exception | None = None + + for attempt in range(1, self.MAX_RETRIES + 1): + await limiter.acquire() + try: + response = await self._client.request( + method, + path, + json=json_body, + params=params, + ) + + if response.status_code == 429: + retry_after = float(response.headers.get("Retry-After", "2")) + logger.warning( + "Rate limited on %s %s, retrying after %.1fs", + method, + path, + retry_after, + ) + await asyncio.sleep(retry_after) + continue + + if response.status_code >= 500: + logger.warning( + "Server error %d on %s %s (attempt %d/%d)", + response.status_code, + method, + path, + attempt, + self.MAX_RETRIES, + ) + if attempt < self.MAX_RETRIES: + delay = self.RETRY_BASE_DELAY * (2 ** (attempt - 1)) + await asyncio.sleep(delay) + continue + response.raise_for_status() + + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError: + raise + except (httpx.TransportError, httpx.TimeoutException) as exc: + last_exc = exc + logger.warning( + "Network error on %s %s (attempt %d/%d): %s", + method, + path, + attempt, + self.MAX_RETRIES, + exc, + ) + if attempt < self.MAX_RETRIES: + delay = self.RETRY_BASE_DELAY * (2 ** (attempt - 1)) + await asyncio.sleep(delay) + continue + + raise last_exc or RuntimeError(f"Request failed after {self.MAX_RETRIES} retries") + + async def _get( + self, + path: str, + *, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return await self._request( + "GET", + path, + limiter=self._data_limiter, + params=params, + ) + + async def _post_action( + self, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return await self._request( + "POST", + path, + limiter=self._action_limiter, + json_body=json_body, + ) + + async def _get_paginated( + self, + path: str, + page_size: int = 100, + ) -> list[dict[str, Any]]: + """Fetch all pages from a paginated endpoint.""" + all_items: list[dict[str, Any]] = [] + page = 1 + + while True: + result = await self._get(path, params={"page": page, "size": page_size}) + data = result.get("data", []) + all_items.extend(data) + + total_pages = result.get("pages", 1) + if page >= total_pages: + break + page += 1 + + return all_items + + # ------------------------------------------------------------------ + # Data endpoints - Characters + # ------------------------------------------------------------------ + + async def get_characters(self) -> list[CharacterSchema]: + result = await self._get("/my/characters") + data = result.get("data", []) + return [CharacterSchema.model_validate(c) for c in data] + + async def get_character(self, name: str) -> CharacterSchema: + result = await self._get(f"/characters/{name}") + return CharacterSchema.model_validate(result["data"]) + + # ------------------------------------------------------------------ + # Data endpoints - Items + # ------------------------------------------------------------------ + + async def get_items(self, page: int = 1, size: int = 100) -> list[ItemSchema]: + result = await self._get("/items", params={"page": page, "size": size}) + return [ItemSchema.model_validate(i) for i in result.get("data", [])] + + async def get_all_items(self) -> list[ItemSchema]: + raw = await self._get_paginated("/items") + return [ItemSchema.model_validate(i) for i in raw] + + # ------------------------------------------------------------------ + # Data endpoints - Monsters + # ------------------------------------------------------------------ + + async def get_monsters(self, page: int = 1, size: int = 100) -> list[MonsterSchema]: + result = await self._get("/monsters", params={"page": page, "size": size}) + return [MonsterSchema.model_validate(m) for m in result.get("data", [])] + + async def get_all_monsters(self) -> list[MonsterSchema]: + raw = await self._get_paginated("/monsters") + return [MonsterSchema.model_validate(m) for m in raw] + + # ------------------------------------------------------------------ + # Data endpoints - Resources + # ------------------------------------------------------------------ + + async def get_resources(self, page: int = 1, size: int = 100) -> list[ResourceSchema]: + result = await self._get("/resources", params={"page": page, "size": size}) + return [ResourceSchema.model_validate(r) for r in result.get("data", [])] + + async def get_all_resources(self) -> list[ResourceSchema]: + raw = await self._get_paginated("/resources") + return [ResourceSchema.model_validate(r) for r in raw] + + # ------------------------------------------------------------------ + # Data endpoints - Maps + # ------------------------------------------------------------------ + + async def get_maps( + self, + page: int = 1, + size: int = 100, + content_type: str | None = None, + content_code: str | None = None, + ) -> list[MapSchema]: + params: dict[str, Any] = {"page": page, "size": size} + if content_type is not None: + params["content_type"] = content_type + if content_code is not None: + params["content_code"] = content_code + result = await self._get("/maps", params=params) + return [MapSchema.model_validate(m) for m in result.get("data", [])] + + async def get_all_maps(self) -> list[MapSchema]: + raw = await self._get_paginated("/maps") + return [MapSchema.model_validate(m) for m in raw] + + # ------------------------------------------------------------------ + # Data endpoints - Events, Bank, GE + # ------------------------------------------------------------------ + + async def get_events(self) -> list[dict[str, Any]]: + result = await self._get("/events") + return result.get("data", []) + + async def get_bank_items(self, page: int = 1, size: int = 100) -> list[dict[str, Any]]: + result = await self._get("/my/bank/items", params={"page": page, "size": size}) + return result.get("data", []) + + async def get_all_bank_items(self) -> list[dict[str, Any]]: + return await self._get_paginated("/my/bank/items") + + async def get_bank_details(self) -> dict[str, Any]: + result = await self._get("/my/bank") + return result.get("data", {}) + + async def get_ge_orders(self) -> list[dict[str, Any]]: + result = await self._get("/my/grandexchange/orders") + return result.get("data", []) + + async def get_ge_history(self) -> list[dict[str, Any]]: + result = await self._get("/my/grandexchange/history") + return result.get("data", []) + + # ------------------------------------------------------------------ + # Action endpoints + # ------------------------------------------------------------------ + + async def move(self, name: str, x: int, y: int) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/move", + json_body={"x": x, "y": y}, + ) + return result.get("data", {}) + + async def fight(self, name: str) -> dict[str, Any]: + result = await self._post_action(f"/my/{name}/action/fight") + return result.get("data", {}) + + async def gather(self, name: str) -> dict[str, Any]: + result = await self._post_action(f"/my/{name}/action/gathering") + return result.get("data", {}) + + async def rest(self, name: str) -> dict[str, Any]: + result = await self._post_action(f"/my/{name}/action/rest") + return result.get("data", {}) + + async def equip( + self, name: str, code: str, slot: str, quantity: int = 1 + ) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/equip", + json_body={"code": code, "slot": slot, "quantity": quantity}, + ) + return result.get("data", {}) + + async def unequip(self, name: str, slot: str, quantity: int = 1) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/unequip", + json_body={"slot": slot, "quantity": quantity}, + ) + return result.get("data", {}) + + async def use_item(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/use", + json_body={"code": code, "quantity": quantity}, + ) + return result.get("data", {}) + + async def deposit_item(self, name: str, code: str, quantity: int) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/bank/deposit", + json_body={"code": code, "quantity": quantity}, + ) + return result.get("data", {}) + + async def withdraw_item(self, name: str, code: str, quantity: int) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/bank/withdraw", + json_body={"code": code, "quantity": quantity}, + ) + return result.get("data", {}) + + async def deposit_gold(self, name: str, quantity: int) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/bank/deposit/gold", + json_body={"quantity": quantity}, + ) + return result.get("data", {}) + + async def withdraw_gold(self, name: str, quantity: int) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/bank/withdraw/gold", + json_body={"quantity": quantity}, + ) + return result.get("data", {}) + + async def craft(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/crafting", + json_body={"code": code, "quantity": quantity}, + ) + return result.get("data", {}) + + async def recycle(self, name: str, code: str, quantity: int = 1) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/recycling", + json_body={"code": code, "quantity": quantity}, + ) + return result.get("data", {}) + + async def ge_buy( + self, name: str, code: str, quantity: int, price: int + ) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/grandexchange/buy", + json_body={"code": code, "quantity": quantity, "price": price}, + ) + return result.get("data", {}) + + async def ge_sell_order( + self, name: str, code: str, quantity: int, price: int + ) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/grandexchange/sell", + json_body={"code": code, "quantity": quantity, "price": price}, + ) + return result.get("data", {}) + + async def ge_buy_order( + self, name: str, code: str, quantity: int, price: int + ) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/grandexchange/buy", + json_body={"code": code, "quantity": quantity, "price": price}, + ) + return result.get("data", {}) + + async def ge_cancel(self, name: str, order_id: str) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/grandexchange/cancel", + json_body={"id": order_id}, + ) + return result.get("data", {}) + + async def task_new(self, name: str) -> dict[str, Any]: + result = await self._post_action(f"/my/{name}/action/task/new") + return result.get("data", {}) + + async def task_trade( + self, name: str, code: str, quantity: int + ) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/task/trade", + json_body={"code": code, "quantity": quantity}, + ) + return result.get("data", {}) + + async def task_complete(self, name: str) -> dict[str, Any]: + result = await self._post_action(f"/my/{name}/action/task/complete") + return result.get("data", {}) + + async def task_exchange(self, name: str) -> dict[str, Any]: + result = await self._post_action(f"/my/{name}/action/task/exchange") + return result.get("data", {}) + + async def task_cancel(self, name: str) -> dict[str, Any]: + result = await self._post_action(f"/my/{name}/action/task/cancel") + return result.get("data", {}) + + async def npc_buy(self, name: str, code: str, quantity: int) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/npc/buy", + json_body={"code": code, "quantity": quantity}, + ) + return result.get("data", {}) + + async def npc_sell(self, name: str, code: str, quantity: int) -> dict[str, Any]: + result = await self._post_action( + f"/my/{name}/action/npc/sell", + json_body={"code": code, "quantity": quantity}, + ) + return result.get("data", {}) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def close(self) -> None: + await self._client.aclose() diff --git a/backend/app/services/bank_service.py b/backend/app/services/bank_service.py new file mode 100644 index 0000000..d573aac --- /dev/null +++ b/backend/app/services/bank_service.py @@ -0,0 +1,102 @@ +"""Bank service providing enriched bank data with item details.""" + +import logging +from typing import Any + +from app.schemas.game import ItemSchema +from app.services.artifacts_client import ArtifactsClient + +logger = logging.getLogger(__name__) + + +class BankService: + """High-level service for bank operations with enriched data.""" + + async def get_contents( + self, + client: ArtifactsClient, + items_cache: list[ItemSchema] | None = None, + ) -> dict[str, Any]: + """Return bank contents enriched with item details from the cache. + + Parameters + ---------- + client: + The artifacts API client. + items_cache: + Optional list of all items from the game data cache. + If provided, bank items are enriched with name, type, level, etc. + + Returns + ------- + Dict with "details" (bank metadata) and "items" (enriched item list). + """ + details = await client.get_bank_details() + raw_items = await client.get_all_bank_items() + + # Build item lookup if cache is provided + item_lookup: dict[str, ItemSchema] = {} + if items_cache: + item_lookup = {item.code: item for item in items_cache} + + enriched_items: list[dict[str, Any]] = [] + for bank_item in raw_items: + code = bank_item.get("code", "") + quantity = bank_item.get("quantity", 0) + + enriched: dict[str, Any] = { + "code": code, + "quantity": quantity, + } + + # Enrich with item details if available + item_data = item_lookup.get(code) + if item_data is not None: + enriched["name"] = item_data.name + enriched["type"] = item_data.type + enriched["subtype"] = item_data.subtype + enriched["level"] = item_data.level + enriched["description"] = item_data.description + enriched["effects"] = [ + {"name": e.name, "value": e.value} + for e in item_data.effects + ] + else: + enriched["name"] = code + enriched["type"] = "" + enriched["subtype"] = "" + enriched["level"] = 0 + enriched["description"] = "" + enriched["effects"] = [] + + enriched_items.append(enriched) + + return { + "details": details, + "items": enriched_items, + } + + async def get_summary( + self, + client: ArtifactsClient, + ) -> dict[str, Any]: + """Return a summary of bank contents: gold, item count, total slots. + + Returns + ------- + Dict with "gold", "item_count", "used_slots", and "total_slots". + """ + details = await client.get_bank_details() + raw_items = await client.get_all_bank_items() + + gold = details.get("gold", 0) + total_slots = details.get("slots", 0) + used_slots = len(raw_items) + item_count = sum(item.get("quantity", 0) for item in raw_items) + + return { + "gold": gold, + "item_count": item_count, + "used_slots": used_slots, + "total_slots": total_slots, + } diff --git a/backend/app/services/character_service.py b/backend/app/services/character_service.py new file mode 100644 index 0000000..4efbf9e --- /dev/null +++ b/backend/app/services/character_service.py @@ -0,0 +1,45 @@ +import logging + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.character_snapshot import CharacterSnapshot +from app.schemas.game import CharacterSchema +from app.services.artifacts_client import ArtifactsClient + +logger = logging.getLogger(__name__) + + +class CharacterService: + """High-level service for character data and snapshot management.""" + + async def get_all(self, client: ArtifactsClient) -> list[CharacterSchema]: + """Return all characters belonging to the authenticated account.""" + return await client.get_characters() + + async def get_one(self, client: ArtifactsClient, name: str) -> CharacterSchema: + """Return a single character by name.""" + return await client.get_character(name) + + async def take_snapshot( + self, + db: AsyncSession, + client: ArtifactsClient, + ) -> list[CharacterSnapshot]: + """Fetch current character states and persist snapshots. + + Returns the list of newly created snapshot rows. + """ + characters = await client.get_characters() + snapshots: list[CharacterSnapshot] = [] + + for char in characters: + snapshot = CharacterSnapshot( + name=char.name, + data=char.model_dump(mode="json"), + ) + db.add(snapshot) + snapshots.append(snapshot) + + await db.commit() + logger.info("Saved %d character snapshots", len(snapshots)) + return snapshots diff --git a/backend/app/services/exchange_service.py b/backend/app/services/exchange_service.py new file mode 100644 index 0000000..db0d041 --- /dev/null +++ b/backend/app/services/exchange_service.py @@ -0,0 +1,210 @@ +"""Grand Exchange service for orders, history, and price tracking.""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.models.price_history import PriceHistory +from app.services.artifacts_client import ArtifactsClient + +logger = logging.getLogger(__name__) + +# Default interval for price capture background task (5 minutes) +_PRICE_CAPTURE_INTERVAL: float = 5 * 60 + + +class ExchangeService: + """High-level service for Grand Exchange operations and price tracking.""" + + def __init__(self) -> None: + self._capture_task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ + # Order and history queries (pass-through to API with enrichment) + # ------------------------------------------------------------------ + + async def get_orders(self, client: ArtifactsClient) -> list[dict[str, Any]]: + """Get all active GE orders for the account. + + Returns + ------- + List of order dicts from the Artifacts API. + """ + return await client.get_ge_orders() + + async def get_history(self, client: ArtifactsClient) -> list[dict[str, Any]]: + """Get GE transaction history for the account. + + Returns + ------- + List of history entry dicts from the Artifacts API. + """ + return await client.get_ge_history() + + # ------------------------------------------------------------------ + # Price capture + # ------------------------------------------------------------------ + + async def capture_prices( + self, + db: AsyncSession, + client: ArtifactsClient, + ) -> int: + """Snapshot current GE prices to the price_history table. + + Captures both buy and sell orders to derive best prices. + + Returns + ------- + Number of price entries captured. + """ + try: + orders = await client.get_ge_orders() + except Exception: + logger.exception("Failed to fetch GE orders for price capture") + return 0 + + if not orders: + logger.debug("No GE orders to capture prices from") + return 0 + + # Aggregate prices by item_code + item_prices: dict[str, dict[str, Any]] = {} + for order in orders: + code = order.get("code", "") + if not code: + continue + + if code not in item_prices: + item_prices[code] = { + "buy_price": None, + "sell_price": None, + "volume": 0, + } + + price = order.get("price", 0) + quantity = order.get("quantity", 0) + order_type = order.get("order", "") # "buy" or "sell" + + item_prices[code]["volume"] += quantity + + if order_type == "buy": + current_buy = item_prices[code]["buy_price"] + if current_buy is None or price > current_buy: + item_prices[code]["buy_price"] = price + elif order_type == "sell": + current_sell = item_prices[code]["sell_price"] + if current_sell is None or price < current_sell: + item_prices[code]["sell_price"] = price + + # Insert price history records + count = 0 + for code, prices in item_prices.items(): + entry = PriceHistory( + item_code=code, + buy_price=prices["buy_price"], + sell_price=prices["sell_price"], + volume=prices["volume"], + ) + db.add(entry) + count += 1 + + await db.commit() + logger.info("Captured %d price entries from GE", count) + return count + + async def get_price_history( + self, + db: AsyncSession, + item_code: str, + days: int = 7, + ) -> list[dict[str, Any]]: + """Get price history for an item over the specified number of days. + + Parameters + ---------- + db: + Database session. + item_code: + The item code to query. + days: + How many days of history to return (default 7). + + Returns + ------- + List of price history dicts ordered by captured_at ascending. + """ + since = datetime.now(timezone.utc) - timedelta(days=days) + + stmt = ( + select(PriceHistory) + .where( + PriceHistory.item_code == item_code, + PriceHistory.captured_at >= since, + ) + .order_by(PriceHistory.captured_at.asc()) + ) + result = await db.execute(stmt) + rows = result.scalars().all() + + return [ + { + "id": row.id, + "item_code": row.item_code, + "buy_price": row.buy_price, + "sell_price": row.sell_price, + "volume": row.volume, + "captured_at": row.captured_at.isoformat() if row.captured_at else None, + } + for row in rows + ] + + # ------------------------------------------------------------------ + # Background price capture task + # ------------------------------------------------------------------ + + def start_price_capture( + self, + db_factory: async_sessionmaker[AsyncSession], + client: ArtifactsClient, + interval_seconds: float = _PRICE_CAPTURE_INTERVAL, + ) -> asyncio.Task[None]: + """Spawn a background task that captures GE prices periodically. + + Parameters + ---------- + db_factory: + Async session factory for database access. + client: + Artifacts API client. + interval_seconds: + How often to capture prices (default 5 minutes). + + Returns + ------- + The created asyncio Task. + """ + + async def _loop() -> None: + while True: + try: + async with db_factory() as db: + await self.capture_prices(db, client) + except asyncio.CancelledError: + logger.info("Price capture background task cancelled") + return + except Exception: + logger.exception("Unhandled error during price capture") + await asyncio.sleep(interval_seconds) + + self._capture_task = asyncio.create_task(_loop()) + return self._capture_task + + def stop_price_capture(self) -> None: + """Cancel the background price capture task.""" + if self._capture_task is not None and not self._capture_task.done(): + self._capture_task.cancel() diff --git a/backend/app/services/game_data_cache.py b/backend/app/services/game_data_cache.py new file mode 100644 index 0000000..d6e217d --- /dev/null +++ b/backend/app/services/game_data_cache.py @@ -0,0 +1,178 @@ +import asyncio +import logging +import time +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.game_cache import GameDataCache +from app.schemas.game import ItemSchema, MapSchema, MonsterSchema, ResourceSchema +from app.services.artifacts_client import ArtifactsClient + +logger = logging.getLogger(__name__) + +# In-memory cache TTL in seconds (30 minutes) +CACHE_TTL: float = 30 * 60 + + +class _MemoryCacheEntry: + __slots__ = ("data", "fetched_at") + + def __init__(self, data: Any, fetched_at: float) -> None: + self.data = data + self.fetched_at = fetched_at + + def is_expired(self) -> bool: + return (time.monotonic() - self.fetched_at) > CACHE_TTL + + +class GameDataCacheService: + """Manages a two-layer cache (in-memory + database) for static game data. + + The database layer acts as a persistent warm cache so that a fresh restart + does not require a full re-fetch from the Artifacts API. The in-memory + layer avoids repeated database round-trips for hot reads. + """ + + def __init__(self) -> None: + self._memory: dict[str, _MemoryCacheEntry] = {} + self._refresh_task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ + # Public helpers + # ------------------------------------------------------------------ + + async def get_items(self, db: AsyncSession) -> list[ItemSchema]: + raw = await self._get_from_cache(db, "items") + if raw is None: + return [] + return [ItemSchema.model_validate(i) for i in raw] + + async def get_monsters(self, db: AsyncSession) -> list[MonsterSchema]: + raw = await self._get_from_cache(db, "monsters") + if raw is None: + return [] + return [MonsterSchema.model_validate(m) for m in raw] + + async def get_resources(self, db: AsyncSession) -> list[ResourceSchema]: + raw = await self._get_from_cache(db, "resources") + if raw is None: + return [] + return [ResourceSchema.model_validate(r) for r in raw] + + async def get_maps(self, db: AsyncSession) -> list[MapSchema]: + raw = await self._get_from_cache(db, "maps") + if raw is None: + return [] + return [MapSchema.model_validate(m) for m in raw] + + # ------------------------------------------------------------------ + # Full refresh + # ------------------------------------------------------------------ + + async def refresh_all(self, db: AsyncSession, client: ArtifactsClient) -> None: + """Fetch all game data from the API and persist into the cache table.""" + logger.info("Starting full game-data cache refresh") + + fetchers: dict[str, Any] = { + "items": client.get_all_items, + "monsters": client.get_all_monsters, + "resources": client.get_all_resources, + "maps": client.get_all_maps, + } + + for data_type, fetcher in fetchers.items(): + try: + results = await fetcher() + serialized = [r.model_dump(mode="json") for r in results] + await self._upsert_cache(db, data_type, serialized) + self._memory[data_type] = _MemoryCacheEntry( + data=serialized, + fetched_at=time.monotonic(), + ) + logger.info( + "Cached %d entries for %s", + len(serialized), + data_type, + ) + except Exception: + logger.exception("Failed to refresh cache for %s", data_type) + + await db.commit() + logger.info("Game-data cache refresh complete") + + # ------------------------------------------------------------------ + # Background periodic refresh + # ------------------------------------------------------------------ + + def start_background_refresh( + self, + db_factory: Any, + client: ArtifactsClient, + interval_seconds: float = CACHE_TTL, + ) -> asyncio.Task[None]: + """Spawn a background task that refreshes the cache periodically.""" + + async def _loop() -> None: + while True: + try: + async with db_factory() as db: + await self.refresh_all(db, client) + except asyncio.CancelledError: + logger.info("Cache refresh background task cancelled") + return + except Exception: + logger.exception("Unhandled error during background cache refresh") + await asyncio.sleep(interval_seconds) + + self._refresh_task = asyncio.create_task(_loop()) + return self._refresh_task + + def stop_background_refresh(self) -> None: + if self._refresh_task is not None and not self._refresh_task.done(): + self._refresh_task.cancel() + + # ------------------------------------------------------------------ + # Internal cache access + # ------------------------------------------------------------------ + + async def _get_from_cache( + self, + db: AsyncSession, + data_type: str, + ) -> list[dict[str, Any]] | None: + # 1. Try in-memory cache + entry = self._memory.get(data_type) + if entry is not None and not entry.is_expired(): + return entry.data + + # 2. Fall back to database + stmt = select(GameDataCache).where(GameDataCache.data_type == data_type) + result = await db.execute(stmt) + row = result.scalar_one_or_none() + + if row is None: + return None + + # Populate in-memory cache from DB + self._memory[data_type] = _MemoryCacheEntry( + data=row.data, + fetched_at=time.monotonic(), + ) + return row.data + + async def _upsert_cache( + self, + db: AsyncSession, + data_type: str, + data: list[dict[str, Any]], + ) -> None: + stmt = select(GameDataCache).where(GameDataCache.data_type == data_type) + result = await db.execute(stmt) + existing = result.scalar_one_or_none() + + if existing is not None: + existing.data = data + else: + db.add(GameDataCache(data_type=data_type, data=data)) diff --git a/backend/app/websocket/__init__.py b/backend/app/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/websocket/client.py b/backend/app/websocket/client.py new file mode 100644 index 0000000..0e714fd --- /dev/null +++ b/backend/app/websocket/client.py @@ -0,0 +1,124 @@ +"""Persistent WebSocket client for the Artifacts MMO game server. + +Maintains a long-lived connection to ``wss://realtime.artifactsmmo.com`` +and dispatches every incoming game event to the :class:`EventBus` so that +other components (event handlers, the frontend relay) can react in real +time. + +Reconnection is handled automatically with exponential back-off. +""" + +from __future__ import annotations + +import asyncio +import json +import logging + +import websockets + +from app.websocket.event_bus import EventBus + +logger = logging.getLogger(__name__) + + +class GameWebSocketClient: + """Persistent WebSocket connection to the Artifacts game server.""" + + WS_URL = "wss://realtime.artifactsmmo.com" + + def __init__(self, token: str, event_bus: EventBus) -> None: + self._token = token + self._event_bus = event_bus + self._ws: websockets.WebSocketClientProtocol | None = None + self._task: asyncio.Task | None = None + self._reconnect_delay = 1.0 + self._max_reconnect_delay = 60.0 + self._running = False + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> asyncio.Task: + """Start the persistent WebSocket connection in a background task.""" + self._running = True + self._task = asyncio.create_task( + self._connection_loop(), + name="game-ws-client", + ) + logger.info("Game WebSocket client starting") + return self._task + + async def stop(self) -> None: + """Gracefully shut down the WebSocket connection.""" + self._running = False + if self._ws is not None: + try: + await self._ws.close() + except Exception: + pass + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("Game WebSocket client stopped") + + # ------------------------------------------------------------------ + # Connection loop + # ------------------------------------------------------------------ + + async def _connection_loop(self) -> None: + """Reconnect loop with exponential back-off.""" + while self._running: + try: + async with websockets.connect( + self.WS_URL, + additional_headers={"Authorization": f"Bearer {self._token}"}, + ) as ws: + self._ws = ws + self._reconnect_delay = 1.0 + logger.info("Game WebSocket connected") + await self._event_bus.publish("ws_status", {"connected": True}) + + async for message in ws: + try: + data = json.loads(message) + await self._handle_message(data) + except json.JSONDecodeError: + logger.warning( + "Invalid JSON from game WS: %s", message[:100] + ) + + except asyncio.CancelledError: + raise + except websockets.ConnectionClosed: + logger.warning("Game WebSocket disconnected") + except Exception: + logger.exception("Game WebSocket error") + + self._ws = None + + if self._running: + await self._event_bus.publish("ws_status", {"connected": False}) + logger.info("Reconnecting in %.1fs", self._reconnect_delay) + await asyncio.sleep(self._reconnect_delay) + self._reconnect_delay = min( + self._reconnect_delay * 2, + self._max_reconnect_delay, + ) + + # ------------------------------------------------------------------ + # Message dispatch + # ------------------------------------------------------------------ + + async def _handle_message(self, data: dict) -> None: + """Dispatch a game event to the event bus. + + Game events are published under the key ``game_{type}`` where + *type* is the value of the ``"type"`` field in the incoming + message (defaults to ``"unknown"`` if absent). + """ + event_type = data.get("type", "unknown") + await self._event_bus.publish(f"game_{event_type}", data) diff --git a/backend/app/websocket/event_bus.py b/backend/app/websocket/event_bus.py new file mode 100644 index 0000000..c20652a --- /dev/null +++ b/backend/app/websocket/event_bus.py @@ -0,0 +1,74 @@ +"""Async pub/sub event bus for internal communication. + +Provides a simple in-process publish/subscribe mechanism built on +``asyncio.Queue``. Components can subscribe to specific event types +(string keys) or use ``subscribe_all`` to receive every published event. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections import defaultdict + +logger = logging.getLogger(__name__) + + +class EventBus: + """Async pub/sub event bus for internal communication.""" + + def __init__(self) -> None: + self._subscribers: dict[str, list[asyncio.Queue]] = defaultdict(list) + + # ------------------------------------------------------------------ + # Subscribe / unsubscribe + # ------------------------------------------------------------------ + + def subscribe(self, event_type: str) -> asyncio.Queue: + """Subscribe to a specific event type. + + Returns an ``asyncio.Queue`` that will receive dicts of the form + ``{"type": event_type, "data": ...}`` whenever an event of that + type is published. + """ + queue: asyncio.Queue = asyncio.Queue() + self._subscribers[event_type].append(queue) + return queue + + def subscribe_all(self) -> asyncio.Queue: + """Subscribe to **all** events (wildcard ``*``). + + Returns a queue that receives every event regardless of type. + """ + queue: asyncio.Queue = asyncio.Queue() + self._subscribers["*"].append(queue) + return queue + + def unsubscribe(self, event_type: str, queue: asyncio.Queue) -> None: + """Remove a queue from a given event type's subscriber list.""" + if queue in self._subscribers[event_type]: + self._subscribers[event_type].remove(queue) + + # ------------------------------------------------------------------ + # Publish + # ------------------------------------------------------------------ + + async def publish(self, event_type: str, data: dict) -> None: + """Publish an event to type-specific *and* wildcard subscribers. + + Parameters + ---------- + event_type: + A string key identifying the event (e.g. ``"automation_action"``). + data: + Arbitrary dict payload delivered to subscribers. + """ + event = {"type": event_type, "data": data} + + # Deliver to type-specific subscribers + for queue in self._subscribers[event_type]: + await queue.put(event) + + # Deliver to wildcard subscribers + for queue in self._subscribers["*"]: + await queue.put(event) diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py new file mode 100644 index 0000000..c3a45d9 --- /dev/null +++ b/backend/app/websocket/handlers.py @@ -0,0 +1,80 @@ +"""Event handlers for processing game events from the WebSocket. + +The :class:`GameEventHandler` subscribes to all events on the bus and +can be extended with domain-specific logic (e.g. updating caches, +triggering automation adjustments, etc.). +""" + +from __future__ import annotations + +import asyncio +import logging + +from app.websocket.event_bus import EventBus + +logger = logging.getLogger(__name__) + + +class GameEventHandler: + """Process game events received via the EventBus.""" + + def __init__(self, event_bus: EventBus) -> None: + self._event_bus = event_bus + self._queue: asyncio.Queue | None = None + self._task: asyncio.Task | None = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> asyncio.Task: + """Subscribe to all events and start the processing loop.""" + self._queue = self._event_bus.subscribe_all() + self._task = asyncio.create_task( + self._process_loop(), + name="game-event-handler", + ) + logger.info("Game event handler started") + return self._task + + async def stop(self) -> None: + """Stop the processing loop and unsubscribe.""" + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + if self._queue is not None: + self._event_bus.unsubscribe("*", self._queue) + logger.info("Game event handler stopped") + + # ------------------------------------------------------------------ + # Processing + # ------------------------------------------------------------------ + + async def _process_loop(self) -> None: + """Read events from the queue and dispatch to handlers.""" + assert self._queue is not None + try: + while True: + event = await self._queue.get() + try: + await self._handle(event) + except Exception: + logger.exception( + "Error handling event: %s", event.get("type") + ) + except asyncio.CancelledError: + logger.debug("Event handler process loop cancelled") + + async def _handle(self, event: dict) -> None: + """Handle a single event. + + Override or extend this method to add domain-specific logic. + Currently logs notable game events for observability. + """ + event_type = event.get("type", "") + + if event_type.startswith("game_"): + logger.info("Game event: %s", event_type) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..eca0b6b --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "artifacts-dashboard-backend" +version = "0.1.0" +description = "Backend for Artifacts MMO Dashboard & Automation Platform" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "httpx>=0.28.0", + "sqlalchemy[asyncio]>=2.0.36", + "asyncpg>=0.30.0", + "alembic>=1.14.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", + "websockets>=14.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24.0", + "pytest-httpx>=0.35.0", + "ruff>=0.8.0", + "mypy>=1.13.0", + "httpx[http2]>=0.28.0", +] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.12" +strict = false +warn_return_any = true +warn_unused_configs = true diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..fd97226 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,120 @@ +"""Common fixtures for the Artifacts MMO Dashboard backend test suite.""" + +import pytest + +from app.engine.pathfinder import Pathfinder +from app.schemas.game import ( + CharacterSchema, + ContentSchema, + InventorySlot, + MapSchema, + MonsterSchema, + ResourceSchema, +) + + +@pytest.fixture +def make_character(): + """Factory fixture that returns a CharacterSchema with sensible defaults. + + Any field can be overridden via keyword arguments. + """ + + def _factory(**overrides) -> CharacterSchema: + defaults = { + "name": "TestHero", + "account": "test_account", + "level": 10, + "hp": 100, + "max_hp": 100, + "x": 0, + "y": 0, + "inventory_max_items": 20, + "inventory": [], + "mining_level": 5, + "woodcutting_level": 5, + "fishing_level": 5, + } + defaults.update(overrides) + + # Convert raw inventory dicts to InventorySlot instances if needed + raw_inv = defaults.get("inventory", []) + if raw_inv and isinstance(raw_inv[0], dict): + defaults["inventory"] = [InventorySlot(**slot) for slot in raw_inv] + + return CharacterSchema(**defaults) + + return _factory + + +@pytest.fixture +def make_monster(): + """Factory fixture that returns a MonsterSchema with sensible defaults.""" + + def _factory(**overrides) -> MonsterSchema: + defaults = { + "name": "Chicken", + "code": "chicken", + "level": 1, + "hp": 50, + } + defaults.update(overrides) + return MonsterSchema(**defaults) + + return _factory + + +@pytest.fixture +def make_resource(): + """Factory fixture that returns a ResourceSchema with sensible defaults.""" + + def _factory(**overrides) -> ResourceSchema: + defaults = { + "name": "Copper Rocks", + "code": "copper_rocks", + "skill": "mining", + "level": 1, + } + defaults.update(overrides) + return ResourceSchema(**defaults) + + return _factory + + +@pytest.fixture +def make_map_tile(): + """Factory fixture that returns a MapSchema tile with content.""" + + def _factory( + x: int, + y: int, + content_type: str | None = None, + content_code: str | None = None, + ) -> MapSchema: + content = None + if content_type and content_code: + content = ContentSchema(type=content_type, code=content_code) + return MapSchema(x=x, y=y, content=content) + + return _factory + + +@pytest.fixture +def pathfinder_with_maps(make_map_tile): + """Fixture that returns a Pathfinder pre-loaded with tiles. + + Usage:: + + pf = pathfinder_with_maps([ + (0, 0, "monster", "chicken"), + (5, 5, "bank", "bank"), + ]) + """ + + def _factory(tile_specs: list[tuple[int, int, str, str]]) -> Pathfinder: + tiles = [make_map_tile(x, y, ct, cc) for x, y, ct, cc in tile_specs] + pf = Pathfinder() + pf.load_maps(tiles) + return pf + + return _factory diff --git a/backend/tests/test_combat_strategy.py b/backend/tests/test_combat_strategy.py new file mode 100644 index 0000000..b5851f0 --- /dev/null +++ b/backend/tests/test_combat_strategy.py @@ -0,0 +1,267 @@ +"""Tests for the CombatStrategy state machine.""" + +import pytest + +from app.engine.strategies.base import ActionType +from app.engine.strategies.combat import CombatStrategy +from app.schemas.game import InventorySlot + + +class TestCombatStrategyMovement: + """Tests for movement-related transitions.""" + + @pytest.mark.asyncio + async def test_move_to_monster(self, make_character, pathfinder_with_maps): + """When not at monster location, the strategy should return MOVE.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy({"monster_code": "chicken"}, pf) + char = make_character(x=0, y=0) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.MOVE + assert plan.params == {"x": 5, "y": 5} + + @pytest.mark.asyncio + async def test_idle_when_no_monster_found(self, make_character, pathfinder_with_maps): + """When no matching monster tile exists, the strategy should IDLE.""" + pf = pathfinder_with_maps([ + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy({"monster_code": "dragon"}, pf) + char = make_character(x=0, y=0) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.IDLE + + +class TestCombatStrategyFighting: + """Tests for combat behavior at the monster tile.""" + + @pytest.mark.asyncio + async def test_fight_when_at_monster(self, make_character, pathfinder_with_maps): + """When at monster and healthy, the strategy should return FIGHT.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy({"monster_code": "chicken"}, pf) + char = make_character(x=5, y=5, hp=100, max_hp=100) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.FIGHT + + @pytest.mark.asyncio + async def test_fight_transitions_to_check_health(self, make_character, pathfinder_with_maps): + """After returning FIGHT, the internal state should advance to CHECK_HEALTH.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy({"monster_code": "chicken"}, pf) + char = make_character(x=5, y=5, hp=100, max_hp=100) + + await strategy.next_action(char) + + assert strategy.get_state() == "check_health" + + +class TestCombatStrategyHealing: + """Tests for healing behavior.""" + + @pytest.mark.asyncio + async def test_heal_when_low_hp(self, make_character, pathfinder_with_maps): + """When HP is below threshold, the strategy should return REST.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy( + {"monster_code": "chicken", "auto_heal_threshold": 50}, + pf, + ) + char = make_character(x=5, y=5, hp=30, max_hp=100) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.REST + + @pytest.mark.asyncio + async def test_heal_with_consumable(self, make_character, pathfinder_with_maps): + """When heal_method is consumable and character has the item, USE_ITEM is returned.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy( + { + "monster_code": "chicken", + "auto_heal_threshold": 50, + "heal_method": "consumable", + "consumable_code": "cooked_chicken", + }, + pf, + ) + char = make_character( + x=5, + y=5, + hp=30, + max_hp=100, + inventory=[InventorySlot(slot=0, code="cooked_chicken", quantity=5)], + ) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.USE_ITEM + assert plan.params["code"] == "cooked_chicken" + + @pytest.mark.asyncio + async def test_heal_consumable_fallback_to_rest(self, make_character, pathfinder_with_maps): + """When heal_method is consumable but character lacks the item, fallback to REST.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy( + { + "monster_code": "chicken", + "auto_heal_threshold": 50, + "heal_method": "consumable", + "consumable_code": "cooked_chicken", + }, + pf, + ) + char = make_character(x=5, y=5, hp=30, max_hp=100, inventory=[]) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.REST + + @pytest.mark.asyncio + async def test_no_heal_at_threshold(self, make_character, pathfinder_with_maps): + """When HP is exactly at threshold, the strategy should FIGHT (not heal).""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy( + {"monster_code": "chicken", "auto_heal_threshold": 50}, + pf, + ) + # HP at exactly 50% + char = make_character(x=5, y=5, hp=50, max_hp=100) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.FIGHT + + +class TestCombatStrategyDeposit: + """Tests for inventory deposit behavior.""" + + @pytest.mark.asyncio + async def test_deposit_when_inventory_full(self, make_character, pathfinder_with_maps): + """When inventory is nearly full, the strategy should move to bank and deposit.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy( + {"monster_code": "chicken", "min_inventory_slots": 3}, + pf, + ) + + # Fill inventory: 20 max, with 18 slots used => 2 free < 3 min + items = [InventorySlot(slot=i, code=f"loot_{i}", quantity=1) for i in range(18)] + char = make_character( + x=5, y=5, + hp=100, max_hp=100, + inventory_max_items=20, + inventory=items, + ) + + # First call: at monster, healthy, so it will FIGHT + plan1 = await strategy.next_action(char) + assert plan1.action_type == ActionType.FIGHT + + # After fight, the state goes to CHECK_HEALTH. Simulate post-fight: + # healthy + low inventory => should move to bank + plan2 = await strategy.next_action(char) + assert plan2.action_type == ActionType.MOVE + assert plan2.params == {"x": 10, "y": 0} + + @pytest.mark.asyncio + async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps): + """When at bank with items, the strategy should DEPOSIT_ITEM.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + (10, 0, "bank", "bank"), + ]) + strategy = CombatStrategy( + {"monster_code": "chicken", "min_inventory_slots": 3}, + pf, + ) + + items = [InventorySlot(slot=i, code=f"loot_{i}", quantity=1) for i in range(18)] + char = make_character( + x=5, y=5, + hp=100, max_hp=100, + inventory_max_items=20, + inventory=items, + ) + + # Fight -> check_health -> check_inventory -> move_to_bank + await strategy.next_action(char) # FIGHT + await strategy.next_action(char) # MOVE to bank + + # Now simulate being at the bank + char_at_bank = make_character( + x=10, y=0, + hp=100, max_hp=100, + inventory_max_items=20, + inventory=items, + ) + + plan = await strategy.next_action(char_at_bank) + assert plan.action_type == ActionType.DEPOSIT_ITEM + assert plan.params["code"] == "loot_0" + + @pytest.mark.asyncio + async def test_no_deposit_when_disabled(self, make_character, pathfinder_with_maps): + """When deposit_loot=False, full inventory should not trigger deposit.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + ]) + strategy = CombatStrategy( + {"monster_code": "chicken", "deposit_loot": False}, + pf, + ) + + items = [InventorySlot(slot=i, code=f"loot_{i}", quantity=1) for i in range(20)] + char = make_character( + x=5, y=5, + hp=100, max_hp=100, + inventory_max_items=20, + inventory=items, + ) + + # Should fight and then loop back to fight (no bank trip) + plan = await strategy.next_action(char) + assert plan.action_type == ActionType.FIGHT + + +class TestCombatStrategyGetState: + """Tests for get_state() reporting.""" + + def test_initial_state(self, pathfinder_with_maps): + """Initial state should be move_to_monster.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + ]) + strategy = CombatStrategy({"monster_code": "chicken"}, pf) + assert strategy.get_state() == "move_to_monster" diff --git a/backend/tests/test_cooldown.py b/backend/tests/test_cooldown.py new file mode 100644 index 0000000..d40a15a --- /dev/null +++ b/backend/tests/test_cooldown.py @@ -0,0 +1,171 @@ +"""Tests for CooldownTracker.""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from app.engine.cooldown import CooldownTracker, _BUFFER_SECONDS + + +class TestCooldownTrackerIsReady: + """Tests for CooldownTracker.is_ready().""" + + def test_no_cooldown_ready(self): + """A character with no recorded cooldown should be ready immediately.""" + tracker = CooldownTracker() + assert tracker.is_ready("Hero") is True + + def test_not_ready_during_cooldown(self): + """A character with an active cooldown should not be ready.""" + tracker = CooldownTracker() + tracker.update("Hero", cooldown_seconds=60) + assert tracker.is_ready("Hero") is False + + def test_ready_after_cooldown_expires(self): + """A character whose cooldown has passed should be ready.""" + tracker = CooldownTracker() + # Set an expiration in the past + past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat() + tracker.update("Hero", cooldown_seconds=0, cooldown_expiration=past) + assert tracker.is_ready("Hero") is True + + def test_unknown_character_is_ready(self): + """A character that was never tracked should be ready.""" + tracker = CooldownTracker() + tracker.update("Other", cooldown_seconds=60) + assert tracker.is_ready("Unknown") is True + + +class TestCooldownTrackerRemaining: + """Tests for CooldownTracker.remaining().""" + + def test_remaining_no_cooldown(self): + """Remaining should be 0 for a character with no cooldown.""" + tracker = CooldownTracker() + assert tracker.remaining("Hero") == 0.0 + + def test_remaining_active_cooldown(self): + """Remaining should be positive during an active cooldown.""" + tracker = CooldownTracker() + tracker.update("Hero", cooldown_seconds=10) + remaining = tracker.remaining("Hero") + assert remaining > 0 + assert remaining <= 10.0 + + def test_remaining_expired_cooldown(self): + """Remaining should be 0 after cooldown has expired.""" + tracker = CooldownTracker() + past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat() + tracker.update("Hero", cooldown_seconds=0, cooldown_expiration=past) + assert tracker.remaining("Hero") == 0.0 + + def test_remaining_calculation_accuracy(self): + """Remaining should approximate the actual duration set.""" + tracker = CooldownTracker() + future = datetime.now(timezone.utc) + timedelta(seconds=5) + tracker.update("Hero", cooldown_seconds=5, cooldown_expiration=future.isoformat()) + remaining = tracker.remaining("Hero") + # Should be close to 5 seconds (within 0.5s tolerance for execution time) + assert 4.0 <= remaining <= 5.5 + + +class TestCooldownTrackerUpdate: + """Tests for CooldownTracker.update().""" + + def test_update_with_expiration_string(self): + """update() should parse an ISO-8601 expiration string.""" + tracker = CooldownTracker() + future = datetime.now(timezone.utc) + timedelta(seconds=30) + tracker.update("Hero", cooldown_seconds=30, cooldown_expiration=future.isoformat()) + assert tracker.is_ready("Hero") is False + assert tracker.remaining("Hero") > 25 + + def test_update_with_seconds_fallback(self): + """update() without expiration should use cooldown_seconds as duration.""" + tracker = CooldownTracker() + tracker.update("Hero", cooldown_seconds=10) + assert tracker.is_ready("Hero") is False + + def test_update_with_invalid_expiration_falls_back(self): + """update() with an unparseable expiration should fall back to duration.""" + tracker = CooldownTracker() + tracker.update("Hero", cooldown_seconds=5, cooldown_expiration="not-a-date") + assert tracker.is_ready("Hero") is False + remaining = tracker.remaining("Hero") + assert remaining > 0 + + def test_update_naive_datetime_gets_utc(self): + """A naive datetime in expiration should be treated as UTC.""" + tracker = CooldownTracker() + future = datetime.now(timezone.utc) + timedelta(seconds=10) + # Strip timezone to create a naive ISO string + naive_str = future.replace(tzinfo=None).isoformat() + tracker.update("Hero", cooldown_seconds=10, cooldown_expiration=naive_str) + assert tracker.is_ready("Hero") is False + + +class TestCooldownTrackerMultipleCharacters: + """Tests for tracking multiple characters independently.""" + + def test_multiple_characters(self): + """Different characters should have independent cooldowns.""" + tracker = CooldownTracker() + tracker.update("Hero", cooldown_seconds=60) + # Second character has no cooldown + assert tracker.is_ready("Hero") is False + assert tracker.is_ready("Sidekick") is True + + def test_multiple_characters_different_durations(self): + """Different characters can have different cooldown durations.""" + tracker = CooldownTracker() + tracker.update("Fast", cooldown_seconds=2) + tracker.update("Slow", cooldown_seconds=120) + assert tracker.remaining("Fast") < tracker.remaining("Slow") + + def test_updating_one_does_not_affect_another(self): + """Updating one character's cooldown should not affect another.""" + tracker = CooldownTracker() + tracker.update("A", cooldown_seconds=60) + remaining_a = tracker.remaining("A") + tracker.update("B", cooldown_seconds=5) + # A's remaining should not have changed (within execution tolerance) + assert abs(tracker.remaining("A") - remaining_a) < 0.5 + + +class TestCooldownTrackerWait: + """Tests for the async CooldownTracker.wait() method.""" + + @pytest.mark.asyncio + async def test_wait_no_cooldown(self): + """wait() should return immediately when no cooldown is set.""" + tracker = CooldownTracker() + with patch("app.engine.cooldown.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await tracker.wait("Hero") + mock_sleep.assert_not_called() + + @pytest.mark.asyncio + async def test_wait_expired_cooldown(self): + """wait() should return immediately when cooldown has already expired.""" + tracker = CooldownTracker() + past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat() + tracker.update("Hero", cooldown_seconds=0, cooldown_expiration=past) + + with patch("app.engine.cooldown.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await tracker.wait("Hero") + mock_sleep.assert_not_called() + + @pytest.mark.asyncio + async def test_wait_active_cooldown_sleeps(self): + """wait() should sleep for the remaining time plus buffer.""" + tracker = CooldownTracker() + future = datetime.now(timezone.utc) + timedelta(seconds=2) + tracker.update("Hero", cooldown_seconds=2, cooldown_expiration=future.isoformat()) + + with patch("app.engine.cooldown.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await tracker.wait("Hero") + mock_sleep.assert_called_once() + sleep_duration = mock_sleep.call_args[0][0] + # Should sleep for ~2 seconds + buffer + assert sleep_duration > 0 + assert sleep_duration <= 2.0 + _BUFFER_SECONDS + 0.5 diff --git a/backend/tests/test_gathering_strategy.py b/backend/tests/test_gathering_strategy.py new file mode 100644 index 0000000..6714c5c --- /dev/null +++ b/backend/tests/test_gathering_strategy.py @@ -0,0 +1,220 @@ +"""Tests for the GatheringStrategy state machine.""" + +import pytest + +from app.engine.strategies.base import ActionType +from app.engine.strategies.gathering import GatheringStrategy +from app.schemas.game import InventorySlot + + +class TestGatheringStrategyMovement: + """Tests for movement to resource tiles.""" + + @pytest.mark.asyncio + async def test_move_to_resource(self, make_character, pathfinder_with_maps): + """When not at resource location, the strategy should return MOVE.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy({"resource_code": "copper_rocks"}, pf) + char = make_character(x=0, y=0) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.MOVE + assert plan.params == {"x": 3, "y": 4} + + @pytest.mark.asyncio + async def test_idle_when_no_resource_found(self, make_character, pathfinder_with_maps): + """When no matching resource tile exists, the strategy should IDLE.""" + pf = pathfinder_with_maps([ + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy({"resource_code": "gold_rocks"}, pf) + char = make_character(x=0, y=0) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.IDLE + + +class TestGatheringStrategyGathering: + """Tests for gathering behavior at the resource tile.""" + + @pytest.mark.asyncio + async def test_gather_when_at_resource(self, make_character, pathfinder_with_maps): + """When at resource and inventory has space, the strategy should GATHER.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy({"resource_code": "copper_rocks"}, pf) + char = make_character(x=3, y=4, inventory_max_items=20, inventory=[]) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.GATHER + + @pytest.mark.asyncio + async def test_gather_when_inventory_has_some_items(self, make_character, pathfinder_with_maps): + """Gathering should continue as long as inventory is not completely full.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy({"resource_code": "copper_rocks"}, pf) + items = [InventorySlot(slot=0, code="copper_ore", quantity=5)] + char = make_character(x=3, y=4, inventory_max_items=20, inventory=items) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.GATHER + + +class TestGatheringStrategyDeposit: + """Tests for deposit behavior when inventory is full.""" + + @pytest.mark.asyncio + async def test_deposit_when_full(self, make_character, pathfinder_with_maps): + """When inventory is full and deposit_on_full is True, move to bank.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy( + {"resource_code": "copper_rocks", "deposit_on_full": True}, + pf, + ) + items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)] + char = make_character(x=3, y=4, inventory_max_items=20, inventory=items) + + plan = await strategy.next_action(char) + + # When at resource with full inventory, the gather handler detects full inventory + # and transitions to check_inventory -> move_to_bank -> MOVE + assert plan.action_type == ActionType.MOVE + assert plan.params == {"x": 10, "y": 0} + + @pytest.mark.asyncio + async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps): + """When at bank with items, the strategy should DEPOSIT_ITEM.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy( + {"resource_code": "copper_rocks", "deposit_on_full": True}, + pf, + ) + items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)] + + # First, move to bank + char_full = make_character(x=3, y=4, inventory_max_items=20, inventory=items) + await strategy.next_action(char_full) # MOVE to bank + + # Now at bank + char_at_bank = make_character(x=10, y=0, inventory_max_items=20, inventory=items) + plan = await strategy.next_action(char_at_bank) + + assert plan.action_type == ActionType.DEPOSIT_ITEM + assert plan.params["code"] == "copper_ore" + + @pytest.mark.asyncio + async def test_complete_when_full_no_deposit(self, make_character, pathfinder_with_maps): + """When inventory is full and deposit_on_full is False, COMPLETE.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + ]) + strategy = GatheringStrategy( + {"resource_code": "copper_rocks", "deposit_on_full": False}, + pf, + ) + items = [InventorySlot(slot=i, code="copper_ore", quantity=1) for i in range(20)] + char = make_character(x=3, y=4, inventory_max_items=20, inventory=items) + + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.COMPLETE + + +class TestGatheringStrategyMaxLoops: + """Tests for the max_loops limit.""" + + @pytest.mark.asyncio + async def test_max_loops(self, make_character, pathfinder_with_maps): + """Strategy should return COMPLETE after max_loops deposit cycles.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy( + {"resource_code": "copper_rocks", "max_loops": 1}, + pf, + ) + + # Simulate a complete gather-deposit cycle to increment _loop_count + strategy._loop_count = 1 # Simulate one completed cycle + + char = make_character(x=3, y=4) + plan = await strategy.next_action(char) + + assert plan.action_type == ActionType.COMPLETE + + @pytest.mark.asyncio + async def test_no_max_loops(self, make_character, pathfinder_with_maps): + """With max_loops=0 (default), the strategy should never COMPLETE due to loops.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy( + {"resource_code": "copper_rocks", "max_loops": 0}, + pf, + ) + + # Even with many loops completed, max_loops=0 means infinite + strategy._loop_count = 999 + + char = make_character(x=3, y=4) + plan = await strategy.next_action(char) + + # Should still gather, not COMPLETE + assert plan.action_type != ActionType.COMPLETE + + @pytest.mark.asyncio + async def test_loop_count_increments_after_deposit(self, make_character, pathfinder_with_maps): + """The loop counter should increment after depositing all items.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + (10, 0, "bank", "bank"), + ]) + strategy = GatheringStrategy( + {"resource_code": "copper_rocks", "max_loops": 5}, + pf, + ) + + assert strategy._loop_count == 0 + + # Simulate being at bank with empty inventory (all items deposited) + # and in DEPOSIT state + strategy._state = strategy._state.__class__("deposit") + strategy._resource_pos = (3, 4) + strategy._bank_pos = (10, 0) + + char = make_character(x=10, y=0, inventory=[]) + await strategy.next_action(char) + + assert strategy._loop_count == 1 + + +class TestGatheringStrategyGetState: + """Tests for get_state() reporting.""" + + def test_initial_state(self, pathfinder_with_maps): + """Initial state should be move_to_resource.""" + pf = pathfinder_with_maps([ + (3, 4, "resource", "copper_rocks"), + ]) + strategy = GatheringStrategy({"resource_code": "copper_rocks"}, pf) + assert strategy.get_state() == "move_to_resource" diff --git a/backend/tests/test_heal_policy.py b/backend/tests/test_heal_policy.py new file mode 100644 index 0000000..410d132 --- /dev/null +++ b/backend/tests/test_heal_policy.py @@ -0,0 +1,166 @@ +"""Tests for HealPolicy.""" + +from app.engine.decision.heal_policy import HealPolicy +from app.engine.strategies.base import ActionType +from app.schemas.game import InventorySlot + + +class TestHealPolicyShouldHeal: + """Tests for HealPolicy.should_heal().""" + + def test_should_heal_low_hp(self, make_character): + """Returns True when HP is below the threshold percentage.""" + policy = HealPolicy() + char = make_character(hp=30, max_hp=100) + + assert policy.should_heal(char, threshold=50) is True + + def test_should_not_heal_full(self, make_character): + """Returns False when character is at full HP.""" + policy = HealPolicy() + char = make_character(hp=100, max_hp=100) + + assert policy.should_heal(char, threshold=50) is False + + def test_should_not_heal_above_threshold(self, make_character): + """Returns False when HP is above threshold.""" + policy = HealPolicy() + char = make_character(hp=80, max_hp=100) + + assert policy.should_heal(char, threshold=50) is False + + def test_should_heal_exactly_at_threshold(self, make_character): + """Returns False when HP is exactly at threshold (not strictly below).""" + policy = HealPolicy() + char = make_character(hp=50, max_hp=100) + + # 50/100 = 50%, threshold=50 => 50 < 50 is False + assert policy.should_heal(char, threshold=50) is False + + def test_should_heal_one_below_threshold(self, make_character): + """Returns True when HP is just 1 below the threshold.""" + policy = HealPolicy() + char = make_character(hp=49, max_hp=100) + + assert policy.should_heal(char, threshold=50) is True + + def test_should_heal_zero_hp(self, make_character): + """Returns True when HP is zero.""" + policy = HealPolicy() + char = make_character(hp=0, max_hp=100) + + assert policy.should_heal(char, threshold=50) is True + + def test_should_heal_zero_max_hp(self, make_character): + """Returns False when max_hp is 0 to avoid division by zero.""" + policy = HealPolicy() + char = make_character(hp=0, max_hp=0) + + assert policy.should_heal(char, threshold=50) is False + + def test_should_heal_high_threshold(self, make_character): + """With a threshold of 100, any missing HP triggers healing.""" + policy = HealPolicy() + char = make_character(hp=99, max_hp=100) + + assert policy.should_heal(char, threshold=100) is True + + +class TestHealPolicyIsFullHealth: + """Tests for HealPolicy.is_full_health().""" + + def test_full_health(self, make_character): + """Returns True when hp == max_hp.""" + policy = HealPolicy() + char = make_character(hp=100, max_hp=100) + + assert policy.is_full_health(char) is True + + def test_not_full_health(self, make_character): + """Returns False when hp < max_hp.""" + policy = HealPolicy() + char = make_character(hp=50, max_hp=100) + + assert policy.is_full_health(char) is False + + def test_overheal(self, make_character): + """Returns True when hp > max_hp (edge case).""" + policy = HealPolicy() + char = make_character(hp=150, max_hp=100) + + assert policy.is_full_health(char) is True + + +class TestHealPolicyChooseHealMethod: + """Tests for HealPolicy.choose_heal_method().""" + + def test_choose_rest(self, make_character): + """Default heal method should return REST.""" + policy = HealPolicy() + char = make_character(hp=30, max_hp=100) + config = {"heal_method": "rest"} + + plan = policy.choose_heal_method(char, config) + + assert plan.action_type == ActionType.REST + + def test_choose_rest_default(self, make_character): + """Empty config should default to REST.""" + policy = HealPolicy() + char = make_character(hp=30, max_hp=100) + + plan = policy.choose_heal_method(char, {}) + + assert plan.action_type == ActionType.REST + + def test_choose_consumable(self, make_character): + """When heal_method is consumable and character has the item, returns USE_ITEM.""" + policy = HealPolicy() + char = make_character( + hp=30, + max_hp=100, + inventory=[InventorySlot(slot=0, code="cooked_chicken", quantity=3)], + ) + config = { + "heal_method": "consumable", + "consumable_code": "cooked_chicken", + } + + plan = policy.choose_heal_method(char, config) + + assert plan.action_type == ActionType.USE_ITEM + assert plan.params["code"] == "cooked_chicken" + assert plan.params["quantity"] == 1 + + def test_choose_consumable_not_in_inventory(self, make_character): + """When consumable is not in inventory, falls back to REST.""" + policy = HealPolicy() + char = make_character(hp=30, max_hp=100, inventory=[]) + config = { + "heal_method": "consumable", + "consumable_code": "cooked_chicken", + } + + plan = policy.choose_heal_method(char, config) + + assert plan.action_type == ActionType.REST + + def test_choose_consumable_no_code(self, make_character): + """When heal_method is consumable but no consumable_code, falls back to REST.""" + policy = HealPolicy() + char = make_character(hp=30, max_hp=100) + config = {"heal_method": "consumable"} + + plan = policy.choose_heal_method(char, config) + + assert plan.action_type == ActionType.REST + + def test_plan_contains_reason(self, make_character): + """The returned plan should always have a non-empty reason string.""" + policy = HealPolicy() + char = make_character(hp=30, max_hp=100) + + plan = policy.choose_heal_method(char, {}) + + assert plan.reason != "" + assert "30" in plan.reason # HP value should appear in reason diff --git a/backend/tests/test_monster_selector.py b/backend/tests/test_monster_selector.py new file mode 100644 index 0000000..bf835b4 --- /dev/null +++ b/backend/tests/test_monster_selector.py @@ -0,0 +1,156 @@ +"""Tests for MonsterSelector.""" + +from app.engine.decision.monster_selector import MonsterSelector + + +class TestMonsterSelectorSelectOptimal: + """Tests for MonsterSelector.select_optimal().""" + + def test_select_optimal_near_level(self, make_character, make_monster): + """Prefers monsters within +/- 5 levels of the character.""" + selector = MonsterSelector() + char = make_character(level=10) + monsters = [ + make_monster(code="chicken", level=1), + make_monster(code="wolf", level=8), + make_monster(code="bear", level=12), + make_monster(code="dragon", level=30), + ] + + result = selector.select_optimal(char, monsters) + + # wolf (8) and bear (12) are within +/- 5 of level 10 + # bear (12) should be preferred because higher level = more XP + assert result is not None + assert result.code == "bear" + + def test_select_optimal_no_monsters(self, make_character): + """Returns None for an empty monster list.""" + selector = MonsterSelector() + char = make_character(level=10) + + result = selector.select_optimal(char, []) + + assert result is None + + def test_prefer_higher_level(self, make_character, make_monster): + """Among candidates within range, prefers higher level.""" + selector = MonsterSelector() + char = make_character(level=10) + monsters = [ + make_monster(code="wolf", level=8), + make_monster(code="ogre", level=13), + make_monster(code="bear", level=11), + ] + + result = selector.select_optimal(char, monsters) + + # All within +/- 5 of 10; ogre at 13 is highest + assert result is not None + assert result.code == "ogre" + + def test_select_exact_level(self, make_character, make_monster): + """A monster at exactly the character's level should be a valid candidate.""" + selector = MonsterSelector() + char = make_character(level=5) + monsters = [ + make_monster(code="goblin", level=5), + ] + + result = selector.select_optimal(char, monsters) + + assert result is not None + assert result.code == "goblin" + + def test_fallback_below_level(self, make_character, make_monster): + """When no monsters are in range, falls back to the best below-level monster.""" + selector = MonsterSelector() + char = make_character(level=20) + monsters = [ + make_monster(code="chicken", level=1), + make_monster(code="rat", level=3), + make_monster(code="wolf", level=10), + ] + + result = selector.select_optimal(char, monsters) + + # All are more than 5 levels below 20, so fallback to highest below-level + assert result is not None + assert result.code == "wolf" + + def test_fallback_all_above(self, make_character, make_monster): + """When all monsters are above the character, picks the lowest-level one.""" + selector = MonsterSelector() + char = make_character(level=1) + monsters = [ + make_monster(code="dragon", level=30), + make_monster(code="demon", level=25), + make_monster(code="ogre", level=20), + ] + + result = selector.select_optimal(char, monsters) + + # All above and out of range; within range [1-5..1+5] = [-4..6] none qualify. + # No monsters at or below level 1, so absolute fallback to lowest + assert result is not None + assert result.code == "ogre" + + def test_boundary_level_included(self, make_character, make_monster): + """Monsters exactly 5 levels away should be included in candidates.""" + selector = MonsterSelector() + char = make_character(level=10) + monsters = [ + make_monster(code="exactly_minus_5", level=5), + make_monster(code="exactly_plus_5", level=15), + ] + + result = selector.select_optimal(char, monsters) + + # Both are exactly at the boundary; prefer higher level + assert result is not None + assert result.code == "exactly_plus_5" + + def test_single_monster(self, make_character, make_monster): + """With a single monster, it should always be selected.""" + selector = MonsterSelector() + char = make_character(level=10) + monsters = [make_monster(code="solo", level=50)] + + result = selector.select_optimal(char, monsters) + + assert result is not None + assert result.code == "solo" + + +class TestMonsterSelectorFilterByCode: + """Tests for MonsterSelector.filter_by_code().""" + + def test_filter_by_code_found(self, make_monster): + """filter_by_code should return the matching monster.""" + selector = MonsterSelector() + monsters = [ + make_monster(code="chicken"), + make_monster(code="wolf"), + ] + + result = selector.filter_by_code(monsters, "wolf") + + assert result is not None + assert result.code == "wolf" + + def test_filter_by_code_not_found(self, make_monster): + """filter_by_code should return None when no monster matches.""" + selector = MonsterSelector() + monsters = [make_monster(code="chicken")] + + result = selector.filter_by_code(monsters, "dragon") + + assert result is None + + def test_filter_by_code_empty_list(self): + """filter_by_code should return None for an empty list.""" + selector = MonsterSelector() + + result = selector.filter_by_code([], "chicken") + + assert result is None diff --git a/backend/tests/test_pathfinder.py b/backend/tests/test_pathfinder.py new file mode 100644 index 0000000..661c279 --- /dev/null +++ b/backend/tests/test_pathfinder.py @@ -0,0 +1,190 @@ +"""Tests for the Pathfinder spatial index.""" + +from app.engine.pathfinder import Pathfinder +from app.schemas.game import ContentSchema, MapSchema + + +class TestPathfinderFindNearest: + """Tests for Pathfinder.find_nearest().""" + + def test_find_nearest(self, pathfinder_with_maps): + """find_nearest should return the closest tile matching type and code.""" + pf = pathfinder_with_maps([ + (10, 10, "monster", "chicken"), + (2, 2, "monster", "chicken"), + (20, 20, "monster", "chicken"), + ]) + result = pf.find_nearest(0, 0, "monster", "chicken") + assert result == (2, 2) + + def test_find_nearest_prefers_manhattan_distance(self, pathfinder_with_maps): + """find_nearest should use Manhattan distance, not Euclidean.""" + pf = pathfinder_with_maps([ + (3, 0, "monster", "wolf"), # Manhattan=3 + (2, 2, "monster", "wolf"), # Manhattan=4 + ]) + result = pf.find_nearest(0, 0, "monster", "wolf") + assert result == (3, 0) + + def test_find_nearest_no_match(self, pathfinder_with_maps): + """find_nearest should return None when no tile matches.""" + pf = pathfinder_with_maps([ + (1, 1, "monster", "chicken"), + ]) + result = pf.find_nearest(0, 0, "monster", "dragon") + assert result is None + + def test_find_nearest_empty_map(self): + """find_nearest should return None on an empty map.""" + pf = Pathfinder() + result = pf.find_nearest(0, 0, "monster", "chicken") + assert result is None + + def test_find_nearest_ignores_different_type(self, pathfinder_with_maps): + """find_nearest should not match tiles with a different content type.""" + pf = pathfinder_with_maps([ + (1, 1, "resource", "chicken"), # same code, different type + ]) + result = pf.find_nearest(0, 0, "monster", "chicken") + assert result is None + + def test_find_nearest_at_origin(self, pathfinder_with_maps): + """find_nearest should return a tile at (0, 0) if it matches.""" + pf = pathfinder_with_maps([ + (0, 0, "bank", "bank"), + (5, 5, "bank", "bank"), + ]) + result = pf.find_nearest(0, 0, "bank", "bank") + assert result == (0, 0) + + +class TestPathfinderFindNearestByType: + """Tests for Pathfinder.find_nearest_by_type().""" + + def test_find_nearest_by_type(self, pathfinder_with_maps): + """find_nearest_by_type should find by type regardless of code.""" + pf = pathfinder_with_maps([ + (10, 10, "bank", "city_bank"), + (3, 3, "bank", "village_bank"), + ]) + result = pf.find_nearest_by_type(0, 0, "bank") + assert result == (3, 3) + + def test_find_nearest_by_type_no_match(self, pathfinder_with_maps): + """find_nearest_by_type should return None when no type matches.""" + pf = pathfinder_with_maps([ + (1, 1, "monster", "chicken"), + ]) + result = pf.find_nearest_by_type(0, 0, "bank") + assert result is None + + +class TestPathfinderFindAll: + """Tests for Pathfinder.find_all().""" + + def test_find_all(self, pathfinder_with_maps): + """find_all should return all tiles matching type and code.""" + pf = pathfinder_with_maps([ + (1, 1, "monster", "chicken"), + (5, 5, "monster", "chicken"), + (3, 3, "monster", "wolf"), + (7, 7, "resource", "copper"), + ]) + result = pf.find_all("monster", "chicken") + assert sorted(result) == [(1, 1), (5, 5)] + + def test_find_all_by_type_only(self, pathfinder_with_maps): + """find_all with code=None should return all tiles of the given type.""" + pf = pathfinder_with_maps([ + (1, 1, "monster", "chicken"), + (5, 5, "monster", "wolf"), + (7, 7, "resource", "copper"), + ]) + result = pf.find_all("monster") + assert sorted(result) == [(1, 1), (5, 5)] + + def test_find_all_no_match(self, pathfinder_with_maps): + """find_all should return an empty list when nothing matches.""" + pf = pathfinder_with_maps([ + (1, 1, "monster", "chicken"), + ]) + result = pf.find_all("bank", "bank") + assert result == [] + + def test_find_all_empty_map(self): + """find_all should return an empty list on an empty map.""" + pf = Pathfinder() + result = pf.find_all("monster") + assert result == [] + + +class TestPathfinderTileHasContent: + """Tests for Pathfinder.tile_has_content() and tile_has_content_type().""" + + def test_tile_has_content(self, pathfinder_with_maps): + """tile_has_content should return True for an exact match.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + ]) + assert pf.tile_has_content(5, 5, "monster", "chicken") is True + + def test_tile_has_content_wrong_code(self, pathfinder_with_maps): + """tile_has_content should return False for a code mismatch.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + ]) + assert pf.tile_has_content(5, 5, "monster", "wolf") is False + + def test_tile_has_content_missing_tile(self): + """tile_has_content should return False for a non-existent tile.""" + pf = Pathfinder() + assert pf.tile_has_content(99, 99, "monster", "chicken") is False + + def test_tile_has_content_no_content(self, make_map_tile): + """tile_has_content should return False for a tile with no content.""" + pf = Pathfinder() + tile = make_map_tile(1, 1) # No content_type/content_code + pf.load_maps([tile]) + assert pf.tile_has_content(1, 1, "monster", "chicken") is False + + def test_tile_has_content_type(self, pathfinder_with_maps): + """tile_has_content_type should match on type alone.""" + pf = pathfinder_with_maps([ + (5, 5, "monster", "chicken"), + ]) + assert pf.tile_has_content_type(5, 5, "monster") is True + assert pf.tile_has_content_type(5, 5, "bank") is False + + +class TestPathfinderMisc: + """Tests for miscellaneous Pathfinder methods.""" + + def test_is_loaded_false_initially(self): + """is_loaded should be False before any maps are loaded.""" + pf = Pathfinder() + assert pf.is_loaded is False + + def test_is_loaded_true_after_load(self, pathfinder_with_maps): + """is_loaded should be True after loading maps.""" + pf = pathfinder_with_maps([(0, 0, "bank", "bank")]) + assert pf.is_loaded is True + + def test_get_tile(self, pathfinder_with_maps): + """get_tile should return the MapSchema at the given coordinates.""" + pf = pathfinder_with_maps([(3, 7, "monster", "chicken")]) + tile = pf.get_tile(3, 7) + assert tile is not None + assert tile.x == 3 + assert tile.y == 7 + assert tile.content.code == "chicken" + + def test_get_tile_missing(self): + """get_tile should return None for coordinates not in the index.""" + pf = Pathfinder() + assert pf.get_tile(99, 99) is None + + def test_manhattan_distance(self): + """manhattan_distance should compute |x1-x2| + |y1-y2|.""" + assert Pathfinder.manhattan_distance(0, 0, 3, 4) == 7 + assert Pathfinder.manhattan_distance(5, 5, 5, 5) == 0 + assert Pathfinder.manhattan_distance(-2, 3, 1, -1) == 7 diff --git a/backend/tests/test_resource_selector.py b/backend/tests/test_resource_selector.py new file mode 100644 index 0000000..05419aa --- /dev/null +++ b/backend/tests/test_resource_selector.py @@ -0,0 +1,238 @@ +"""Tests for ResourceSelector.""" + +from app.engine.decision.resource_selector import ResourceSelector + + +class TestResourceSelectorSelectOptimal: + """Tests for ResourceSelector.select_optimal().""" + + def test_select_optimal(self, make_character, make_resource): + """Picks the best resource for the character's skill level.""" + selector = ResourceSelector() + char = make_character(mining_level=5) + resources = [ + make_resource(code="copper_rocks", skill="mining", level=1), + make_resource(code="iron_rocks", skill="mining", level=5), + make_resource(code="gold_rocks", skill="mining", level=8), + make_resource(code="diamond_rocks", skill="mining", level=20), + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is not None + # gold_rocks (level 8) is within range [2..8] and highest => best XP + assert result.resource.code == "gold_rocks" + + def test_no_matching_skill(self, make_character, make_resource): + """Returns None for a non-matching skill.""" + selector = ResourceSelector() + char = make_character(mining_level=5) + resources = [ + make_resource(code="oak_tree", skill="woodcutting", level=5), + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is None + + def test_unknown_skill(self, make_character, make_resource): + """Returns None when the skill attribute does not exist on the character.""" + selector = ResourceSelector() + char = make_character() + resources = [ + make_resource(code="mystery", skill="alchemy_brewing", level=1), + ] + + # "alchemy_brewing_level" does not exist on CharacterSchema + result = selector.select_optimal(char, resources, "alchemy_brewing") + + assert result is None + + def test_empty_resources(self, make_character): + """Returns None for an empty resource list.""" + selector = ResourceSelector() + char = make_character(mining_level=5) + + result = selector.select_optimal(char, [], "mining") + + assert result is None + + def test_prefers_higher_level_in_range(self, make_character, make_resource): + """Among resources in the optimal range, prefers higher level.""" + selector = ResourceSelector() + char = make_character(mining_level=10) + resources = [ + make_resource(code="iron", skill="mining", level=8), + make_resource(code="mithril", skill="mining", level=12), + make_resource(code="gold", skill="mining", level=10), + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is not None + # mithril (12) is within range and above skill level => best score + assert result.resource.code == "mithril" + + def test_prefers_above_skill_over_below(self, make_character, make_resource): + """Resources at or above skill level are preferred (bonus score).""" + selector = ResourceSelector() + char = make_character(mining_level=10) + resources = [ + make_resource(code="lower", skill="mining", level=7), # diff=-3, in range, below + make_resource(code="higher", skill="mining", level=11), # diff=+1, in range, above + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is not None + assert result.resource.code == "higher" + + def test_fallback_to_highest_gatherable(self, make_character, make_resource): + """When no resource is in optimal range, the scoring prefers the one closest to range.""" + selector = ResourceSelector() + char = make_character(mining_level=10) + resources = [ + make_resource(code="copper", skill="mining", level=1), # diff=-9, penalty=6, score=0.1 + make_resource(code="iron", skill="mining", level=5), # diff=-5, penalty=2, score=3.0 + make_resource(code="gold", skill="mining", level=6), # diff=-4, penalty=1, score=4.0 + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is not None + # gold (level 6) has the smallest penalty outside the +/- 3 range + # diff=-4, penalty=1, score=4.0 -- highest among below-range candidates + assert result.resource.code == "gold" + + def test_absolute_fallback_to_lowest(self, make_character, make_resource): + """When nothing is gatherable (all too high), absolute fallback to lowest level.""" + selector = ResourceSelector() + char = make_character(mining_level=1) + resources = [ + make_resource(code="high1", skill="mining", level=50), + make_resource(code="high2", skill="mining", level=30), + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is not None + # high1 at level 50 has diff=+49 (too high, score=0), high2 diff=+29 (too high, score=0) + # Fallback: no gatherable (all above skill), so absolute fallback picks lowest + assert result.resource.code == "high2" + + def test_selection_score_is_positive(self, make_character, make_resource): + """The returned selection should have a positive score.""" + selector = ResourceSelector() + char = make_character(mining_level=5) + resources = [ + make_resource(code="copper", skill="mining", level=5), + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is not None + assert result.score > 0 + + def test_selection_has_reason(self, make_character, make_resource): + """The returned selection should have a non-empty reason string.""" + selector = ResourceSelector() + char = make_character(mining_level=5) + resources = [ + make_resource(code="copper", skill="mining", level=5), + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is not None + assert result.reason != "" + + +class TestResourceSelectorSkills: + """Tests for different skill types.""" + + def test_woodcutting_skill(self, make_character, make_resource): + """ResourceSelector works with woodcutting skill.""" + selector = ResourceSelector() + char = make_character(woodcutting_level=8) + resources = [ + make_resource(code="ash_tree", skill="woodcutting", level=1), + make_resource(code="spruce_tree", skill="woodcutting", level=7), + make_resource(code="birch_tree", skill="woodcutting", level=10), + ] + + result = selector.select_optimal(char, resources, "woodcutting") + + assert result is not None + assert result.resource.skill == "woodcutting" + + def test_fishing_skill(self, make_character, make_resource): + """ResourceSelector works with fishing skill.""" + selector = ResourceSelector() + char = make_character(fishing_level=3) + resources = [ + make_resource(code="shrimp_spot", skill="fishing", level=1), + make_resource(code="trout_spot", skill="fishing", level=5), + ] + + result = selector.select_optimal(char, resources, "fishing") + + assert result is not None + assert result.resource.skill == "fishing" + + def test_mixed_skills_filtered(self, make_character, make_resource): + """Only resources matching the requested skill are considered.""" + selector = ResourceSelector() + char = make_character(mining_level=5, woodcutting_level=5) + resources = [ + make_resource(code="copper", skill="mining", level=5), + make_resource(code="ash_tree", skill="woodcutting", level=5), + ] + + result = selector.select_optimal(char, resources, "mining") + + assert result is not None + assert result.resource.code == "copper" + assert result.resource.skill == "mining" + + +class TestResourceSelectorScoring: + """Tests for the internal scoring logic.""" + + def test_score_resource_too_high(self, make_resource): + """Resources more than LEVEL_RANGE above skill get score 0.""" + selector = ResourceSelector() + resource = make_resource(level=20) + + score, reason = selector._score_resource(resource, skill_level=5) + + assert score == 0.0 + + def test_score_resource_in_range(self, make_resource): + """Resources within range get a positive score.""" + selector = ResourceSelector() + resource = make_resource(level=5) + + score, reason = selector._score_resource(resource, skill_level=5) + + assert score > 0 + + def test_score_resource_above_gets_bonus(self, make_resource): + """Resources at or above skill level within range get a bonus.""" + selector = ResourceSelector() + above = make_resource(code="above", level=7) + below = make_resource(code="below", level=3) + + score_above, _ = selector._score_resource(above, skill_level=5) + score_below, _ = selector._score_resource(below, skill_level=5) + + assert score_above > score_below + + def test_score_resource_far_below(self, make_resource): + """Resources far below skill level get a diminishing score.""" + selector = ResourceSelector() + resource = make_resource(level=1) + + score, reason = selector._score_resource(resource, skill_level=20) + + assert score > 0 + assert "Below" in reason diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..0a50dd1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,54 @@ +services: + db: + image: postgres:17 + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + - DATABASE_URL=${DATABASE_URL} + - ARTIFACTS_TOKEN=${ARTIFACTS_TOKEN} + - CORS_ORIGINS=${CORS_ORIGINS} + depends_on: + db: + condition: service_healthy + restart: always + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + target: prod + environment: + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} + depends_on: + - backend + restart: always + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - frontend + - backend + restart: always + +volumes: + pgdata: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..542b13c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +services: + db: + image: postgres:17 + environment: + POSTGRES_USER: ${POSTGRES_USER:-artifacts} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-artifacts} + POSTGRES_DB: ${POSTGRES_DB:-artifacts} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-artifacts}"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + environment: + - DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://artifacts:artifacts@db:5432/artifacts} + - ARTIFACTS_TOKEN=${ARTIFACTS_TOKEN} + - CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:3000"]} + depends_on: + db: + condition: service_healthy + volumes: + - ./backend/app:/app/app + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + target: dev + ports: + - "3000:3000" + environment: + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000} + depends_on: + - backend + volumes: + - ./frontend/src:/app/src + restart: unless-stopped + +volumes: + pgdata: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..ca75653 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,78 @@ +# API Reference + +Base URL: `http://localhost:8000` + +## Health + +### `GET /health` +Returns service health status. + +## Characters + +### `GET /api/characters` +List all characters with current state. + +### `GET /api/characters/{name}` +Get detailed character info including equipment, inventory, and skills. + +## Game Data + +### `GET /api/game/items` +All game items (cached). + +### `GET /api/game/monsters` +All monsters (cached). + +### `GET /api/game/resources` +All resources (cached). + +### `GET /api/game/maps` +All map tiles (cached). + +## Dashboard + +### `GET /api/dashboard` +Aggregated dashboard data for all characters. + +## Automations + +### `GET /api/automations` +List all automation configs. + +### `POST /api/automations` +Create a new automation. + +### `POST /api/automations/{id}/start` +Start an automation. + +### `POST /api/automations/{id}/stop` +Stop an automation. + +### `POST /api/automations/{id}/pause` +Pause an automation. + +### `POST /api/automations/{id}/resume` +Resume a paused automation. + +## Bank + +### `GET /api/bank` +Bank contents with item details. + +## Exchange + +### `GET /api/exchange/orders` +Active GE orders. + +### `GET /api/exchange/prices/{item_code}` +Price history for an item. + +## Events + +### `GET /api/events` +Active and historical game events. + +## WebSocket + +### `WS /ws/live` +Real-time event stream (character updates, automation status, game events). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5b9793b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,63 @@ +# Architecture + +## Overview + +Artifacts Dashboard is a monorepo with a Python/FastAPI backend and Next.js frontend, connected via REST API and WebSocket. + +``` +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Frontend │────▶│ Backend │────▶│ Artifacts API│ +│ (Next.js) │◀────│ (FastAPI) │◀────│ │ +└─────────────┘ WS └──────┬───────┘ └──────────────┘ + │ + ┌──────▼───────┐ + │ PostgreSQL │ + └──────────────┘ +``` + +## Backend + +### Layers + +1. **API Layer** (`app/api/`) — FastAPI routes, request/response handling +2. **Service Layer** (`app/services/`) — business logic, external API communication +3. **Engine Layer** (`app/engine/`) — automation engine with strategies and decision modules +4. **Data Layer** (`app/models/`) — SQLAlchemy models, database access + +### Key Patterns + +- **Strategy Pattern** — each automation type (combat, gathering, crafting, trading, task) implements a common interface +- **State Machine** — strategies operate as state machines (e.g., move → fight → heal → deposit → repeat) +- **Token Bucket** — rate limiting shared across all automation runners +- **Event Bus** — asyncio pub/sub connecting engine events with WebSocket relay +- **A* Pathfinding** — map navigation using cached tile data + +### Automation Engine + +``` +AutomationManager +├── AutomationRunner (per character) +│ ├── Strategy (combat/gathering/crafting/trading/task) +│ ├── CooldownTracker +│ └── RateLimiter (shared) +└── Coordinator (multi-character) +``` + +## Frontend + +- **App Router** — file-based routing with layouts +- **TanStack Query** — server state management with WebSocket-driven invalidation +- **shadcn/ui** — component library built on Radix UI +- **Recharts** — analytics charts + +## Database + +| Table | Purpose | +|-------|---------| +| `game_data_cache` | Cached static game data | +| `character_snapshots` | Periodic character state snapshots | +| `automation_configs` | Automation configurations | +| `automation_runs` | Automation execution state | +| `automation_logs` | Action logs | +| `price_history` | Grand Exchange price history | +| `event_log` | Game event history | diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md new file mode 100644 index 0000000..98b9450 --- /dev/null +++ b/docs/AUTOMATION.md @@ -0,0 +1,49 @@ +# Automation Guide + +## Strategies + +### Combat +Automated monster fighting with healing and loot management. + +**Config:** +- `monster_code` — target monster +- `auto_heal_threshold` — HP% to trigger healing (default: 50) +- `heal_method` — `rest` or `consumable` +- `consumable_code` — item to use for healing +- `min_inventory_slots` — minimum free slots before depositing (default: 3) +- `deposit_loot` — auto-deposit at bank (default: true) + +### Gathering +Automated resource collection. + +**Config:** +- `resource_code` — target resource +- `deposit_on_full` — deposit when inventory full (default: true) +- `max_loops` — stop after N loops (0 = infinite) + +### Crafting +Automated crafting with optional material gathering. + +**Config:** +- `item_code` — item to craft +- `quantity` — how many to craft +- `gather_materials` — auto-gather missing materials +- `recycle_excess` — recycle excess items + +### Trading +Grand Exchange automation. + +**Config:** +- `mode` — `sell` or `buy` +- `item_code` — item to trade +- `min_price` / `max_price` — price range +- `quantity` — trade quantity + +### Task +NPC task automation. + +**Config:** +- Auto-accept, complete requirements, deliver, exchange coins. + +### Leveling +Composite strategy for optimal XP gain. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..549732d --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,27 @@ +# Deployment + +## Docker Compose (Production) + +```bash +cp .env.example .env +# Edit .env with production values +docker compose -f docker-compose.prod.yml up -d +``` + +## Coolify + +1. Connect your GitHub repository in Coolify +2. Set environment variables in the Coolify dashboard +3. Deploy — Coolify will build from `docker-compose.prod.yml` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `ARTIFACTS_TOKEN` | Yes | Your Artifacts MMO API token | +| `DATABASE_URL` | Yes | PostgreSQL connection string | +| `CORS_ORIGINS` | No | Allowed CORS origins (JSON array) | +| `POSTGRES_USER` | Yes | PostgreSQL username | +| `POSTGRES_PASSWORD` | Yes | PostgreSQL password | +| `POSTGRES_DB` | Yes | PostgreSQL database name | +| `NEXT_PUBLIC_API_URL` | Yes | Backend API URL for frontend | diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e8aa625 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-slim AS base +RUN corepack enable pnpm + +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . + +# Development target +FROM base AS dev +EXPOSE 3000 +CMD ["pnpm", "dev"] + +# Production target +FROM base AS prod +RUN pnpm build +EXPOSE 3000 +CMD ["pnpm", "start"] diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..03909d9 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f4fd256 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.21", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.575.0", + "next": "16.1.6", + "radix-ui": "^1.4.3", + "react": "19.2.3", + "react-dom": "19.2.3", + "recharts": "^3.7.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "shadcn": "^3.8.5", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "typescript": "^5" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..4db3e68 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,8026 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.3) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.575.0 + version: 0.575.0(react@19.2.3) + next: + specifier: 16.1.6 + version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + recharts: + specifier: ^3.7.0 + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.1 + '@types/node': + specifier: ^20 + version: 20.19.35 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + eslint: + specifier: ^9 + version: 9.39.3(jiti@2.6.1) + eslint-config-next: + specifier: 16.1.6 + version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + shadcn: + specifier: ^3.8.5 + version: 3.8.5(@types/node@20.19.35)(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.2.1 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@antfu/ni@25.0.0': + resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} + hasBin: true + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@dotenvx/dotenvx@1.52.0': + resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} + hasBin: true + + '@ecies/ciphers@0.2.5': + resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.4': + resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.3': + resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@16.1.6': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + + '@next/eslint-plugin-next@16.1.6': + resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} + + '@next/swc-darwin-arm64@16.1.6': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.6': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.1.6': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.1.6': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.1.6': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.6': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.1': + resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} + + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.35': + resolution: {integrity: sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001775: + resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eciesjs@0.4.17: + resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.302: + resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + engines: {node: '>=10.13.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.1.6: + resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.3: + resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + fzf@0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql@16.13.0: + resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hono@4.12.3: + resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.575.0: + resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next@16.1.6: + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + + recharts@3.7.0: + resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@3.8.5: + resolution: {integrity: sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@5.4.4: + resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} + engines: {node: '>=20'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@antfu/ni@25.0.0': + dependencies: + ansis: 4.2.0 + fzf: 0.5.2 + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@dotenvx/dotenvx@1.52.0': + dependencies: + commander: 11.1.0 + dotenv: 17.3.1 + eciesjs: 0.4.17 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.3) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.3 + which: 4.0.0 + + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))': + dependencies: + eslint: 9.39.3(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.4': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.3': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@floating-ui/utils@0.2.10': {} + + '@hono/node-server@1.19.9(hono@4.12.3)': + dependencies: + hono: 4.12.3 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/confirm@5.1.21(@types/node@20.19.35)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.35) + '@inquirer/type': 3.0.10(@types/node@20.19.35) + optionalDependencies: + '@types/node': 20.19.35 + + '@inquirer/core@10.3.2(@types/node@20.19.35)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.35) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.35 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/type@3.0.10(@types/node@20.19.35)': + optionalDependencies: + '@types/node': 20.19.35 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.3) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.3 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@16.1.6': {} + + '@next/eslint-plugin-next@16.1.6': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.1.6': + optional: true + + '@next/swc-darwin-x64@16.1.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.6': + optional: true + + '@next/swc-linux-arm64-musl@16.1.6': + optional: true + + '@next/swc-linux-x64-gnu@16.1.6': + optional: true + + '@next/swc-linux-x64-musl@16.1.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.6': + optional: true + + '@next/swc-win32-x64-msvc@16.1.6': + optional: true + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/rect': 1.1.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.3 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1) + + '@rtsao/scc@1.1.0': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/postcss@4.2.1': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + postcss: 8.5.6 + tailwindcss: 4.2.1 + + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.3 + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.4 + path-browserify: 1.0.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.35': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/statuses@2.0.6': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@types/validate-npm-package-name@4.0.2': {} + + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 9.39.3(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@4.2.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.1: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.0: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001775 + electron-to-chromium: 1.5.302 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001775: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + concat-map@0.0.1: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + damerau-levenshtein@1.0.8: {} + + data-uri-to-buffer@4.0.1: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + dedent@1.7.1: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + diff@8.0.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eciesjs@0.4.17: + dependencies: + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.302: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.20.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.2: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.44.0: {} + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.1.6 + eslint: 9.39.3(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1)) + globals: 16.4.0 + typescript-eslint: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + get-tsconfig: 4.13.6 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.3(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.3(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.3(jiti@2.6.1) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.3(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.3(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 9.39.3(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.3(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.4 + '@eslint/js': 9.39.3 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + fuzzysort@3.1.0: {} + + fzf@0.5.2: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphql@16.13.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + headers-polyfill@4.0.3: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hono@4.12.3: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immer@10.2.0: {} + + immer@11.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + internmap@2.0.3: {} + + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-node-process@1.2.0: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-regexp@3.1.0: {} + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + jose@6.1.3: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.575.0(react@19.2.3): + dependencies: + react: 19.2.3 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@20.19.35) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@2.0.0: {} + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@next/env': 16.1.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001775 + postcss: 8.4.31 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-domexception@1.0.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.27: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object-treeify@1.1.33: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + outvariant@1.4.3: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pkce-challenge@5.0.1: {} + + possible-typed-array-names@1.1.0: {} + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + prelude-ls@1.2.1: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.3): + dependencies: + react: 19.2.3 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.3): + dependencies: + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.3) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.3): + dependencies: + get-nonce: 1.0.1 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.3: {} + + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.44.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-is: 16.13.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.3) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + reselect@5.1.1: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setprototypeof@1.2.0: {} + + shadcn@3.8.5(@types/node@20.19.35)(typescript@5.9.3): + dependencies: + '@antfu/ni': 25.0.0 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@dotenvx/dotenvx': 1.52.0 + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.1 + commander: 14.0.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + dedent: 1.7.1 + deepmerge: 4.3.1 + diff: 8.0.3 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.3 + fuzzysort: 3.1.0 + https-proxy-agent: 7.0.6 + kleur: 4.1.5 + msw: 2.12.10(@types/node@20.19.35)(typescript@5.9.3) + node-fetch: 3.3.2 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + prompts: 2.4.2 + recast: 0.23.11 + stringify-object: 5.0.0 + tailwind-merge: 3.5.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@types/node' + - babel-plugin-macros + - supports-color + - typescript + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + stable-hash@0.0.5: {} + + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): + dependencies: + client-only: 0.0.1 + react: 19.2.3 + optionalDependencies: + '@babel/core': 7.29.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + tiny-invariant@1.3.3: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@5.4.4: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + until-async@3.0.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.3): + dependencies: + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.3): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + + util-deprecate@1.0.2: {} + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + web-streams-polyfill@3.3.3: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@3.25.76: {} + + zod@4.3.6: {} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/src/app/analytics/page.tsx b/frontend/src/app/analytics/page.tsx new file mode 100644 index 0000000..9e9b2a0 --- /dev/null +++ b/frontend/src/app/analytics/page.tsx @@ -0,0 +1,452 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + BarChart3, + Loader2, + Activity, + Coins, + TrendingUp, + Zap, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + LineChart, + Line, + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { useCharacters } from "@/hooks/use-characters"; +import { useAnalytics } from "@/hooks/use-analytics"; +import { SKILLS, SKILL_COLOR_TEXT_MAP } from "@/lib/constants"; +import type { Character } from "@/lib/types"; + +const TIME_RANGES = [ + { label: "Last 1h", hours: 1 }, + { label: "Last 6h", hours: 6 }, + { label: "Last 24h", hours: 24 }, + { label: "Last 7d", hours: 168 }, +] as const; + +const SKILL_CHART_COLORS = [ + "#f59e0b", // amber + "#22c55e", // green + "#3b82f6", // blue + "#ef4444", // red + "#64748b", // slate + "#a855f7", // purple + "#f97316", // orange + "#10b981", // emerald +]; + +function formatTime(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +interface ChartTooltipPayloadItem { + name: string; + value: number; + color: string; +} + +function ChartTooltip({ + active, + payload, + label, +}: { + active?: boolean; + payload?: ChartTooltipPayloadItem[]; + label?: string; +}) { + if (!active || !payload?.length || !label) return null; + + return ( + +

{label}

+ {payload.map((entry: ChartTooltipPayloadItem) => ( +
+ + {entry.name}: + + {entry.value.toLocaleString()} + +
+ ))} +
+ ); +} + +function SkillLevelChart({ character }: { character: Character }) { + const skillData = SKILLS.map((skill, idx) => ({ + skill: skill.label, + level: character[`${skill.key}_level` as keyof Character] as number, + fill: SKILL_CHART_COLORS[idx], + })); + + return ( + + + + + + } /> + + {skillData.map((entry, index) => ( + + ))} + + + + ); +} + +export default function AnalyticsPage() { + const { data: characters, isLoading: loadingChars } = useCharacters(); + const [selectedChar, setSelectedChar] = useState("_all"); + const [timeRange, setTimeRange] = useState(24); + + const characterName = + selectedChar === "_all" ? undefined : selectedChar; + const { + data: analytics, + isLoading: loadingAnalytics, + error, + } = useAnalytics(characterName, timeRange); + + const selectedCharacter = useMemo(() => { + if (!characters || selectedChar === "_all") return null; + return characters.find((c) => c.name === selectedChar) ?? null; + }, [characters, selectedChar]); + + const xpChartData = useMemo(() => { + if (!analytics?.xp_history) return []; + return analytics.xp_history.map((point) => ({ + time: formatTime(point.timestamp), + xp: point.value, + label: point.label ?? "XP", + })); + }, [analytics]); + + const goldChartData = useMemo(() => { + if (!analytics?.gold_history) return []; + return analytics.gold_history.map((point) => ({ + time: formatTime(point.timestamp), + gold: point.value, + })); + }, [analytics]); + + const isLoading = loadingChars || loadingAnalytics; + + return ( +
+
+

+ Analytics +

+

+ Track XP gains, gold progression, and activity metrics +

+
+ + {error && ( + +

+ Failed to load analytics. Make sure the backend is running. +

+
+ )} + + {/* Filters */} +
+ + +
+ {TIME_RANGES.map((range) => ( + + ))} +
+
+ + {isLoading && ( +
+ +
+ )} + + {analytics && ( + <> + {/* Stats Card */} +
+ + + + + Actions / Hour + + + +

+ {analytics.actions_per_hour.toFixed(1)} +

+
+
+ + + + + + XP Data Points + + + +

+ {analytics.xp_history.length} +

+
+
+ + + + + + Gold Data Points + + + +

+ {analytics.gold_history.length} +

+
+
+
+ + {/* XP Gain Chart */} + + + + + XP Gain Over Time + + + + {xpChartData.length > 0 ? ( + + + + + + } /> + + + + ) : ( +
+ No XP data available for the selected time range. +
+ )} +
+
+ + {/* Gold Tracking Chart */} + + + + + Gold Tracking + + + + {goldChartData.length > 0 ? ( + + + + + + + + + + + + } /> + + + + ) : ( +
+ No gold data available for the selected time range. +
+ )} +
+
+ + {/* Level Progression */} + {selectedCharacter && ( + + + + + Skill Levels - {selectedCharacter.name} + + + + + + {/* Skill badges */} +
+ {SKILLS.map((skill) => { + const level = selectedCharacter[ + `${skill.key}_level` as keyof Character + ] as number; + return ( + + + {skill.label} + + + {level} + + + ); + })} +
+
+
+ )} + + {!selectedCharacter && characters && characters.length > 0 && ( + + +

+ Select a specific character above to view skill level + progression. +

+
+ )} + + )} + + {/* Empty state when no analytics */} + {!analytics && !isLoading && !error && ( + + +

+ No analytics data available yet. Start automations or perform + actions to generate data. +

+
+ )} +
+ ); +} diff --git a/frontend/src/app/automations/[id]/page.tsx b/frontend/src/app/automations/[id]/page.tsx new file mode 100644 index 0000000..d5d8336 --- /dev/null +++ b/frontend/src/app/automations/[id]/page.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { use } from "react"; +import { useRouter } from "next/navigation"; +import { + ArrowLeft, + Loader2, + Swords, + Pickaxe, + Bot, + Clock, + Calendar, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from "@/components/ui/table"; +import { + useAutomation, + useAutomationStatuses, + useAutomationLogs, +} from "@/hooks/use-automations"; +import { RunControls } from "@/components/automation/run-controls"; +import { LogStream } from "@/components/automation/log-stream"; +import { cn } from "@/lib/utils"; + +const STRATEGY_ICONS: Record = { + combat: , + gathering: , + crafting: , + trading: , + task: , + leveling: , +}; + +const STATUS_BADGE_CLASSES: Record = { + running: "bg-green-600 text-white", + paused: "bg-yellow-600 text-white", + stopped: "", + error: "bg-red-600 text-white", +}; + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleString(); +} + +function formatDuration(start: string, end: string | null): string { + const startTime = new Date(start).getTime(); + const endTime = end ? new Date(end).getTime() : Date.now(); + const diffMs = endTime - startTime; + const diffS = Math.floor(diffMs / 1000); + + if (diffS < 60) return `${diffS}s`; + const m = Math.floor(diffS / 60); + const s = diffS % 60; + if (m < 60) return `${m}m ${s}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} + +function ConfigDisplay({ config }: { config: Record }) { + const entries = Object.entries(config).filter( + ([, v]) => v !== undefined && v !== null && v !== "" + ); + + if (entries.length === 0) { + return ( +

No configuration set.

+ ); + } + + return ( +
+ {entries.map(([key, value]) => ( +
+ + {key.replace(/_/g, " ")} + + + {typeof value === "boolean" ? (value ? "Yes" : "No") : String(value)} + +
+ ))} +
+ ); +} + +export default function AutomationDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id: idStr } = use(params); + const id = parseInt(idStr, 10); + const router = useRouter(); + + const { data, isLoading, error } = useAutomation(id); + const { data: statuses } = useAutomationStatuses(); + const { data: logs } = useAutomationLogs(id); + + const status = statuses?.find((s) => s.config_id === id); + const currentStatus = status?.status ?? "stopped"; + const actionsCount = status?.actions_count ?? 0; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+ + +

+ Failed to load automation. It may have been deleted. +

+
+
+ ); + } + + const { config: automation, runs } = data; + + return ( +
+ {/* Header */} +
+ +
+
+ {STRATEGY_ICONS[automation.strategy_type] ?? ( + + )} +

+ {automation.name} +

+
+

+ {automation.character_name} ·{" "} + {automation.strategy_type}{" "} + strategy +

+
+
+ + {/* Controls */} + + + + + {/* Tabs: Config / Runs / Logs */} + + + Live Logs + Configuration + Run History + + + + + + Live Log Stream + + + + + + + + + + + Strategy Configuration + + + + + + + + + + + Run History + + + {runs.length === 0 ? ( +

+ No runs yet. Start the automation to create a run. +

+ ) : ( + + + + Status + Started + Duration + Actions + Error + + + + {runs.map((run) => ( + + + + {run.status} + + + +
+ + {formatDate(run.started_at)} +
+
+ +
+ + {formatDuration( + run.started_at, + run.stopped_at + )} +
+
+ + {run.actions_count.toLocaleString()} + + + {run.error_message && ( + + {run.error_message} + + )} + +
+ ))} +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/automations/new/page.tsx b/frontend/src/app/automations/new/page.tsx new file mode 100644 index 0000000..96c6cbb --- /dev/null +++ b/frontend/src/app/automations/new/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { StrategySelector } from "@/components/automation/strategy-selector"; +import { + ConfigForm, + DEFAULT_COMBAT_CONFIG, + DEFAULT_GATHERING_CONFIG, + DEFAULT_CRAFTING_CONFIG, + DEFAULT_TRADING_CONFIG, + DEFAULT_TASK_CONFIG, + DEFAULT_LEVELING_CONFIG, +} from "@/components/automation/config-form"; +import { useCharacters } from "@/hooks/use-characters"; +import { useCreateAutomation } from "@/hooks/use-automations"; +import { toast } from "sonner"; + +type StrategyType = + | "combat" + | "gathering" + | "crafting" + | "trading" + | "task" + | "leveling"; + +const DEFAULT_CONFIGS: Record> = { + combat: DEFAULT_COMBAT_CONFIG as unknown as Record, + gathering: DEFAULT_GATHERING_CONFIG as unknown as Record, + crafting: DEFAULT_CRAFTING_CONFIG as unknown as Record, + trading: DEFAULT_TRADING_CONFIG as unknown as Record, + task: DEFAULT_TASK_CONFIG as unknown as Record, + leveling: DEFAULT_LEVELING_CONFIG as unknown as Record, +}; + +export default function NewAutomationPage() { + const router = useRouter(); + const { data: characters, isLoading: loadingCharacters } = useCharacters(); + const createMutation = useCreateAutomation(); + + const [name, setName] = useState(""); + const [characterName, setCharacterName] = useState(""); + const [strategyType, setStrategyType] = useState(null); + const [config, setConfig] = useState>({}); + + function handleStrategyChange(strategy: StrategyType) { + setStrategyType(strategy); + setConfig(DEFAULT_CONFIGS[strategy]); + } + + function handleCreate() { + if (!name.trim()) { + toast.error("Please enter a name for the automation"); + return; + } + if (!characterName) { + toast.error("Please select a character"); + return; + } + if (!strategyType) { + toast.error("Please select a strategy"); + return; + } + + createMutation.mutate( + { + name: name.trim(), + character_name: characterName, + strategy_type: strategyType, + config, + }, + { + onSuccess: () => { + toast.success("Automation created successfully"); + router.push("/automations"); + }, + onError: (err) => { + toast.error(`Failed to create automation: ${err.message}`); + }, + } + ); + } + + return ( +
+
+ +
+

+ New Automation +

+

+ Configure a new automated strategy +

+
+
+ + {/* Step 1: Name + Character */} + + + + + 1 + + Basic Info + + + +
+ + setName(e.target.value)} + /> +
+ +
+ + +
+
+
+ + {/* Step 2: Strategy */} + + + + + 2 + + Select Strategy + + + + + + + + {/* Step 3: Configuration */} + + + + + 3 + + Configure Strategy + + + + {strategyType && ( + + )} + + + + + +
+ + +
+
+ ); +} diff --git a/frontend/src/app/automations/page.tsx b/frontend/src/app/automations/page.tsx new file mode 100644 index 0000000..a4aa902 --- /dev/null +++ b/frontend/src/app/automations/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Plus, + Trash2, + Swords, + Pickaxe, + Bot, + Loader2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + useAutomations, + useAutomationStatuses, + useDeleteAutomation, + useControlAutomation, +} from "@/hooks/use-automations"; +import { RunControls } from "@/components/automation/run-controls"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +const STRATEGY_ICONS: Record = { + combat: , + gathering: , + crafting: , + trading: , + task: , + leveling: , +}; + +const STRATEGY_COLORS: Record = { + combat: "text-red-400", + gathering: "text-green-400", + crafting: "text-blue-400", + trading: "text-yellow-400", + task: "text-purple-400", + leveling: "text-cyan-400", +}; + +export default function AutomationsPage() { + const router = useRouter(); + const { data: automations, isLoading, error } = useAutomations(); + const { data: statuses } = useAutomationStatuses(); + const deleteMutation = useDeleteAutomation(); + const [deleteTarget, setDeleteTarget] = useState<{ + id: number; + name: string; + } | null>(null); + + const statusMap = new Map( + (statuses ?? []).map((s) => [s.config_id, s]) + ); + + function handleDelete() { + if (!deleteTarget) return; + deleteMutation.mutate(deleteTarget.id, { + onSuccess: () => { + toast.success(`Automation "${deleteTarget.name}" deleted`); + setDeleteTarget(null); + }, + onError: (err) => { + toast.error(`Failed to delete: ${err.message}`); + }, + }); + } + + return ( +
+
+
+

+ Automations +

+

+ Manage automated strategies for your characters +

+
+ +
+ + {error && ( + +

+ Failed to load automations. Make sure the backend is running. +

+
+ )} + + {isLoading && ( +
+ +
+ )} + + {automations && automations.length === 0 && !isLoading && ( + + +

+ No automations configured yet. Create one to get started. +

+ +
+ )} + + {automations && automations.length > 0 && ( + + + + + Name + Character + Strategy + Status / Controls + + + + + {automations.map((automation) => { + const status = statusMap.get(automation.id); + const currentStatus = status?.status ?? "stopped"; + const actionsCount = status?.actions_count ?? 0; + + return ( + + router.push(`/automations/${automation.id}`) + } + > + + {automation.name} + + + {automation.character_name} + + +
+ {STRATEGY_ICONS[automation.strategy_type] ?? ( + + )} + + {automation.strategy_type} + +
+
+ e.stopPropagation()}> + + + e.stopPropagation()}> + + +
+ ); + })} +
+
+
+ )} + + !open && setDeleteTarget(null)} + > + + + Delete Automation + + Are you sure you want to delete “{deleteTarget?.name} + ”? This action cannot be undone. Any running automation + will be stopped. + + + + + + + + +
+ ); +} diff --git a/frontend/src/app/bank/page.tsx b/frontend/src/app/bank/page.tsx new file mode 100644 index 0000000..055070a --- /dev/null +++ b/frontend/src/app/bank/page.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Coins, Loader2, Package, Search, Vault } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useBank } from "@/hooks/use-bank"; + +interface BankItem { + code: string; + quantity: number; + type?: string; + [key: string]: unknown; +} + +export default function BankPage() { + const { data, isLoading, error } = useBank(); + const [search, setSearch] = useState(""); + const [sortBy, setSortBy] = useState<"code" | "quantity">("code"); + + const bankDetails = data?.details as + | { gold?: number; slots?: number; max_slots?: number } + | undefined; + const bankItems = (data?.items ?? []) as BankItem[]; + + const filteredItems = useMemo(() => { + let items = [...bankItems]; + + if (search.trim()) { + const q = search.toLowerCase().trim(); + items = items.filter((item) => item.code.toLowerCase().includes(q)); + } + + items.sort((a, b) => { + if (sortBy === "quantity") return b.quantity - a.quantity; + return a.code.localeCompare(b.code); + }); + + return items; + }, [bankItems, search, sortBy]); + + return ( +
+
+

+ Bank +

+

+ View and manage your stored items and gold +

+
+ + {error && ( + +

+ Failed to load bank data. Make sure the backend is running. +

+
+ )} + + {isLoading && ( +
+ +
+ )} + + {data && ( + <> + {/* Summary cards */} +
+ + + + + Gold + + + +

+ {(bankDetails?.gold ?? 0).toLocaleString()} +

+
+
+ + + + + + Items + + + +

+ {bankItems.length} +

+

unique items

+
+
+ + + + + + Slots + + + +

+ {bankDetails?.slots ?? 0} + + {" "} + / {bankDetails?.max_slots ?? 0} + +

+
+
+
+ + +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {/* Items Grid */} + {filteredItems.length > 0 && ( +
+ {filteredItems.map((item) => ( + +
+

+ {item.code} +

+
+ + {item.quantity.toLocaleString()} + + {item.type && ( + + {item.type} + + )} +
+
+
+ ))} +
+ )} + + {/* Empty state */} + {filteredItems.length === 0 && !isLoading && ( + + +

+ {search.trim() + ? `No items matching "${search}"` + : "Your bank is empty. Deposit items to see them here."} +

+
+ )} + + )} +
+ ); +} diff --git a/frontend/src/app/characters/[name]/page.tsx b/frontend/src/app/characters/[name]/page.tsx new file mode 100644 index 0000000..4ef62ee --- /dev/null +++ b/frontend/src/app/characters/[name]/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { use, useState } from "react"; +import Link from "next/link"; +import { + ArrowLeft, + Loader2, + Move, + Swords, + Pickaxe, + BedDouble, +} from "lucide-react"; +import { useCharacter } from "@/hooks/use-characters"; +import { StatsPanel } from "@/components/character/stats-panel"; +import { EquipmentGrid } from "@/components/character/equipment-grid"; +import { InventoryGrid } from "@/components/character/inventory-grid"; +import { SkillBars } from "@/components/character/skill-bars"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { executeAction } from "@/lib/api-client"; +import { toast } from "sonner"; + +export default function CharacterPage({ + params, +}: { + params: Promise<{ name: string }>; +}) { + const { name } = use(params); + const decodedName = decodeURIComponent(name); + const { data: character, isLoading, error } = useCharacter(decodedName); + + const [moveX, setMoveX] = useState(""); + const [moveY, setMoveY] = useState(""); + const [actionPending, setActionPending] = useState(null); + + async function handleAction( + action: string, + params: Record = {} + ) { + setActionPending(action); + try { + await executeAction(decodedName, action, params); + toast.success(`Action "${action}" executed successfully`); + } catch (err) { + toast.error( + `Action "${action}" failed: ${err instanceof Error ? err.message : "Unknown error"}` + ); + } finally { + setActionPending(null); + } + } + + if (isLoading) { + return ( +
+
+ +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (error || !character) { + return ( +
+
+ +

Character

+
+ +

+ Failed to load character "{decodedName}". Make sure the + backend is running and the character exists. +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ {character.name} +

+

+ Level {character.level} · {character.skin} +

+
+
+ + {/* Top row: Stats + Equipment */} +
+ + +
+ + {/* Bottom row: Inventory + Skills */} +
+ + +
+ + {/* Manual Actions */} + + + Manual Actions + + +
+ {/* Move action with x,y inputs */} +
+
+ + setMoveX(e.target.value)} + placeholder={String(character.x)} + className="w-20 h-9" + /> +
+
+ + setMoveY(e.target.value)} + placeholder={String(character.y)} + className="w-20 h-9" + /> +
+ +
+ + {/* Fight */} + + + {/* Gather */} + + + {/* Rest */} + +
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..ddecf5e --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useDashboard } from "@/hooks/use-characters"; +import { CharacterCard } from "@/components/dashboard/character-card"; +import { Card } from "@/components/ui/card"; + +function CharacterCardSkeleton() { + return ( + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + ); +} + +export default function DashboardPage() { + const { data, isLoading, error } = useDashboard(); + + return ( +
+
+

+ Dashboard +

+

+ Overview of all characters and server status +

+
+ + {error && ( + +

+ Failed to load dashboard data. Make sure the backend is running. +

+
+ )} + +
+ {isLoading && + Array.from({ length: 3 }).map((_, i) => ( + + ))} + + {data?.characters.map((character) => ( + + ))} +
+ + {data && data.characters.length === 0 && !isLoading && ( + +

+ No characters found. Make sure the backend is connected to the + Artifacts API. +

+
+ )} +
+ ); +} diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx new file mode 100644 index 0000000..356ef76 --- /dev/null +++ b/frontend/src/app/error.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { AlertTriangle, RotateCcw } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+ + + +
+

Something went wrong

+

+ {error.message || "An unexpected error occurred."} +

+
+ +
+
+
+ ); +} diff --git a/frontend/src/app/events/page.tsx b/frontend/src/app/events/page.tsx new file mode 100644 index 0000000..c2dd084 --- /dev/null +++ b/frontend/src/app/events/page.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useMemo } from "react"; +import { + Zap, + Loader2, + MapPin, + Clock, + CalendarDays, + Sparkles, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useEvents, useEventHistory } from "@/hooks/use-events"; +import type { GameEvent } from "@/lib/types"; + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatRelativeTime(dateStr: string): string { + const now = Date.now(); + 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 = { + 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 getEventTypeStyle(type: string): string { + return EVENT_TYPE_COLORS[type] ?? "text-muted-foreground border-border"; +} + +function ActiveEventCard({ event }: { event: GameEvent }) { + const location = getEventLocation(event); + const expiry = getEventExpiry(event); + + return ( + + +
+
+ + + {event.type} + +
+ {expiry && ( +
+ + {formatRelativeTime(expiry)} +
+ )} +
+ +

{getEventDescription(event)}

+ +
+ {location && ( +
+ + {location} +
+ )} +
+ + {formatDate(event.created_at)} +
+
+
+
+ ); +} + +export default function EventsPage() { + const { data: activeEvents, isLoading: loadingActive, error } = useEvents(); + const { data: historicalEvents, isLoading: loadingHistory } = + useEventHistory(); + + const sortedHistory = useMemo(() => { + if (!historicalEvents) return []; + return [...historicalEvents].sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + }, [historicalEvents]); + + return ( +
+
+

+ Events +

+

+ View active game events and historical event data. Updates every 10 + seconds. +

+
+ + {error && ( + +

+ Failed to load events. Make sure the backend is running. +

+
+ )} + + {/* Active Events */} +
+

+ + Active Events +

+ + {loadingActive && ( +
+ +
+ )} + + {activeEvents && activeEvents.length > 0 && ( +
+ {activeEvents.map((event, idx) => ( + + ))} +
+ )} + + {activeEvents && activeEvents.length === 0 && !loadingActive && ( + + +

+ No active events right now. Check back later. +

+
+ )} +
+ + {/* Historical Events */} +
+

+ + Event History +

+ + {loadingHistory && ( +
+ +
+ )} + + {sortedHistory.length > 0 && ( + + + + + Type + Description + Location + Date + + + + {sortedHistory.map((event, idx) => ( + + + + {event.type} + + + + {getEventDescription(event)} + + + {getEventLocation(event) ?? "-"} + + + {formatDate(event.created_at)} + + + ))} + +
+
+ )} + + {sortedHistory.length === 0 && !loadingHistory && ( + +

+ No historical events recorded yet. +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/exchange/page.tsx b/frontend/src/app/exchange/page.tsx new file mode 100644 index 0000000..c7e4389 --- /dev/null +++ b/frontend/src/app/exchange/page.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + ArrowLeftRight, + Loader2, + Search, + ShoppingCart, + History, + TrendingUp, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + useExchangeOrders, + useExchangeHistory, + usePriceHistory, +} from "@/hooks/use-exchange"; +import { PriceChart } from "@/components/exchange/price-chart"; +import type { GEOrder } from "@/lib/types"; + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function OrdersTable({ + orders, + isLoading, + search, + emptyMessage, +}: { + orders: GEOrder[]; + isLoading: boolean; + search: string; + emptyMessage: string; +}) { + const filtered = useMemo(() => { + if (!search.trim()) return orders; + const q = search.toLowerCase().trim(); + return orders.filter((order) => order.code.toLowerCase().includes(q)); + }, [orders, search]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (filtered.length === 0) { + return ( +
+ +

{emptyMessage}

+
+ ); + } + + return ( + + + + Item + Type + Price + Quantity + Created + + + + {filtered.map((order) => ( + + {order.code} + + + {order.type.toUpperCase()} + + + + {order.price.toLocaleString()} + + + {order.quantity.toLocaleString()} + + + {formatDate(order.created_at)} + + + ))} + +
+ ); +} + +export default function ExchangePage() { + const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders(); + const { data: history, isLoading: loadingHistory } = useExchangeHistory(); + + const [marketSearch, setMarketSearch] = useState(""); + const [priceItemCode, setPriceItemCode] = useState(""); + const [searchedItem, setSearchedItem] = useState(""); + + const { + data: priceData, + isLoading: loadingPrices, + } = usePriceHistory(searchedItem); + + function handlePriceSearch() { + if (priceItemCode.trim()) { + setSearchedItem(priceItemCode.trim()); + } + } + + return ( +
+
+

+ Grand Exchange +

+

+ Browse market orders, track your trades, and analyze price history +

+
+ + {ordersError && ( + +

+ Failed to load exchange data. Make sure the backend is running. +

+
+ )} + + + + + + Market + + + + My Orders + + + + Price History + + + + {/* Market Tab */} + +
+ + setMarketSearch(e.target.value)} + className="pl-9" + /> +
+ + + + +
+ + {/* My Orders Tab */} + + + + + + + {/* Price History Tab */} + +
+ setPriceItemCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handlePriceSearch()} + /> + +
+ + {searchedItem && ( + + + + + Price History: {searchedItem} + + + + {loadingPrices ? ( +
+ +
+ ) : ( + + )} +
+
+ )} + + {!searchedItem && ( + + +

+ Enter an item code above to view price history and volume data. +

+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..382ca14 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,126 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..64319e4 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Providers } from "@/components/layout/providers"; +import { Sidebar } from "@/components/layout/sidebar"; +import { Header } from "@/components/layout/header"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: { + default: "Artifacts Dashboard", + template: "%s | Artifacts Dashboard", + }, + description: + "Dashboard & automation platform for Artifacts MMO — control, automate, and analyze your characters through a beautiful web interface.", + openGraph: { + title: "Artifacts Dashboard", + description: + "Dashboard & automation platform for Artifacts MMO — control, automate, and analyze your characters.", + type: "website", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
+ +
+
+
+ {children} +
+
+
+
+ + + ); +} diff --git a/frontend/src/app/loading.tsx b/frontend/src/app/loading.tsx new file mode 100644 index 0000000..d8070b2 --- /dev/null +++ b/frontend/src/app/loading.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from "lucide-react"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/logs/page.tsx b/frontend/src/app/logs/page.tsx new file mode 100644 index 0000000..74aa6f9 --- /dev/null +++ b/frontend/src/app/logs/page.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + ScrollText, + Loader2, + CheckCircle, + XCircle, + ChevronDown, +} 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 { useCharacters } from "@/hooks/use-characters"; +import { useLogs } from "@/hooks/use-analytics"; +import type { ActionLog } from "@/lib/types"; + +const ACTION_TYPE_COLORS: Record = { + move: "bg-blue-500/20 text-blue-400", + fight: "bg-red-500/20 text-red-400", + gather: "bg-green-500/20 text-green-400", + rest: "bg-yellow-500/20 text-yellow-400", + deposit: "bg-purple-500/20 text-purple-400", + withdraw: "bg-cyan-500/20 text-cyan-400", + craft: "bg-emerald-500/20 text-emerald-400", + buy: "bg-teal-500/20 text-teal-400", + sell: "bg-pink-500/20 text-pink-400", + equip: "bg-orange-500/20 text-orange-400", + unequip: "bg-orange-500/20 text-orange-400", + use: "bg-amber-500/20 text-amber-400", + task: "bg-indigo-500/20 text-indigo-400", +}; + +function getActionColor(type: string): string { + return ACTION_TYPE_COLORS[type] ?? "bg-muted text-muted-foreground"; +} + +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 getDetailsString(log: ActionLog): string { + const d = log.details; + if (!d || Object.keys(d).length === 0) return "-"; + + const parts: string[] = []; + if (d.reason && typeof d.reason === "string") return d.reason; + if (d.message && typeof d.message === "string") return d.message; + if (d.error && typeof d.error === "string") return d.error; + if (d.result && typeof d.result === "string") return d.result; + + if (d.monster) parts.push(`monster: ${d.monster}`); + if (d.resource) parts.push(`resource: ${d.resource}`); + if (d.item) parts.push(`item: ${d.item}`); + if (d.x !== undefined && d.y !== undefined) parts.push(`(${d.x}, ${d.y})`); + if (d.xp) parts.push(`xp: +${d.xp}`); + if (d.gold) parts.push(`gold: ${d.gold}`); + if (d.quantity) parts.push(`qty: ${d.quantity}`); + + return parts.length > 0 ? parts.join(" | ") : JSON.stringify(d); +} + +const ALL_ACTION_TYPES = [ + "move", + "fight", + "gather", + "rest", + "deposit", + "withdraw", + "craft", + "buy", + "sell", + "equip", + "unequip", + "use", + "task", +]; + +export default function LogsPage() { + const { data: characters } = useCharacters(); + const [characterFilter, setCharacterFilter] = useState("_all"); + const [actionFilter, setActionFilter] = useState("_all"); + const [visibleCount, setVisibleCount] = useState(50); + + const { data: logs, isLoading, error } = useLogs({ + character: characterFilter === "_all" ? undefined : characterFilter, + }); + + const filteredLogs = useMemo(() => { + let items = logs ?? []; + + if (actionFilter !== "_all") { + items = items.filter((log) => log.action_type === actionFilter); + } + + // 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 ( +
+
+

+ Action Logs +

+

+ View detailed action logs across all characters +

+
+ + {error && ( + +

+ Failed to load logs. Make sure the backend is running. +

+
+ )} + + {/* Filters */} +
+ + + + + {filteredLogs.length > 0 && ( +
+ Showing {visibleLogs.length} of {filteredLogs.length} entries +
+ )} +
+ + {isLoading && ( +
+ +
+ )} + + {/* Log Table */} + {visibleLogs.length > 0 && ( + + + + + Time + Character + Action + Details + Status + + + + {visibleLogs.map((log) => ( + + + {formatDate(log.created_at)} + + + {log.character_name ?? "-"} + + + + {log.action_type} + + + + {getDetailsString(log)} + + + {log.success ? ( + + ) : ( + + )} + + + ))} + +
+
+ )} + + {/* Load More */} + {hasMore && ( +
+ +
+ )} + + {/* Empty state */} + {filteredLogs.length === 0 && !isLoading && ( + + +

+ No log entries found. Actions performed by characters or automations + will appear here. +

+
+ )} +
+ ); +} diff --git a/frontend/src/app/map/page.tsx b/frontend/src/app/map/page.tsx new file mode 100644 index 0000000..c7cedef --- /dev/null +++ b/frontend/src/app/map/page.tsx @@ -0,0 +1,456 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Loader2, Plus, Minus, RotateCcw } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useMaps } from "@/hooks/use-game-data"; +import { useCharacters } from "@/hooks/use-characters"; +import type { MapTile, Character } from "@/lib/types"; + +const CONTENT_COLORS: Record = { + monster: "#ef4444", + monsters: "#ef4444", + resource: "#22c55e", + resources: "#22c55e", + bank: "#3b82f6", + grand_exchange: "#f59e0b", + workshop: "#a855f7", + npc: "#06b6d4", + tasks_master: "#ec4899", +}; + +const EMPTY_COLOR = "#1f2937"; +const CHARACTER_COLOR = "#facc15"; +const GRID_LINE_COLOR = "#374151"; + +const LEGEND_ITEMS = [ + { label: "Monsters", color: "#ef4444", key: "monster" }, + { label: "Resources", color: "#22c55e", key: "resource" }, + { label: "Bank", color: "#3b82f6", key: "bank" }, + { label: "Grand Exchange", color: "#f59e0b", key: "grand_exchange" }, + { label: "Workshop", color: "#a855f7", key: "workshop" }, + { label: "NPC", color: "#06b6d4", key: "npc" }, + { label: "Tasks Master", color: "#ec4899", key: "tasks_master" }, + { label: "Empty", color: "#1f2937", key: "empty" }, + { label: "Character", color: "#facc15", key: "character" }, +]; + +function getTileColor(tile: MapTile): string { + if (!tile.content?.type) return EMPTY_COLOR; + return CONTENT_COLORS[tile.content.type] ?? EMPTY_COLOR; +} + +interface SelectedTile { + tile: MapTile; + characters: Character[]; +} + +export default function MapPage() { + const { data: tiles, isLoading, error } = useMaps(); + const { data: characters } = useCharacters(); + + const canvasRef = useRef(null); + const containerRef = useRef(null); + + const [zoom, setZoom] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [selectedTile, setSelectedTile] = useState(null); + + const [filters, setFilters] = useState>({ + monster: true, + resource: true, + bank: true, + grand_exchange: true, + workshop: true, + npc: true, + tasks_master: true, + empty: true, + character: true, + }); + + // Compute grid bounds + const bounds = useMemo(() => { + if (!tiles || tiles.length === 0) return { minX: 0, maxX: 0, minY: 0, maxY: 0 }; + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + for (const tile of tiles) { + if (tile.x < minX) minX = tile.x; + if (tile.x > maxX) maxX = tile.x; + if (tile.y < minY) minY = tile.y; + if (tile.y > maxY) maxY = tile.y; + } + return { minX, maxX, minY, maxY }; + }, [tiles]); + + // Build tile lookup + const tileMap = useMemo(() => { + if (!tiles) return new Map(); + const map = new Map(); + for (const tile of tiles) { + map.set(`${tile.x},${tile.y}`, tile); + } + return map; + }, [tiles]); + + // Character positions + const charPositions = useMemo(() => { + if (!characters) return new Map(); + const map = new Map(); + for (const char of characters) { + const key = `${char.x},${char.y}`; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(char); + } + return map; + }, [characters]); + + const BASE_CELL_SIZE = 18; + const cellSize = BASE_CELL_SIZE * zoom; + + const drawMap = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container || !tiles || tiles.length === 0) return; + + const dpr = window.devicePixelRatio || 1; + const width = container.clientWidth; + const height = container.clientHeight; + + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.scale(dpr, dpr); + ctx.fillStyle = "#0f172a"; + ctx.fillRect(0, 0, width, height); + + const gridWidth = bounds.maxX - bounds.minX + 1; + const gridHeight = bounds.maxY - bounds.minY + 1; + + const centerOffsetX = (width - gridWidth * cellSize) / 2 + offset.x; + const centerOffsetY = (height - gridHeight * cellSize) / 2 + offset.y; + + // Draw tiles + for (const tile of tiles) { + const contentType = tile.content?.type ?? "empty"; + // Normalize plural to singular for filter check + const normalizedType = contentType === "monsters" ? "monster" : contentType === "resources" ? "resource" : contentType; + + if (!filters[normalizedType] && normalizedType !== "empty") continue; + if (normalizedType === "empty" && !filters.empty) continue; + + const px = (tile.x - bounds.minX) * cellSize + centerOffsetX; + const py = (tile.y - bounds.minY) * cellSize + centerOffsetY; + + // Skip tiles outside visible area + if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height) + continue; + + ctx.fillStyle = getTileColor(tile); + ctx.fillRect(px, py, cellSize - 1, cellSize - 1); + + // Grid lines when zoomed in + if (cellSize > 10) { + ctx.strokeStyle = GRID_LINE_COLOR; + ctx.lineWidth = 0.5; + ctx.strokeRect(px, py, cellSize - 1, cellSize - 1); + } + } + + // Draw character markers + if (filters.character && characters) { + for (const char of characters) { + const px = + (char.x - bounds.minX) * cellSize + centerOffsetX + cellSize / 2; + const py = + (char.y - bounds.minY) * cellSize + centerOffsetY + cellSize / 2; + + if (px < -20 || py < -20 || px > width + 20 || py > height + 20) + continue; + + const radius = Math.max(4, cellSize / 3); + + // Outer glow + ctx.beginPath(); + ctx.arc(px, py, radius + 2, 0, Math.PI * 2); + ctx.fillStyle = "rgba(250, 204, 21, 0.3)"; + ctx.fill(); + + // Inner dot + ctx.beginPath(); + ctx.arc(px, py, radius, 0, Math.PI * 2); + ctx.fillStyle = CHARACTER_COLOR; + ctx.fill(); + ctx.strokeStyle = "#000"; + ctx.lineWidth = 1; + ctx.stroke(); + + // Label + if (cellSize >= 14) { + ctx.fillStyle = "#fff"; + ctx.font = `${Math.max(9, cellSize * 0.5)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(char.name, px, py - radius - 3); + } + } + } + }, [tiles, characters, bounds, cellSize, offset, filters]); + + useEffect(() => { + drawMap(); + }, [drawMap]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver(() => drawMap()); + observer.observe(container); + return () => observer.disconnect(); + }, [drawMap]); + + function handleCanvasClick(e: React.MouseEvent) { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container || !tiles) return; + + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const gridWidth = bounds.maxX - bounds.minX + 1; + const gridHeight = bounds.maxY - bounds.minY + 1; + const centerOffsetX = (container.clientWidth - gridWidth * cellSize) / 2 + offset.x; + const centerOffsetY = (container.clientHeight - gridHeight * cellSize) / 2 + offset.y; + + const tileX = Math.floor((mx - centerOffsetX) / cellSize) + bounds.minX; + const tileY = Math.floor((my - centerOffsetY) / cellSize) + bounds.minY; + + const key = `${tileX},${tileY}`; + const tile = tileMap.get(key); + + if (tile) { + const charsOnTile = charPositions.get(key) ?? []; + setSelectedTile({ tile, characters: charsOnTile }); + } else { + setSelectedTile(null); + } + } + + function handleMouseDown(e: React.MouseEvent) { + if (e.button !== 0) return; + setDragging(true); + setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y }); + } + + function handleMouseMove(e: React.MouseEvent) { + if (!dragging) return; + setOffset({ + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y, + }); + } + + function handleMouseUp() { + setDragging(false); + } + + function handleWheel(e: React.WheelEvent) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + setZoom((z) => Math.max(0.3, Math.min(5, z + delta))); + } + + function resetView() { + setZoom(1); + setOffset({ x: 0, y: 0 }); + } + + function toggleFilter(key: string) { + setFilters((prev) => ({ ...prev, [key]: !prev[key] })); + } + + return ( +
+
+

+ World Map +

+

+ Interactive map of the game world. Click tiles for details, drag to + pan, scroll to zoom. +

+
+ + {error && ( + +

+ Failed to load map data. Make sure the backend is running. +

+
+ )} + + {/* Filters */} +
+ {LEGEND_ITEMS.map((item) => ( + + ))} +
+ + {/* Map container */} +
+
+ {isLoading && ( +
+ +
+ )} + + + + {/* Zoom controls */} +
+ + + +
+ + {/* Zoom level indicator */} +
+ {Math.round(zoom * 100)}% +
+
+ + {/* Side panel */} + {selectedTile && ( + +
+

+ {selectedTile.tile.name} +

+ +
+ +
+
+ + Position + +

+ ({selectedTile.tile.x}, {selectedTile.tile.y}) +

+
+ + {selectedTile.tile.content && ( +
+ + Content + +
+ + + {selectedTile.tile.content.type} + +
+

+ {selectedTile.tile.content.code} +

+
+ )} + + {!selectedTile.tile.content && ( +
+ + Content + +

Empty tile

+
+ )} + + {selectedTile.characters.length > 0 && ( +
+ + Characters + +
+ {selectedTile.characters.map((char) => ( + + {char.name} (Lv. {char.level}) + + ))} +
+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx new file mode 100644 index 0000000..7a4276f --- /dev/null +++ b/frontend/src/app/not-found.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { MapPinOff } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+ + + +
+

Page not found

+

+ The page you're looking for doesn't exist. +

+
+ +
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..a74cb27 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/dashboard"); +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..daa5402 --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Settings, Save, RotateCcw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; + +interface AppSettings { + apiUrl: string; + characterRefreshInterval: number; + automationRefreshInterval: number; + mapAutoRefresh: boolean; +} + +const DEFAULT_SETTINGS: AppSettings = { + apiUrl: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000", + characterRefreshInterval: 5, + automationRefreshInterval: 3, + mapAutoRefresh: true, +}; + +function loadSettings(): AppSettings { + if (typeof window === "undefined") return DEFAULT_SETTINGS; + try { + const saved = localStorage.getItem("artifacts-settings"); + if (saved) return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }; + } catch {} + return DEFAULT_SETTINGS; +} + +export default function SettingsPage() { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + + useEffect(() => { + setSettings(loadSettings()); + }, []); + + function handleSave() { + try { + localStorage.setItem("artifacts-settings", JSON.stringify(settings)); + toast.success("Settings saved"); + } catch { + toast.error("Failed to save settings"); + } + } + + function handleReset() { + setSettings(DEFAULT_SETTINGS); + localStorage.removeItem("artifacts-settings"); + toast.success("Settings reset to defaults"); + } + + return ( +
+
+

Settings

+

+ Configure dashboard preferences +

+
+ + + + + + Connection + + + +
+ + + setSettings((s) => ({ ...s, apiUrl: e.target.value })) + } + placeholder="http://localhost:8000" + /> +

+ The URL of the backend API server. Requires page reload to take + effect. +

+
+
+
+ + + + + + Refresh Intervals + + + +
+ + + setSettings((s) => ({ + ...s, + characterRefreshInterval: parseInt(e.target.value, 10) || 5, + })) + } + /> +
+ +
+ + + setSettings((s) => ({ + ...s, + automationRefreshInterval: parseInt(e.target.value, 10) || 3, + })) + } + /> +
+
+
+ + + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/automation/config-form.tsx b/frontend/src/components/automation/config-form.tsx new file mode 100644 index 0000000..503cb81 --- /dev/null +++ b/frontend/src/components/automation/config-form.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SKILLS } from "@/lib/constants"; +import type { + CombatConfig, + GatheringConfig, + CraftingConfig, + TradingConfig, + TaskConfig, + LevelingConfig, +} from "@/lib/types"; + +// ---------- Combat ---------- + +interface CombatConfigFormProps { + value: CombatConfig; + onChange: (config: CombatConfig) => void; +} + +export function CombatConfigForm({ value, onChange }: CombatConfigFormProps) { + function update(partial: Partial) { + onChange({ ...value, ...partial }); + } + + return ( +
+
+ + update({ monster_code: e.target.value })} + /> +

+ The code of the monster to fight. Check the game data for valid codes. +

+
+ +
+
+ + + {value.auto_heal_threshold}% + +
+ update({ auto_heal_threshold: v })} + /> +

+ Heal when HP drops below this percentage. +

+
+ +
+ + +
+ + {value.heal_method === "consumable" && ( +
+ + update({ consumable_code: e.target.value })} + /> +

+ The item code of the healing consumable to use. +

+
+ )} + +
+
+ + + {value.min_inventory_slots} + +
+ update({ min_inventory_slots: v })} + /> +

+ Go to bank when fewer than this many slots are free. +

+
+ +
+
+ +

+ Automatically deposit loot at the bank when inventory is full. +

+
+ update({ deposit_loot: checked })} + /> +
+
+ ); +} + +// ---------- Gathering ---------- + +interface GatheringConfigFormProps { + value: GatheringConfig; + onChange: (config: GatheringConfig) => void; +} + +export function GatheringConfigForm({ + value, + onChange, +}: GatheringConfigFormProps) { + function update(partial: Partial) { + onChange({ ...value, ...partial }); + } + + return ( +
+
+ + update({ resource_code: e.target.value })} + /> +

+ The code of the resource to gather. Check the game data for valid + codes. +

+
+ +
+
+ +

+ Automatically deposit gathered items at the bank when inventory is + full. +

+
+ update({ deposit_on_full: checked })} + /> +
+ +
+ + + update({ max_loops: parseInt(e.target.value, 10) || 0 }) + } + /> +

+ Maximum number of gathering loops. Set to 0 for infinite. +

+
+
+ ); +} + +// ---------- Crafting ---------- + +interface CraftingConfigFormProps { + value: CraftingConfig; + onChange: (config: CraftingConfig) => void; +} + +export function CraftingConfigForm({ + value, + onChange, +}: CraftingConfigFormProps) { + function update(partial: Partial) { + onChange({ ...value, ...partial }); + } + + return ( +
+
+ + update({ item_code: e.target.value })} + /> +

+ The code of the item to craft. Check the game data for valid codes. +

+
+ +
+ + + update({ quantity: parseInt(e.target.value, 10) || 1 }) + } + /> +

+ Number of items to craft. Set to 0 for infinite. +

+
+ +
+
+ +

+ Automatically gather required materials before crafting. +

+
+ + update({ gather_materials: checked }) + } + /> +
+ +
+
+ +

+ Recycle excess crafted items to recover some materials. +

+
+ update({ recycle_excess: checked })} + /> +
+
+ ); +} + +// ---------- Trading ---------- + +interface TradingConfigFormProps { + value: TradingConfig; + onChange: (config: TradingConfig) => void; +} + +export function TradingConfigForm({ + value, + onChange, +}: TradingConfigFormProps) { + function update(partial: Partial) { + onChange({ ...value, ...partial }); + } + + return ( +
+
+ + +

+ Choose how the trading automation should operate. +

+
+ +
+ + update({ item_code: e.target.value })} + /> +

+ The item to trade. Leave empty to trade all applicable items. +

+
+ +
+
+ + + update({ min_price: parseInt(e.target.value, 10) || 0 }) + } + /> +
+
+ + + update({ max_price: parseInt(e.target.value, 10) || 0 }) + } + /> +
+
+

+ Price boundaries for buying/selling. Set to 0 to use market prices. +

+ +
+ + + update({ quantity: parseInt(e.target.value, 10) || 0 }) + } + /> +

+ Maximum quantity to trade. Set to 0 for unlimited. +

+
+
+ ); +} + +// ---------- Task ---------- + +interface TaskConfigFormProps { + value: TaskConfig; + onChange: (config: TaskConfig) => void; +} + +export function TaskConfigForm({ value: _value, onChange: _onChange }: TaskConfigFormProps) { + return ( +
+
+

+ Task automation will auto-accept and complete tasks from the tasks + master. No additional configuration is needed. The automation will + handle moving to the tasks master, accepting tasks, completing them, + and turning them in. +

+
+
+ ); +} + +// ---------- Leveling ---------- + +interface LevelingConfigFormProps { + value: LevelingConfig; + onChange: (config: LevelingConfig) => void; +} + +export function LevelingConfigForm({ + value, + onChange, +}: LevelingConfigFormProps) { + function update(partial: Partial) { + onChange({ ...value, ...partial }); + } + + return ( +
+
+ + +

+ Optionally focus on a specific skill. If left to auto-detect, the + automation will choose the most efficient skill to level. +

+
+
+ ); +} + +// ---------- Unified Config Form ---------- + +type StrategyType = + | "combat" + | "gathering" + | "crafting" + | "trading" + | "task" + | "leveling"; + +interface ConfigFormProps { + strategyType: StrategyType; + config: Record; + onChange: (config: Record) => void; +} + +const DEFAULT_COMBAT_CONFIG: CombatConfig = { + monster_code: "", + auto_heal_threshold: 50, + heal_method: "rest", + min_inventory_slots: 3, + deposit_loot: true, +}; + +const DEFAULT_GATHERING_CONFIG: GatheringConfig = { + resource_code: "", + deposit_on_full: true, + max_loops: 0, +}; + +const DEFAULT_CRAFTING_CONFIG: CraftingConfig = { + item_code: "", + quantity: 1, + gather_materials: true, + recycle_excess: false, +}; + +const DEFAULT_TRADING_CONFIG: TradingConfig = { + mode: "sell_loot", + item_code: "", + min_price: 0, + max_price: 0, + quantity: 0, +}; + +const DEFAULT_TASK_CONFIG: TaskConfig = {}; + +const DEFAULT_LEVELING_CONFIG: LevelingConfig = {}; + +export function ConfigForm({ strategyType, config, onChange }: ConfigFormProps) { + const cast = (c: unknown) => onChange(c as Record); + + switch (strategyType) { + case "combat": { + const merged: CombatConfig = { + ...DEFAULT_COMBAT_CONFIG, + ...(config as Partial), + }; + return cast(c)} />; + } + case "gathering": { + const merged: GatheringConfig = { + ...DEFAULT_GATHERING_CONFIG, + ...(config as Partial), + }; + return cast(c)} />; + } + case "crafting": { + const merged: CraftingConfig = { + ...DEFAULT_CRAFTING_CONFIG, + ...(config as Partial), + }; + return cast(c)} />; + } + case "trading": { + const merged: TradingConfig = { + ...DEFAULT_TRADING_CONFIG, + ...(config as Partial), + }; + return cast(c)} />; + } + case "task": { + const merged: TaskConfig = { + ...DEFAULT_TASK_CONFIG, + ...(config as Partial), + }; + return cast(c)} />; + } + case "leveling": { + const merged: LevelingConfig = { + ...DEFAULT_LEVELING_CONFIG, + ...(config as Partial), + }; + return cast(c)} />; + } + default: + return null; + } +} + +export { + DEFAULT_COMBAT_CONFIG, + DEFAULT_GATHERING_CONFIG, + DEFAULT_CRAFTING_CONFIG, + DEFAULT_TRADING_CONFIG, + DEFAULT_TASK_CONFIG, + DEFAULT_LEVELING_CONFIG, +}; diff --git a/frontend/src/components/automation/log-stream.tsx b/frontend/src/components/automation/log-stream.tsx new file mode 100644 index 0000000..9f401e7 --- /dev/null +++ b/frontend/src/components/automation/log-stream.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { CheckCircle, XCircle } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import type { AutomationLog } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +const ACTION_TYPE_COLORS: Record = { + move: "bg-blue-500/20 text-blue-400", + fight: "bg-red-500/20 text-red-400", + gather: "bg-green-500/20 text-green-400", + rest: "bg-yellow-500/20 text-yellow-400", + deposit: "bg-purple-500/20 text-purple-400", + withdraw: "bg-cyan-500/20 text-cyan-400", + equip: "bg-orange-500/20 text-orange-400", + unequip: "bg-orange-500/20 text-orange-400", + craft: "bg-emerald-500/20 text-emerald-400", + use: "bg-amber-500/20 text-amber-400", + buy: "bg-teal-500/20 text-teal-400", + sell: "bg-pink-500/20 text-pink-400", +}; + +function getActionTypeColor(actionType: string): string { + return ACTION_TYPE_COLORS[actionType] ?? "bg-muted text-muted-foreground"; +} + +function formatRelativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffS = Math.floor(diffMs / 1000); + + if (diffS < 5) return "just now"; + if (diffS < 60) return `${diffS}s ago`; + const diffM = Math.floor(diffS / 60); + if (diffM < 60) return `${diffM}m ago`; + const diffH = Math.floor(diffM / 60); + if (diffH < 24) return `${diffH}h ago`; + return `${Math.floor(diffH / 24)}d ago`; +} + +function getLogReason(log: AutomationLog): string { + const details = log.details; + if (details.reason && typeof details.reason === "string") return details.reason; + if (details.message && typeof details.message === "string") + return details.message; + if (details.error && typeof details.error === "string") return details.error; + if (details.result && typeof details.result === "string") + return details.result; + + // Build a summary from known fields + const parts: string[] = []; + if (details.monster) parts.push(`monster: ${details.monster}`); + if (details.resource) parts.push(`resource: ${details.resource}`); + if (details.item) parts.push(`item: ${details.item}`); + if (details.x !== undefined && details.y !== undefined) + parts.push(`(${details.x}, ${details.y})`); + if (details.hp) parts.push(`hp: ${details.hp}`); + if (details.gold) parts.push(`gold: ${details.gold}`); + if (details.xp) parts.push(`xp: +${details.xp}`); + if (details.quantity) parts.push(`qty: ${details.quantity}`); + + return parts.length > 0 ? parts.join(" | ") : JSON.stringify(details); +} + +interface LogStreamProps { + logs: AutomationLog[]; + maxHeight?: string; +} + +export function LogStream({ logs, maxHeight = "400px" }: LogStreamProps) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [logs.length]); + + if (logs.length === 0) { + return ( +
+ No log entries yet. Start the automation to see activity. +
+ ); + } + + return ( + +
+ {logs.map((log) => ( +
+ + {formatRelativeTime(log.created_at)} + + + + {log.action_type} + + + {log.success ? ( + + ) : ( + + )} + + + {getLogReason(log)} + +
+ ))} +
+
+ + ); +} diff --git a/frontend/src/components/automation/run-controls.tsx b/frontend/src/components/automation/run-controls.tsx new file mode 100644 index 0000000..93e90ae --- /dev/null +++ b/frontend/src/components/automation/run-controls.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Play, Square, Pause, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useControlAutomation } from "@/hooks/use-automations"; +import { toast } from "sonner"; + +interface RunControlsProps { + automationId: number; + status: string; + actionsCount: number; +} + +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: "", + }, + error: { + label: "Error", + variant: "destructive", + className: "", + }, +}; + +export function RunControls({ + automationId, + status, + actionsCount, +}: RunControlsProps) { + const control = useControlAutomation(); + const statusConfig = STATUS_CONFIG[status] ?? STATUS_CONFIG.stopped; + + function handleControl(action: "start" | "stop" | "pause" | "resume") { + control.mutate( + { id: automationId, action }, + { + onSuccess: () => { + toast.success( + `Automation ${action === "start" ? "started" : action === "stop" ? "stopped" : action === "pause" ? "paused" : "resumed"} successfully` + ); + }, + onError: (error) => { + toast.error(`Failed to ${action} automation: ${error.message}`); + }, + } + ); + } + + const isStopped = status === "stopped" || status === "error" || !status; + const isRunning = status === "running"; + const isPaused = status === "paused"; + + return ( +
+ + {control.isPending && ( + + )} + {statusConfig.label} + + + + {actionsCount.toLocaleString()} actions + + +
+ {isStopped && ( + + )} + + {isRunning && ( + <> + + + + )} + + {isPaused && ( + <> + + + + )} +
+
+ ); +} diff --git a/frontend/src/components/automation/strategy-selector.tsx b/frontend/src/components/automation/strategy-selector.tsx new file mode 100644 index 0000000..57d7144 --- /dev/null +++ b/frontend/src/components/automation/strategy-selector.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Swords, Pickaxe, Hammer, TrendingUp, ClipboardList, GraduationCap } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +const STRATEGIES = [ + { + type: "combat" as const, + label: "Combat", + description: + "Fight monsters automatically. Configure target, healing, and loot management.", + icon: Swords, + color: "text-red-400", + borderColor: "border-red-500/50", + bgColor: "bg-red-500/10", + }, + { + type: "gathering" as const, + label: "Gathering", + description: + "Gather resources automatically. Configure resource type and inventory management.", + icon: Pickaxe, + color: "text-green-400", + borderColor: "border-green-500/50", + bgColor: "bg-green-500/10", + }, + { + type: "crafting" as const, + label: "Crafting", + description: + "Craft items automatically. Configure item, quantity, and material management.", + icon: Hammer, + color: "text-blue-400", + borderColor: "border-blue-500/50", + bgColor: "bg-blue-500/10", + }, + { + type: "trading" as const, + label: "Trading", + description: + "Trade on the Grand Exchange. Sell loot, buy materials, or flip items for profit.", + icon: TrendingUp, + color: "text-yellow-400", + borderColor: "border-yellow-500/50", + bgColor: "bg-yellow-500/10", + }, + { + type: "task" as const, + label: "Task", + description: + "Complete tasks automatically. Auto-accept and complete available tasks.", + icon: ClipboardList, + color: "text-purple-400", + borderColor: "border-purple-500/50", + bgColor: "bg-purple-500/10", + }, + { + type: "leveling" as const, + label: "Leveling", + description: + "Level up skills efficiently. Optionally focus on a specific skill to train.", + icon: GraduationCap, + color: "text-cyan-400", + borderColor: "border-cyan-500/50", + bgColor: "bg-cyan-500/10", + }, +] as const; + +type StrategyType = (typeof STRATEGIES)[number]["type"]; + +interface StrategySelectorProps { + value: string | null; + onChange: (strategy: StrategyType) => void; +} + +export function StrategySelector({ value, onChange }: StrategySelectorProps) { + return ( +
+ {STRATEGIES.map((strategy) => { + const Icon = strategy.icon; + const isSelected = value === strategy.type; + + return ( + onChange(strategy.type)} + > + +
+ +
+
+

+ {strategy.label} +

+

+ {strategy.description} +

+
+
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/character/equipment-grid.tsx b/frontend/src/components/character/equipment-grid.tsx new file mode 100644 index 0000000..be31b05 --- /dev/null +++ b/frontend/src/components/character/equipment-grid.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { EQUIPMENT_SLOTS } from "@/lib/constants"; +import type { Character } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface EquipmentGridProps { + character: Character; +} + +export function EquipmentGrid({ character }: EquipmentGridProps) { + return ( + + + Equipment + + +
+ {EQUIPMENT_SLOTS.map((slot) => { + const itemCode = character[slot.key as keyof Character] as string; + const isEmpty = !itemCode; + + // Show quantity for utility slots + let quantity: number | null = null; + if (slot.key === "utility1_slot" && itemCode) { + quantity = character.utility1_slot_quantity; + } else if (slot.key === "utility2_slot" && itemCode) { + quantity = character.utility2_slot_quantity; + } + + return ( +
+ + {slot.label} + + {isEmpty ? ( + + Empty + + ) : ( +
+ + {itemCode} + + {quantity !== null && quantity > 0 && ( + + x{quantity} + + )} +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/character/inventory-grid.tsx b/frontend/src/components/character/inventory-grid.tsx new file mode 100644 index 0000000..a02f3ea --- /dev/null +++ b/frontend/src/components/character/inventory-grid.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Character } from "@/lib/types"; + +interface InventoryGridProps { + character: Character; +} + +export function InventoryGrid({ character }: InventoryGridProps) { + const usedSlots = character.inventory.filter( + (slot) => slot.code && slot.code !== "" + ).length; + const totalSlots = character.inventory_max_items; + + // Build a map from slot number to inventory item + const slotMap = new Map( + character.inventory + .filter((slot) => slot.code && slot.code !== "") + .map((slot) => [slot.slot, slot]) + ); + + return ( + + +
+ Inventory + + {usedSlots}/{totalSlots} + +
+ + {totalSlots - usedSlots} slots available + +
+ +
+ {Array.from({ length: totalSlots }).map((_, index) => { + const item = slotMap.get(index + 1); + const isEmpty = !item; + + return ( +
+ {item && ( + <> + + {item.code} + + {item.quantity > 1 && ( + + x{item.quantity} + + )} + + )} +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/character/skill-bars.tsx b/frontend/src/components/character/skill-bars.tsx new file mode 100644 index 0000000..640c53f --- /dev/null +++ b/frontend/src/components/character/skill-bars.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { SKILLS, SKILL_COLOR_MAP, SKILL_COLOR_TEXT_MAP } from "@/lib/constants"; +import type { Character } from "@/lib/types"; + +interface SkillBarsProps { + character: Character; +} + +export function SkillBars({ character }: SkillBarsProps) { + const skillData = SKILLS.map((skill) => ({ + ...skill, + level: character[`${skill.key}_level` as keyof Character] as number, + xp: character[`${skill.key}_xp` as keyof Character] as number, + })).sort((a, b) => b.level - a.level); + + return ( + + + Skills + + + {skillData.map((skill) => ( +
+
+ + {skill.label} + + + Lv. {skill.level} + +
+
+
+
0 ? Math.max(2, (skill.xp % 100)) : 0)}%`, + }} + /> +
+ + {skill.xp.toLocaleString()} XP + +
+
+ ))} + + + ); +} diff --git a/frontend/src/components/character/stats-panel.tsx b/frontend/src/components/character/stats-panel.tsx new file mode 100644 index 0000000..9bdaef9 --- /dev/null +++ b/frontend/src/components/character/stats-panel.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { + Heart, + Zap, + MapPin, + Coins, + Gauge, + Timer, + Crosshair, + Shield, +} from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { ELEMENTS } from "@/lib/constants"; +import type { Character } from "@/lib/types"; + +interface StatsPanelProps { + character: Character; +} + +function StatRow({ + label, + value, + icon: Icon, + className, +}: { + label: string; + value: string | number; + icon?: React.ComponentType<{ className?: string }>; + className?: string; +}) { + return ( +
+
+ {Icon && } + {label} +
+ {value} +
+ ); +} + +export function StatsPanel({ character }: StatsPanelProps) { + const xpPercent = + character.max_xp > 0 + ? Math.round((character.xp / character.max_xp) * 100) + : 0; + const hpPercent = + character.max_hp > 0 + ? Math.round((character.hp / character.max_hp) * 100) + : 0; + + return ( + + + Character Stats + + + {/* Level + XP */} +
+
+ + Level {character.level} + + + {character.xp.toLocaleString()} / {character.max_xp.toLocaleString()} XP ({xpPercent}%) + +
+
+
+
+
+ + {/* HP */} +
+
+
+ + HP +
+ + {character.hp} / {character.max_hp} + +
+
+
+
+
+ + + + {/* Core stats */} +
+ + + + + + +
+ + + + {/* Elemental stats */} +
+

+ Elemental Stats +

+ + {/* Header */} +
+ Element + Attack + Damage + Resist +
+ + {ELEMENTS.map((element) => { + const attack = + character[`attack_${element.key}` as keyof Character] as number; + const dmg = + character[`dmg_${element.key}` as keyof Character] as number; + const res = + character[`res_${element.key}` as keyof Character] as number; + + return ( +
+ + {element.label} + + {attack} + + {dmg > 0 ? `+${dmg}` : dmg} + + {res} +
+ ); + })} +
+ + + ); +} diff --git a/frontend/src/components/dashboard/character-card.tsx b/frontend/src/components/dashboard/character-card.tsx new file mode 100644 index 0000000..b398bef --- /dev/null +++ b/frontend/src/components/dashboard/character-card.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { MapPin, Coins, Swords, Shield, Clock, Target, Bot, Pickaxe } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import type { Character, AutomationStatus } from "@/lib/types"; +import { SKILLS, SKILL_COLOR_TEXT_MAP } from "@/lib/constants"; +import { cn } from "@/lib/utils"; + +interface CharacterCardProps { + character: Character; + automationStatus?: AutomationStatus; +} + +function CooldownTimer({ expiration }: { expiration: string | null }) { + const [remaining, setRemaining] = useState(0); + + useEffect(() => { + if (!expiration) { + setRemaining(0); + return; + } + + function calculateRemaining() { + const now = Date.now(); + const expiresAt = new Date(expiration!).getTime(); + const diff = Math.max(0, Math.ceil((expiresAt - now) / 1000)); + setRemaining(diff); + } + + calculateRemaining(); + const interval = setInterval(calculateRemaining, 1000); + return () => clearInterval(interval); + }, [expiration]); + + if (remaining <= 0) return null; + + return ( +
+ + {remaining}s +
+ ); +} + +const AUTOMATION_STATUS_STYLES: Record = { + running: { dot: "bg-green-500", text: "text-green-400" }, + paused: { dot: "bg-yellow-500", text: "text-yellow-400" }, + stopped: { dot: "bg-gray-500", text: "text-gray-400" }, + error: { dot: "bg-red-500", text: "text-red-400" }, +}; + +const AUTOMATION_STRATEGY_ICONS: Record = { + combat: , + gathering: , +}; + +export function CharacterCard({ character, automationStatus }: CharacterCardProps) { + const router = useRouter(); + const xpPercent = + character.max_xp > 0 + ? Math.round((character.xp / character.max_xp) * 100) + : 0; + const hpPercent = + character.max_hp > 0 + ? Math.round((character.hp / character.max_hp) * 100) + : 0; + + const totalAttack = + character.attack_fire + + character.attack_earth + + character.attack_water + + character.attack_air; + const totalDmg = + character.dmg_fire + + character.dmg_earth + + character.dmg_water + + character.dmg_air; + + // Get top 3 skills sorted by level + const skillEntries = SKILLS.map((skill) => ({ + ...skill, + level: character[`${skill.key}_level` as keyof Character] as number, + xp: character[`${skill.key}_xp` as keyof Character] as number, + })) + .sort((a, b) => b.level - a.level) + .slice(0, 3); + + return ( + router.push(`/characters/${character.name}`)} + > + +
+ {character.name} +
+ {automationStatus && + automationStatus.status !== "stopped" && ( + + + {AUTOMATION_STRATEGY_ICONS[automationStatus.strategy_type] ?? ( + + )} + + {automationStatus.strategy_type} + + + )} + + Lv. {character.level} + +
+
+
+ + + {/* HP Bar */} +
+
+ HP + + {character.hp}/{character.max_hp} + +
+
+
+
+
+ + {/* XP Bar */} +
+
+ XP + {xpPercent}% +
+
+
+
+
+ + {/* Position + Gold row */} +
+
+ + + ({character.x}, {character.y}) + +
+
+ + {character.gold.toLocaleString()} +
+
+ + {/* Combat Stats */} +
+
+ + ATK {totalAttack} +
+
+ + DMG +{totalDmg} +
+
+ + {/* Top Skills */} +
+ {skillEntries.map((skill) => ( + + + {skill.label} + + + {skill.level} + + + ))} +
+ + {/* Cooldown */} + + + {/* Task Progress */} + {character.task && ( +
+
+ + + {character.task_type}: {character.task} + +
+
+ 0 + ? (character.task_progress / character.task_total) * 100 + : 0 + } + className="h-1.5 flex-1" + /> + + {character.task_progress}/{character.task_total} + +
+
+ )} + + {/* Equipment Summary */} + {(character.weapon_slot || character.shield_slot) && ( +
+ {character.weapon_slot && ( + + {character.weapon_slot} + + )} + {character.shield_slot && ( + + {character.shield_slot} + + )} +
+ )} + + + ); +} diff --git a/frontend/src/components/exchange/price-chart.tsx b/frontend/src/components/exchange/price-chart.tsx new file mode 100644 index 0000000..cbf8ede --- /dev/null +++ b/frontend/src/components/exchange/price-chart.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { + ComposedChart, + Line, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + Legend, +} from "recharts"; +import type { PricePoint } from "@/lib/types"; +import { Card } from "@/components/ui/card"; + +interface PriceChartProps { + data: PricePoint[]; +} + +function formatTime(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +interface TooltipPayloadItem { + name: string; + value: number; + color: string; +} + +function CustomTooltip({ + active, + payload, + label, +}: { + active?: boolean; + payload?: TooltipPayloadItem[]; + label?: string; +}) { + if (!active || !payload?.length || !label) return null; + + return ( + +

{formatDate(label)}

+ {payload.map((entry: TooltipPayloadItem) => ( +
+ + {entry.name}: + + {entry.value.toLocaleString()} + +
+ ))} +
+ ); +} + +export function PriceChart({ data }: PriceChartProps) { + if (data.length === 0) { + return ( +
+ No price data available for this item. +
+ ); + } + + const chartData = data.map((point) => ({ + ...point, + time: point.captured_at, + })); + + return ( + + + + + + + } /> + + + + + + + ); +} diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx new file mode 100644 index 0000000..0aa2a08 --- /dev/null +++ b/frontend/src/components/layout/header.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Menu, + X, + Swords, + LayoutDashboard, + Bot, + Map, + Landmark, + ArrowLeftRight, + Zap, + ScrollText, + BarChart3, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { NAV_ITEMS } from "@/lib/constants"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { useConnectionStatus } from "@/components/layout/providers"; + +const ICON_MAP: Record> = { + LayoutDashboard, + Bot, + Map, + Landmark, + ArrowLeftRight, + Zap, + ScrollText, + BarChart3, +}; + +const STATUS_CONFIG = { + connected: { + dotColor: "bg-green-500", + pingColor: "bg-green-400", + label: "Connected", + animate: false, + }, + connecting: { + dotColor: "bg-yellow-500", + pingColor: "bg-yellow-400", + label: "Connecting...", + animate: true, + }, + disconnected: { + dotColor: "bg-red-500", + pingColor: "bg-red-400", + label: "Disconnected", + animate: false, + }, +} as const; + +export function Header() { + const pathname = usePathname(); + const [mobileOpen, setMobileOpen] = useState(false); + const connectionStatus = useConnectionStatus(); + const config = STATUS_CONFIG[connectionStatus]; + + return ( +
+ {/* Mobile menu */} + + + + + + + + + Artifacts + + + + + + + {/* Mobile title */} +
+ + Artifacts +
+ + {/* Spacer */} +
+ + {/* Connection status */} +
+ + {config.animate && ( + + )} + {connectionStatus === "connected" && ( + + )} + + + {config.label} +
+
+ ); +} diff --git a/frontend/src/components/layout/providers.tsx b/frontend/src/components/layout/providers.tsx new file mode 100644 index 0000000..024980c --- /dev/null +++ b/frontend/src/components/layout/providers.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "sonner"; +import { + useWebSocket, + type ConnectionStatus, +} from "@/hooks/use-websocket"; + +interface WebSocketContextValue { + status: ConnectionStatus; + lastMessage: unknown | null; +} + +const WebSocketContext = createContext({ + status: "disconnected", + lastMessage: null, +}); + +function WebSocketProvider({ children }: { children: React.ReactNode }) { + const { status, lastMessage } = useWebSocket(); + + return ( + + {children} + + ); +} + +export function useConnectionStatus(): ConnectionStatus { + const context = useContext(WebSocketContext); + return context.status; +} + +export function useLastWebSocketMessage(): unknown | null { + const context = useContext(WebSocketContext); + return context.lastMessage; +} + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }) + ); + + return ( + + + {children} + + + + ); +} diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..1582532 --- /dev/null +++ b/frontend/src/components/layout/sidebar.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; +import { + LayoutDashboard, + Bot, + Map, + Landmark, + ArrowLeftRight, + Zap, + ScrollText, + BarChart3, + Settings, + ChevronLeft, + ChevronRight, + Swords, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { NAV_ITEMS } from "@/lib/constants"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const ICON_MAP: Record> = { + LayoutDashboard, + Bot, + Map, + Landmark, + ArrowLeftRight, + Zap, + ScrollText, + BarChart3, + Settings, +}; + +export function Sidebar() { + const pathname = usePathname(); + const [collapsed, setCollapsed] = useState(false); + + return ( + + + + ); +} diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..1ac1570 --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..beb56ed --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..b5ea4ab --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..80d7ad6 --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bffc327 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..8916905 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..1ac80f7 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import { Label as LabelPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..bca13fe --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0f873dc --- /dev/null +++ b/frontend/src/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..fd01b74 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,190 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..4c24b2a --- /dev/null +++ b/frontend/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx new file mode 100644 index 0000000..5963090 --- /dev/null +++ b/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as SheetPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx new file mode 100644 index 0000000..4b815ae --- /dev/null +++ b/frontend/src/components/ui/slider.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" +import { Slider as SliderPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..80dd45b --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import { Switch as SwitchPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..51b74dd --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..7f73dcd --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,91 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000..7f21b5e --- /dev/null +++ b/frontend/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +