From 2537f8e754d726f2060836496c52a395f1011d41 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 12:33:09 +0000 Subject: [PATCH 01/34] auto-commit for 3666d9c5-c807-48ca-89a1-51ec1dba100f --- repo_clone/BTForceManager | 1 + 1 file changed, 1 insertion(+) create mode 160000 repo_clone/BTForceManager diff --git a/repo_clone/BTForceManager b/repo_clone/BTForceManager new file mode 160000 index 0000000..ada2066 --- /dev/null +++ b/repo_clone/BTForceManager @@ -0,0 +1 @@ +Subproject commit ada20666ff145ba56a5738fa517275ce3cd31071 From 0d584b5629a46fee611abfe9541aeea90c973b17 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 12:40:23 +0000 Subject: [PATCH 02/34] auto-commit for 7f0b9d0d-f09b-442b-a769-42283ce6b302 --- .gitignore | 12 ++ backend/Dockerfile | 14 ++ backend/alembic.ini | 149 ++++++++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 86 ++++++++++ backend/alembic/script.py.mako | 28 ++++ .../alembic/versions/e3c21f33f8fc_baseline.py | 28 ++++ backend/database.py | 17 ++ backend/requirements.txt | 133 ++++++++++++++++ backend/server.py | 46 ++++++ backend/tests/test_health.py | 52 ++++++ docker-compose.yml | 13 ++ repo_clone/BTForceManager | 1 - test_reports/iteration_1.json | 32 ++++ test_reports/pytest/pytest_results.xml | 1 + 15 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/e3c21f33f8fc_baseline.py create mode 100644 backend/database.py create mode 100644 backend/requirements.txt create mode 100644 backend/server.py create mode 100644 backend/tests/test_health.py create mode 100644 docker-compose.yml delete mode 160000 repo_clone/BTForceManager create mode 100644 test_reports/iteration_1.json create mode 100644 test_reports/pytest/pytest_results.xml diff --git a/.gitignore b/.gitignore index dc92321..d8210fb 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,15 @@ credentials.json *.pem *.key .credentials + +# Backend (Python / SQLite) +__pycache__/ +*.pyc +backend/data/ +*.db +*.db-journal +*.sqlite3 + +# Keep committed env templates +!backend/.env.example +!backend/.env.docker.example diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fbb733f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /data + +EXPOSE 8000 + +CMD ["sh", "-c", "alembic upgrade head && uvicorn server:app --host 0.0.0.0 --port 8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..16923b4 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///./data/btforcemanager.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..38362e5 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,86 @@ +import os +from logging.config import fileConfig + +from dotenv import load_dotenv +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +load_dotenv() + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# alembic uses a sync driver; strip the async aiosqlite qualifier from DATABASE_URL +sync_db_url = os.environ["DATABASE_URL"].replace("sqlite+aiosqlite", "sqlite") +config.set_main_option("sqlalchemy.url", sync_db_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + 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 run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_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..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${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, Sequence[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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/e3c21f33f8fc_baseline.py b/backend/alembic/versions/e3c21f33f8fc_baseline.py new file mode 100644 index 0000000..0899199 --- /dev/null +++ b/backend/alembic/versions/e3c21f33f8fc_baseline.py @@ -0,0 +1,28 @@ +"""baseline + +Revision ID: e3c21f33f8fc +Revises: +Create Date: 2026-07-19 12:36:49.739684 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e3c21f33f8fc' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..6c75c10 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,17 @@ +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +DATABASE_URL = os.environ["DATABASE_URL"] + +engine = create_async_engine(DATABASE_URL, echo=False) +SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +class Base(DeclarativeBase): + pass + + +async def get_session(): + async with SessionLocal() as session: + yield session diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d63ce97 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,133 @@ +aiohappyeyeballs==2.7.1 +aiohttp==3.14.1 +aiosignal==1.4.0 +aiosqlite==0.22.1 +alembic==1.18.5 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.14.2 +ast_serialize==0.6.0 +attrs==26.1.0 +bcrypt==4.1.3 +black==26.5.1 +boto3==1.43.49 +botocore==1.43.49 +certifi==2026.6.17 +cffi==2.1.0 +charset-normalizer==3.4.9 +click==8.4.2 +cryptography==49.0.0 +distro==1.9.0 +dnspython==2.8.0 +ecdsa==0.19.2 +email-validator==2.3.0 +emergentintegrations==0.2.0 +execnet==2.1.2 +fastapi==0.110.1 +fastuuid==0.14.0 +filelock==3.30.0 +flake8==7.3.0 +frozenlist==1.8.0 +fsspec==2026.6.0 +google-ai-generativelanguage==0.6.15 +google-api-core==2.31.0 +google-api-python-client==2.198.0 +google-auth==2.55.2 +google-auth-httplib2==0.4.0 +google-genai==2.12.0 +google-generativeai==0.8.6 +googleapis-common-protos==1.75.0 +greenlet==3.5.3 +grpcio==1.82.1 +grpcio-status==1.71.2 +h11==0.16.0 +hf-xet==1.5.1 +httpcore==1.0.9 +httplib2==0.32.0 +httptools==0.8.0 +httpx==0.28.1 +huggingface_hub==1.23.0 +idna==3.18 +importlib_metadata==9.0.0 +iniconfig==2.3.0 +isort==8.0.1 +Jinja2==3.1.6 +jiter==0.16.0 +jmespath==1.1.0 +jq==1.12.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +librt==0.13.0 +litellm @ https://customer-assets.emergentagent.com/internal-asset/library/litellm-1.80.0-py3-none-any.whl#sha256=adf398c513273de9341f61822296c6b2145f7f2dc4a69daf3ac04829f5bde3f8 +Mako==1.3.12 +markdown-it-py==4.2.0 +MarkupSafe==3.0.3 +mccabe==0.7.0 +mdurl==0.1.2 +motor==3.3.1 +multidict==6.7.1 +mypy==2.3.0 +mypy_extensions==1.1.0 +numpy==2.4.6 +oauthlib==3.3.1 +openai==1.99.9 +packaging==26.2 +pandas==3.0.3 +passlib==1.7.4 +pathspec==1.1.1 +pillow==12.3.0 +platformdirs==4.10.0 +pluggy==1.6.0 +propcache==0.5.2 +proto-plus==1.28.1 +protobuf==5.29.6 +pyasn1==0.6.4 +pyasn1_modules==0.4.2 +pycodestyle==2.14.0 +pycparser==3.0 +pydantic==2.13.4 +pydantic_core==2.46.4 +pyflakes==3.4.0 +Pygments==2.20.0 +PyJWT==2.13.0 +pymongo==4.6.3 +pyparsing==3.3.2 +pytest==9.1.1 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +python-jose==3.5.0 +python-multipart==0.0.32 +pytokens==0.4.1 +PyYAML==6.0.3 +referencing==0.37.0 +regex==2026.7.10 +requests==2.34.2 +requests-oauthlib==2.0.0 +rich==15.0.0 +rpds-py==2026.6.3 +rsa==4.9.1 +s3transfer==0.19.1 +s5cmd==0.2.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.51 +starlette==0.37.2 +stripe==14.4.1 +tenacity==9.1.4 +tiktoken==0.13.0 +tokenizers==0.23.1 +tqdm==4.68.4 +typer==0.27.0 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +tzdata==2026.3 +uritemplate==4.2.0 +urllib3==2.7.0 +uvicorn==0.25.0 +uvloop==0.22.1 +watchfiles==1.2.0 +websockets==16.1 +yarl==1.24.2 +zipp==4.1.0 diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..f9d214c --- /dev/null +++ b/backend/server.py @@ -0,0 +1,46 @@ +from contextlib import asynccontextmanager + +from dotenv import load_dotenv +load_dotenv() + +from fastapi import FastAPI, APIRouter +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text + +from database import engine + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + await engine.dispose() + + +app = FastAPI(title="BTForceManager API", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +async def health_check(): + try: + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + db_status = "connected" + except Exception: + db_status = "error" + return {"status": "ok", "db": db_status} + + +api_router = FastAPI() + +app.get("/health")(health_check) + +router = APIRouter(prefix="/api") +router.get("/health")(health_check) +app.include_router(router) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..164d6c4 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,52 @@ +"""Phase 1 health check tests for BTForceManager backend.""" +import os +import sqlite3 +import requests +import pytest + +PREVIEW_URL = os.environ.get("preview_endpoint", "https://74f0460d-8c6c-427d-90e1-61960e96d92f.preview.emergentagent.com").rstrip("/") +INTERNAL_URL = "http://localhost:8001" +DB_PATH = "/app/backend/data/btforcemanager.db" + + +class TestHealthEndpoints: + def test_internal_health_no_prefix(self): + r = requests.get(f"{INTERNAL_URL}/health", timeout=10) + assert r.status_code == 200 + body = r.json() + assert body == {"status": "ok", "db": "connected"} + + def test_internal_api_health(self): + r = requests.get(f"{INTERNAL_URL}/api/health", timeout=10) + assert r.status_code == 200 + assert r.json() == {"status": "ok", "db": "connected"} + + def test_external_api_health_via_ingress(self): + r = requests.get(f"{PREVIEW_URL}/api/health", timeout=15) + assert r.status_code == 200 + assert r.json() == {"status": "ok", "db": "connected"} + + +class TestAlembicBaseline: + def test_sqlite_db_file_exists(self): + assert os.path.exists(DB_PATH), f"SQLite DB not found at {DB_PATH}" + + def test_alembic_version_table_and_baseline_revision(self): + conn = sqlite3.connect(DB_PATH) + try: + tables = [r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()] + assert "alembic_version" in tables + versions = conn.execute("SELECT version_num FROM alembic_version").fetchall() + assert len(versions) == 1, f"Expected exactly 1 alembic version row, got {versions}" + assert versions[0][0], "alembic version_num is empty" + finally: + conn.close() + + +class TestFrontendUntouched: + def test_frontend_root_loads(self): + r = requests.get(f"{PREVIEW_URL}/", timeout=15) + assert r.status_code == 200 + assert " sqlite:// for sync driver during migrations - verified baseline migration ran cleanly.", + "server.py mounts /health both bare and under APIRouter(prefix='/api') so both routes share one handler - clean; matches ingress requirement that only /api-prefixed routes are exposed externally.", + "No frontend/.env with REACT_APP_BACKEND_URL exists yet - acceptable for Phase 1 since frontend is not wired to backend, but must be added before any future phase that consumes /api endpoints from the SPA." + ], + "updated_files": [ + "/app/backend/tests/test_health.py (new - pytest suite for health endpoints, alembic baseline, and frontend smoke)" + ], + "success_rate": {"backend": "100%", "frontend": "100%"}, + "seed_data_creation": "None - alembic baseline is intentionally empty per Phase 1 scope.", + "retest_needed": false, + "should_main_agent_self_test": true, + "context_for_next_testing_agent": "Phase 1 backend is just a health check + empty alembic baseline. When Phase 2 introduces real endpoints (forces/mechs/pilots), add tests under /app/backend/tests/. External URL for ingress: https://74f0460d-8c6c-427d-90e1-61960e96d92f.preview.emergentagent.com (only /api/* is proxied to backend:8001 externally; internally use http://localhost:8001 for the bare /health route). SQLite DB path: /app/backend/data/btforcemanager.db. Frontend has no REACT_APP_BACKEND_URL configured yet." +} diff --git a/test_reports/pytest/pytest_results.xml b/test_reports/pytest/pytest_results.xml new file mode 100644 index 0000000..591d272 --- /dev/null +++ b/test_reports/pytest/pytest_results.xml @@ -0,0 +1 @@ + \ No newline at end of file From 654f39757357b4dcee360de5f3f37fecdf79fe8d Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 12:41:14 +0000 Subject: [PATCH 03/34] auto-commit for 84c9721a-ecdf-41e2-8969-871ede61bd25 --- memory/PRD.md | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/memory/PRD.md b/memory/PRD.md index 93eb1b8..0d12d8e 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -1,32 +1,37 @@ -# PRD +# PRD - BTForceManager ## Original Problem Statement -I need your help to fix a minor but in my repo https://github.com/AFoletti/BTForceManager -I see that the build github actions fails because Node.js 20 is deprecated. Can you please fix it? +Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremental, self-contained user stories. Migrate the app from a static GitHub Pages app (React bundle + flat JSON files) to a self-hosted app on a Synology NAS, backed by SQLite. Game logic in frontend/src/lib/*.js is storage-agnostic and must remain unchanged. ## Architecture Decisions -- Kept the existing GitHub Actions workflow structure unchanged. -- Applied a targeted CI fix by updating the workflow Node.js version from 20 to 22. -- Limited scope to repository workflows that referenced deprecated Node.js versions. +- Backend: FastAPI + SQLAlchemy (async, aiosqlite) + Alembic, deployed via Docker/docker-compose for the user's Synology NAS. +- DB engine intentionally deviates from the platform's default MongoDB convention: SQLite was explicitly requested by the user for a lightweight, single-user, self-hosted NAS deployment target (not Emergent's own hosting). +- In this sandbox, backend runs via supervisor/uvicorn on :8001 (no Docker daemon available here); Dockerfile/docker-compose.yml are deliverable artifacts for the user's actual NAS deployment. +- Existing frontend (static, JSON-driven) is untouched in this phase; game logic under frontend/src/lib/*.js preserved as-is. ## What's Implemented -- Scanned repository workflows for deprecated Node.js usage. -- Updated `.github/workflows/build-frontend.yml` from `node-version: '20'` to `node-version: '22'`. -- Verified the workflow file change and confirmed no other workflow files in the repo required the same update. -- Ran a frontend production build locally to validate the CI build path. +### Phase 1 (Backend Skeleton + Health Check) - Done, tested 100% pass +- `backend/server.py`: FastAPI app, CORS, GET `/health` and GET `/api/health` (shared handler) returning `{status, db}`. +- `backend/database.py`: async SQLAlchemy engine/session, `DATABASE_URL` from env only. +- `backend/.env` (sandbox) + `.env.example` + `.env.docker.example` (NAS templates). +- Alembic initialized (`backend/alembic/`), empty baseline migration `e3c21f33f8fc`, `env.py` converts `sqlite+aiosqlite` -> `sqlite` for sync migrations. +- `backend/Dockerfile`, root `docker-compose.yml` (backend service, named volume for SQLite persistence at `/data`). +- `.gitignore` updated: ignores `backend/data/`, `*.db`, `__pycache__`; keeps `.env.example`/`.env.docker.example` committed. +- Verified: supervisor backend RUNNING, `/health` + `/api/health` (internal and external via ingress) both return `{"status":"ok","db":"connected"}`, `alembic upgrade head` creates `alembic_version` table in SQLite. + +## Known Pre-existing Issue (not caused by Phase 1) +- Transient console error "Failed to fetch ghost-bear.json" on cold load in `useForceManager.js` `loadForces()` - app still functions correctly (other forces load fine). Flagged by testing agent, not blocking, not part of this migration scope yet. ## Prioritized Backlog -### P0 -- Confirm the GitHub Actions build passes on the next run. +### P0 (next phases per migration roadmap) +- Phase 2: Define SQLAlchemy models mirroring the current force/mech/pilot/mission JSON data contracts; Alembic migration for real schema. +- Phase 3: Data migration script - import existing `data/forces/*.json` into SQLite. +- Phase 4: REST API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js` (ported or called via API). +- Phase 5: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. +- Phase 6: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. ### P1 -- Optionally refresh `actions/setup-node` and other GitHub Actions to their latest supported majors. -- Optionally add workflow triggers beyond manual dispatch if automated CI is desired. - -### P2 -- Add a dedicated CI status badge to the README. -- Add lightweight workflow caching or artifact checks if build time becomes a concern. +- Investigate the pre-existing `ghost-bear.json` fetch race in `useForceManager.js`. ## Next Tasks -- Re-run the `Build Frontend` workflow in GitHub Actions. -- If desired, extend CI to run on push and pull requests. +- Await user's next user-story (Phase 2 scope) before proceeding. From 3570687b1e7162ee3feda2c1ee77c309a1d901e1 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 12:42:06 +0000 Subject: [PATCH 04/34] Auto-generated changes --- .emergent/emergent.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index c9b0db1..54e3087 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { - "job_id": "86e4729b-4931-4e3b-a723-419f52bba5fb", - "created_at": "2026-07-08T12:24:43.560533+00:00Z" + "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", + "created_at": "2026-07-19T12:42:06.034001+00:00Z" } From a538daa465aa5af1e4e4d8dd8e47249119496931 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 12:48:39 +0000 Subject: [PATCH 05/34] auto-commit for 092c902b-e0df-4b5e-a96f-e4d8cbd4fe51 --- .emergent/cron/applied.hash | 0 .emergent/cron/dispatch_webhook.sh | 74 +++++++ .emergent/cron/watch_crons.sh | 37 ++++ .emergent/cron/webhook-crons | 8 + .emergent/cron/webhook_crond.sh | 46 ++++ backend/alembic/env.py | 7 +- .../4bce84c5ebae_core_force_schema.py | 156 +++++++++++++ backend/import_legacy_data.py | 207 ++++++++++++++++++ backend/models.py | 119 ++++++++++ backend/requirements.txt | 1 + backend/routers/__init__.py | 1 + backend/routers/forces.py | 55 +++++ backend/serializers.py | 136 ++++++++++++ backend/server.py | 4 +- backend/tests/test_forces_api.py | 90 ++++++++ test_reports/iteration_2.json | 22 ++ test_reports/pytest/pytest_results.xml | 2 +- 17 files changed, 959 insertions(+), 6 deletions(-) create mode 100644 .emergent/cron/applied.hash create mode 100755 .emergent/cron/dispatch_webhook.sh create mode 100755 .emergent/cron/watch_crons.sh create mode 100644 .emergent/cron/webhook-crons create mode 100755 .emergent/cron/webhook_crond.sh create mode 100644 backend/alembic/versions/4bce84c5ebae_core_force_schema.py create mode 100644 backend/import_legacy_data.py create mode 100644 backend/models.py create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/forces.py create mode 100644 backend/serializers.py create mode 100644 backend/tests/test_forces_api.py create mode 100644 test_reports/iteration_2.json diff --git a/.emergent/cron/applied.hash b/.emergent/cron/applied.hash new file mode 100644 index 0000000..e69de29 diff --git a/.emergent/cron/dispatch_webhook.sh b/.emergent/cron/dispatch_webhook.sh new file mode 100755 index 0000000..849c601 --- /dev/null +++ b/.emergent/cron/dispatch_webhook.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Pod-local webhook-cron dispatcher: one crontab line per enabled cron, run by +# crond inside the preview/env pod. The full endpoint URL is substituted at +# render time; this fires a single request with the .env secret and exits 0. +set -eu + +: "${CRON_NAME:?}" "${METHOD:?}" "${ENDPOINT_URL_B64:?}" +JOB_ID="${JOB_ID:-}" +WEBHOOK_ENV_FILE="${WEBHOOK_ENV_FILE:-/app/backend/.env}" +AT_DATE="${AT_DATE:-}" +END_DATE="${END_DATE:-}" + +# AT_DATE (one-time trigger): crond can't express the year, so the "M H D Mo *" +# line re-fires this minute every year. Fire only when the current UTC minute +# (first 16 chars of RFC3339) matches AT_DATE's minute. +if [ -n "$AT_DATE" ]; then + now_min="$(date -u +%Y-%m-%dT%H:%M)" + at_min="$(printf '%s' "$AT_DATE" | cut -c1-16)" + [ "$now_min" = "$at_min" ] || exit 0 +fi + +# END_DATE (recurring cutoff): both sides use the same fixed %Y-%m-%dT%H:%M:%SZ +# layout, so comparing their digit-only forms numerically preserves chronological +# order. Stop firing once now is strictly past END_DATE. +if [ -n "$END_DATE" ]; then + now_num="$(date -u +%Y%m%d%H%M%S)" + end_num="$(printf '%s' "$END_DATE" | tr -cd '0-9')" + [ "$now_num" -le "$end_num" ] || exit 0 +fi + +ENDPOINT="$(printf '%s' "$ENDPOINT_URL_B64" | base64 -d)" + +strip_quotes() { + # Strip a single matching pair of surrounding quotes. + v="$1" + case "$v" in + \"*\") v="${v#\"}"; v="${v%\"}" ;; + \'*\') v="${v#\'}"; v="${v%\'}" ;; + esac + printf '%s' "$v" +} + +# Read the per-app secret from the dotenv at dispatch time (never from cron env). +read_secret() { + [ -f "$WEBHOOK_ENV_FILE" ] || return 0 + line="$(grep -E '^WEBHOOK_CRON_SECRET=' "$WEBHOOK_ENV_FILE" | tail -n 1 || true)" + value="$(strip_quotes "${line#WEBHOOK_CRON_SECRET=}")" + printf '%s' "$value" +} +WEBHOOK_CRON_SECRET="$(read_secret)" + +# RUN_ID is the idempotency key: cron name + fire time (minute granularity +# matches the schedule floor). +RUN_ID="${CRON_NAME}-$(date -u +%Y%m%dT%H%M)" +DISPATCH_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +ENVELOPE="{\"event\":\"schedule.triggered\",\"schedule_id\":\"$CRON_NAME\",\"run_id\":\"$RUN_ID\",\"dispatch_time\":\"$DISPATCH_TIME\",\"job_id\":\"$JOB_ID\",\"data\":null}" + +# Fire-and-forget: one request, no retries, no run reporting. `|| true` keeps +# `set -e` happy on a curl transport failure (000, non-zero exit). +# --location-trusted: internal-cluster pods get a cross-host 307 to the +# internal.; the Bearer must survive that same-platform redirect. +HTTP_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' \ + --max-time 10 \ + --location-trusted --max-redirs 2 \ + -X "$METHOD" \ + -H "Authorization: Bearer $WEBHOOK_CRON_SECRET" \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Id: $RUN_ID" \ + -H "X-Webhook-Timestamp: $DISPATCH_TIME" \ + -d "$ENVELOPE" \ + "$ENDPOINT" 2>/dev/null || true)" + +echo "dispatch complete (cron=$CRON_NAME http=${HTTP_STATUS:-000})" +exit 0 diff --git a/.emergent/cron/watch_crons.sh b/.emergent/cron/watch_crons.sh new file mode 100755 index 0000000..d692eca --- /dev/null +++ b/.emergent/cron/watch_crons.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Pod-local crons.yml change watcher: runs every minute from the same crontab. +# When the live .emergent/crons.yml hash differs from the last-applied hash it +# asks agent-service to reconcile PREVIEW crons (scope=preview keeps prod/AWS +# untouched). It never writes applied.hash (the install does, so a failed +# reconcile retries next minute) and never exits non-zero. +set -u + +YAML="${CRONS_YAML_FILE:-/app/.emergent/crons.yml}" +APPLIED="${APPLIED_HASH_FILE:-/app/.emergent/cron/applied.hash}" +JOB_ID="${JOB_ID:-}" +CRON_API_URL="${CRON_API_URL:-}" + +# sha256 of $1, or empty when the file is absent (matches the install writer). +hash_file() { + if [ -f "$1" ]; then + sha256sum "$1" 2>/dev/null | cut -d' ' -f1 + else + printf '' + fi +} + +current="$(hash_file "$YAML")" +applied="$(cat "$APPLIED" 2>/dev/null || printf '')" + +# Converged: nothing to do. +[ "$current" = "$applied" ] && exit 0 +# No API URL baked (older pod) — can't reconcile; retry once one is present. +[ -n "$CRON_API_URL" ] || exit 0 + +# Fire-and-forget preview reconcile; silent on any transport failure. +curl -sS -o /dev/null --max-time 15 \ + -X POST \ + -H "Content-Type: application/json" \ + -d "{\"job_id\":\"$JOB_ID\",\"scope\":\"preview\"}" \ + "$CRON_API_URL/internal/crons/reconcile" >/dev/null 2>&1 || true +exit 0 diff --git a/.emergent/cron/webhook-crons b/.emergent/cron/webhook-crons new file mode 100644 index 0000000..579d0ae --- /dev/null +++ b/.emergent/cron/webhook-crons @@ -0,0 +1,8 @@ +# Managed by Emergent webhook-cron (pod-local syscron). DO NOT EDIT. +SHELL=/bin/sh +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +JOB_ID=e8fe6d4b-1b47-4757-a9f4-54ebb4753164 +WEBHOOK_ENV_FILE=/app/backend/.env +CRON_API_URL=https://ea.int.apis.emergentagent.com + +* * * * * root JOB_ID=e8fe6d4b-1b47-4757-a9f4-54ebb4753164 CRON_API_URL=https://ea.int.apis.emergentagent.com /bin/sh /app/.emergent/cron/watch_crons.sh >> /var/log/webhook-cron.log 2>&1 diff --git a/.emergent/cron/webhook_crond.sh b/.emergent/cron/webhook_crond.sh new file mode 100755 index 0000000..d82b599 --- /dev/null +++ b/.emergent/cron/webhook_crond.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# supervisord program entrypoint for the pod-local webhook-cron daemon. +# +# Runs in the FOREGROUND so supervisord supervises it; declared autostart=true +# so it comes back automatically on every pod resume. Before exec'ing the cron +# daemon it self-heals the live crontab from the persistent workspace copy +# (/app is a PVC, /etc/cron.d is not) so a freshly resumed pod schedules the +# last-rendered crons even before agent-service reconciles. +set -eu + +CRON_DIR=/app/.emergent/cron +PERSIST="$CRON_DIR/webhook-crons" +CRON_D=/etc/cron.d/webhook-crons +DISPATCH="$CRON_DIR/dispatch_webhook.sh" +LOG=/var/log/webhook-cron.log + +# Restore the live /etc/cron.d entry from the persistent copy when present. +if [ -f "$PERSIST" ]; then + mkdir -p "$(dirname "$CRON_D")" 2>/dev/null || true + cp "$PERSIST" "$CRON_D" 2>/dev/null || true + chmod 0644 "$CRON_D" 2>/dev/null || true +fi +[ -f "$DISPATCH" ] && chmod 0755 "$DISPATCH" 2>/dev/null || true +touch "$LOG" 2>/dev/null || true + +# If the base image predates the `cron` package, install it at runtime +# (best-effort; a failure falls through to the error below). Debian base + sudo. +if ! command -v cron >/dev/null 2>&1 && ! command -v crond >/dev/null 2>&1; then + echo "webhook_crond: no cron daemon found, attempting runtime install" >&2 + if command -v apt-get >/dev/null 2>&1; then + SUDO="" + [ "$(id -u)" -eq 0 ] || SUDO="sudo" + $SUDO apt-get update >/dev/null 2>&1 && + $SUDO apt-get install -y --no-install-recommends cron >/dev/null 2>&1 || + echo "webhook_crond: runtime cron install failed" >&2 + fi +fi + +# Prefer Debian/cronie `cron` (-f foreground), fall back to busybox `crond`. +if command -v cron >/dev/null 2>&1; then + exec cron -f -L 15 +elif command -v crond >/dev/null 2>&1; then + exec crond -f -l 8 +fi +echo "webhook_crond: no cron daemon (cron/crond) installed in image" >&2 +exit 127 diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 38362e5..e819e0f 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -24,9 +24,10 @@ # add your model's MetaData object here # for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -target_metadata = None +import models # noqa: E402,F401 +from database import Base + +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: diff --git a/backend/alembic/versions/4bce84c5ebae_core_force_schema.py b/backend/alembic/versions/4bce84c5ebae_core_force_schema.py new file mode 100644 index 0000000..443cef7 --- /dev/null +++ b/backend/alembic/versions/4bce84c5ebae_core_force_schema.py @@ -0,0 +1,156 @@ +"""core force schema + +Revision ID: 4bce84c5ebae +Revises: e3c21f33f8fc +Create Date: 2026-07-19 12:44:56.055661 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4bce84c5ebae' +down_revision: Union[str, Sequence[str], None] = 'e3c21f33f8fc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('forces', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('image', sa.String(), nullable=False), + sa.Column('starting_warchest', sa.Integer(), nullable=False), + sa.Column('current_warchest', sa.Integer(), nullable=False), + sa.Column('wp_multiplier', sa.Integer(), nullable=False), + sa.Column('current_date', sa.String(), nullable=False), + sa.Column('notes', sa.Text(), nullable=False), + sa.Column('special_abilities', sa.JSON(), nullable=False), + sa.Column('other_actions_log', sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('elementals', + sa.Column('id', sa.String(), nullable=False), + sa.Column('force_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('commander', sa.String(), nullable=False), + sa.Column('gunnery', sa.Integer(), nullable=False), + sa.Column('antimech', sa.Integer(), nullable=False), + sa.Column('suits_destroyed', sa.Integer(), nullable=False), + sa.Column('suits_damaged', sa.Integer(), nullable=False), + sa.Column('bv', sa.Integer(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('image', sa.String(), nullable=False), + sa.Column('history', sa.Text(), nullable=False), + sa.Column('warchest_cost', sa.Integer(), nullable=False), + sa.Column('activity_log', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['force_id'], ['forces.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_elementals_force_id'), 'elementals', ['force_id'], unique=False) + op.create_table('full_snapshots', + sa.Column('id', sa.String(), nullable=False), + sa.Column('force_id', sa.String(), nullable=False), + sa.Column('snapshot_id', sa.String(), nullable=False), + sa.Column('force_data', sa.JSON(), nullable=False), + sa.Column('created_at', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['force_id'], ['forces.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_full_snapshots_force_id'), 'full_snapshots', ['force_id'], unique=False) + op.create_table('mechs', + sa.Column('id', sa.String(), nullable=False), + sa.Column('force_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('pilot_id', sa.String(), nullable=False), + sa.Column('bv', sa.Integer(), nullable=False), + sa.Column('weight', sa.Integer(), nullable=False), + sa.Column('image', sa.String(), nullable=False), + sa.Column('history', sa.Text(), nullable=False), + sa.Column('warchest_cost', sa.Integer(), nullable=False), + sa.Column('activity_log', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['force_id'], ['forces.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_mechs_force_id'), 'mechs', ['force_id'], unique=False) + op.create_table('missions', + sa.Column('id', sa.String(), nullable=False), + sa.Column('force_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('cost', sa.Integer(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('objectives', sa.JSON(), nullable=False), + sa.Column('recap', sa.Text(), nullable=False), + sa.Column('completed', sa.Boolean(), nullable=False), + sa.Column('assigned_mechs', sa.JSON(), nullable=False), + sa.Column('assigned_elementals', sa.JSON(), nullable=False), + sa.Column('created_at', sa.String(), nullable=False), + sa.Column('in_game_date', sa.String(), nullable=False), + sa.Column('completed_at', sa.String(), nullable=True), + sa.Column('sp_budget', sa.Integer(), nullable=True), + sa.Column('sp_purchases', sa.JSON(), nullable=False), + sa.Column('total_tonnage', sa.Integer(), nullable=True), + sa.Column('op_for_units', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['force_id'], ['forces.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_missions_force_id'), 'missions', ['force_id'], unique=False) + op.create_table('pilots', + sa.Column('id', sa.String(), nullable=False), + sa.Column('force_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('gunnery', sa.Integer(), nullable=False), + sa.Column('piloting', sa.Integer(), nullable=False), + sa.Column('injuries', sa.Integer(), nullable=False), + sa.Column('dezgra', sa.Boolean(), nullable=False), + sa.Column('history', sa.Text(), nullable=False), + sa.Column('warchest_cost', sa.Integer(), nullable=False), + sa.Column('activity_log', sa.JSON(), nullable=False), + sa.Column('combat_record', sa.JSON(), nullable=True), + sa.Column('achievements', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['force_id'], ['forces.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_pilots_force_id'), 'pilots', ['force_id'], unique=False) + op.create_table('snapshots', + sa.Column('id', sa.String(), nullable=False), + sa.Column('force_id', sa.String(), nullable=False), + sa.Column('type', sa.String(), nullable=False), + sa.Column('label', sa.String(), nullable=False), + sa.Column('created_at', sa.String(), nullable=False), + sa.Column('current_warchest', sa.Integer(), nullable=False), + sa.Column('starting_warchest', sa.Integer(), nullable=False), + sa.Column('net_warchest_change', sa.Integer(), nullable=False), + sa.Column('missions_completed', sa.Integer(), nullable=False), + sa.Column('units', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['force_id'], ['forces.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_snapshots_force_id'), 'snapshots', ['force_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_snapshots_force_id'), table_name='snapshots') + op.drop_table('snapshots') + op.drop_index(op.f('ix_pilots_force_id'), table_name='pilots') + op.drop_table('pilots') + op.drop_index(op.f('ix_missions_force_id'), table_name='missions') + op.drop_table('missions') + op.drop_index(op.f('ix_mechs_force_id'), table_name='mechs') + op.drop_table('mechs') + op.drop_index(op.f('ix_full_snapshots_force_id'), table_name='full_snapshots') + op.drop_table('full_snapshots') + op.drop_index(op.f('ix_elementals_force_id'), table_name='elementals') + op.drop_table('elementals') + op.drop_table('forces') + # ### end Alembic commands ### diff --git a/backend/import_legacy_data.py b/backend/import_legacy_data.py new file mode 100644 index 0000000..ba8fc42 --- /dev/null +++ b/backend/import_legacy_data.py @@ -0,0 +1,207 @@ +"""One-time migration script: imports legacy JSON campaign data (data/forces/*.json, +as listed in data/forces/manifest.json) into the SQLite database. + +Safe to re-run: for each force being imported, existing rows for that force are +deleted before re-inserting, so the script always leaves the DB in sync with the +current contents of the JSON files. + +Usage: + cd backend && python import_legacy_data.py +""" +import asyncio +import json +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +from sqlalchemy import delete + +from database import SessionLocal, engine +from models import Base, Force, Mech, Pilot, Elemental, Mission, Snapshot, FullSnapshot + +REPO_ROOT = Path(__file__).resolve().parent.parent +FORCES_DIR = REPO_ROOT / "data" / "forces" +MANIFEST_PATH = FORCES_DIR / "manifest.json" + + +def load_manifest_filenames(): + manifest = json.loads(MANIFEST_PATH.read_text()) + return manifest["forces"] + + +def build_force(raw): + return Force( + id=raw["id"], + name=raw.get("name", ""), + description=raw.get("description", ""), + image=raw.get("image", ""), + starting_warchest=raw.get("startingWarchest", 0), + current_warchest=raw.get("currentWarchest", 0), + wp_multiplier=raw.get("wpMultiplier", 5), + current_date=raw.get("currentDate", ""), + notes=raw.get("notes", ""), + special_abilities=raw.get("specialAbilities", []), + other_actions_log=raw.get("otherActionsLog", []), + ) + + +def build_mechs(raw, force_id): + return [ + Mech( + id=m["id"], + force_id=force_id, + name=m.get("name", ""), + status=m.get("status", "Operational"), + pilot_id=m.get("pilotId", ""), + bv=m.get("bv", 0), + weight=m.get("weight", 0), + image=m.get("image", ""), + history=m.get("history", ""), + warchest_cost=m.get("warchestCost", 0), + activity_log=m.get("activityLog", []), + ) + for m in raw.get("mechs", []) + ] + + +def build_elementals(raw, force_id): + return [ + Elemental( + id=e["id"], + force_id=force_id, + name=e.get("name", ""), + commander=e.get("commander", ""), + gunnery=e.get("gunnery", 0), + antimech=e.get("antimech", 0), + suits_destroyed=e.get("suitsDestroyed", 0), + suits_damaged=e.get("suitsDamaged", 0), + bv=e.get("bv", 0), + status=e.get("status", "Operational"), + image=e.get("image", ""), + history=e.get("history", ""), + warchest_cost=e.get("warchestCost", 0), + activity_log=e.get("activityLog", []), + ) + for e in raw.get("elementals", []) + ] + + +def build_pilots(raw, force_id): + return [ + Pilot( + id=p["id"], + force_id=force_id, + name=p.get("name", ""), + gunnery=p.get("gunnery", 0), + piloting=p.get("piloting", 0), + injuries=p.get("injuries", 0), + dezgra=p.get("dezgra", False), + history=p.get("history", ""), + warchest_cost=p.get("warchestCost", 0), + activity_log=p.get("activityLog", []), + combat_record=p.get("combatRecord"), + achievements=p.get("achievements", []), + ) + for p in raw.get("pilots", []) + ] + + +def build_missions(raw, force_id): + return [ + Mission( + id=m["id"], + force_id=force_id, + name=m.get("name", ""), + cost=m.get("cost", 0), + description=m.get("description", ""), + objectives=m.get("objectives", []), + recap=m.get("recap", ""), + completed=m.get("completed", False), + assigned_mechs=m.get("assignedMechs", []), + assigned_elementals=m.get("assignedElementals", []), + created_at=m.get("createdAt", ""), + in_game_date=m.get("inGameDate", ""), + completed_at=m.get("completedAt"), + sp_budget=m.get("spBudget"), + sp_purchases=m.get("spPurchases", []), + total_tonnage=m.get("totalTonnage"), + op_for_units=m.get("opForUnits", []), + ) + for m in raw.get("missions", []) + ] + + +def build_snapshots(raw, force_id): + return [ + Snapshot( + id=s["id"], + force_id=force_id, + type=s.get("type", ""), + label=s.get("label", ""), + created_at=s.get("createdAt", ""), + current_warchest=s.get("currentWarchest", 0), + starting_warchest=s.get("startingWarchest", 0), + net_warchest_change=s.get("netWarchestChange", 0), + missions_completed=s.get("missionsCompleted", 0), + units=s.get("units", {}), + ) + for s in raw.get("snapshots", []) + ] + + +def build_full_snapshots(raw, force_id): + return [ + FullSnapshot( + id=fs["id"], + force_id=force_id, + snapshot_id=fs.get("snapshotId", ""), + force_data=fs.get("forceData", {}), + created_at=fs.get("createdAt", ""), + ) + for fs in raw.get("fullSnapshots", []) + ] + + +async def import_force(session, filename): + raw = json.loads((FORCES_DIR / filename).read_text()) + force_id = raw["id"] + + # Idempotent re-run: wipe any existing rows for this force first. + for model in (FullSnapshot, Snapshot, Mission, Elemental, Pilot, Mech): + await session.execute(delete(model).where(model.force_id == force_id)) + await session.execute(delete(Force).where(Force.id == force_id)) + + session.add(build_force(raw)) + session.add_all(build_mechs(raw, force_id)) + session.add_all(build_elementals(raw, force_id)) + session.add_all(build_pilots(raw, force_id)) + session.add_all(build_missions(raw, force_id)) + session.add_all(build_snapshots(raw, force_id)) + session.add_all(build_full_snapshots(raw, force_id)) + + counts = { + "mechs": len(raw.get("mechs", [])), + "pilots": len(raw.get("pilots", [])), + "elementals": len(raw.get("elementals", [])), + "missions": len(raw.get("missions", [])), + "snapshots": len(raw.get("snapshots", [])), + "fullSnapshots": len(raw.get("fullSnapshots", [])), + } + return force_id, counts + + +async def main(): + filenames = load_manifest_filenames() + async with SessionLocal() as session: + async with session.begin(): + for filename in filenames: + force_id, counts = await import_force(session, filename) + print(f"Imported {filename} -> force '{force_id}': {counts}") + await engine.dispose() + print(f"Done. Imported {len(filenames)} force(s) from manifest.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..7b673af --- /dev/null +++ b/backend/models.py @@ -0,0 +1,119 @@ +from sqlalchemy import String, Integer, Boolean, Text, JSON, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base + + +class Force(Base): + __tablename__ = "forces" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(Text, default="") + image: Mapped[str] = mapped_column(String, default="") + starting_warchest: Mapped[int] = mapped_column(Integer, default=0) + current_warchest: Mapped[int] = mapped_column(Integer, default=0) + wp_multiplier: Mapped[int] = mapped_column(Integer, default=5) + current_date: Mapped[str] = mapped_column(String, default="") + notes: Mapped[str] = mapped_column(Text, default="") + special_abilities: Mapped[list] = mapped_column(JSON, default=list) + other_actions_log: Mapped[list] = mapped_column(JSON, default=list) + + +class Mech(Base): + __tablename__ = "mechs" + + id: Mapped[str] = mapped_column(String, primary_key=True) + force_id: Mapped[str] = mapped_column(String, ForeignKey("forces.id"), index=True) + name: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="Operational") + pilot_id: Mapped[str] = mapped_column(String, default="") + bv: Mapped[int] = mapped_column(Integer, default=0) + weight: Mapped[int] = mapped_column(Integer, default=0) + image: Mapped[str] = mapped_column(String, default="") + history: Mapped[str] = mapped_column(Text, default="") + warchest_cost: Mapped[int] = mapped_column(Integer, default=0) + activity_log: Mapped[list] = mapped_column(JSON, default=list) + + +class Elemental(Base): + __tablename__ = "elementals" + + id: Mapped[str] = mapped_column(String, primary_key=True) + force_id: Mapped[str] = mapped_column(String, ForeignKey("forces.id"), index=True) + name: Mapped[str] = mapped_column(String, default="") + commander: Mapped[str] = mapped_column(String, default="") + gunnery: Mapped[int] = mapped_column(Integer, default=0) + antimech: Mapped[int] = mapped_column(Integer, default=0) + suits_destroyed: Mapped[int] = mapped_column(Integer, default=0) + suits_damaged: Mapped[int] = mapped_column(Integer, default=0) + bv: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[str] = mapped_column(String, default="Operational") + image: Mapped[str] = mapped_column(String, default="") + history: Mapped[str] = mapped_column(Text, default="") + warchest_cost: Mapped[int] = mapped_column(Integer, default=0) + activity_log: Mapped[list] = mapped_column(JSON, default=list) + + +class Pilot(Base): + __tablename__ = "pilots" + + id: Mapped[str] = mapped_column(String, primary_key=True) + force_id: Mapped[str] = mapped_column(String, ForeignKey("forces.id"), index=True) + name: Mapped[str] = mapped_column(String, default="") + gunnery: Mapped[int] = mapped_column(Integer, default=0) + piloting: Mapped[int] = mapped_column(Integer, default=0) + injuries: Mapped[int] = mapped_column(Integer, default=0) + dezgra: Mapped[bool] = mapped_column(Boolean, default=False) + history: Mapped[str] = mapped_column(Text, default="") + warchest_cost: Mapped[int] = mapped_column(Integer, default=0) + activity_log: Mapped[list] = mapped_column(JSON, default=list) + combat_record: Mapped[dict] = mapped_column(JSON, nullable=True) + achievements: Mapped[list] = mapped_column(JSON, default=list) + + +class Mission(Base): + __tablename__ = "missions" + + id: Mapped[str] = mapped_column(String, primary_key=True) + force_id: Mapped[str] = mapped_column(String, ForeignKey("forces.id"), index=True) + name: Mapped[str] = mapped_column(String, default="") + cost: Mapped[int] = mapped_column(Integer, default=0) + description: Mapped[str] = mapped_column(Text, default="") + objectives: Mapped[list] = mapped_column(JSON, default=list) + recap: Mapped[str] = mapped_column(Text, default="") + completed: Mapped[bool] = mapped_column(Boolean, default=False) + assigned_mechs: Mapped[list] = mapped_column(JSON, default=list) + assigned_elementals: Mapped[list] = mapped_column(JSON, default=list) + created_at: Mapped[str] = mapped_column(String, default="") + in_game_date: Mapped[str] = mapped_column(String, default="") + completed_at: Mapped[str] = mapped_column(String, nullable=True) + sp_budget: Mapped[int] = mapped_column(Integer, nullable=True) + sp_purchases: Mapped[list] = mapped_column(JSON, default=list) + total_tonnage: Mapped[int] = mapped_column(Integer, nullable=True) + op_for_units: Mapped[list] = mapped_column(JSON, default=list) + + +class Snapshot(Base): + __tablename__ = "snapshots" + + id: Mapped[str] = mapped_column(String, primary_key=True) + force_id: Mapped[str] = mapped_column(String, ForeignKey("forces.id"), index=True) + type: Mapped[str] = mapped_column(String, default="") + label: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + current_warchest: Mapped[int] = mapped_column(Integer, default=0) + starting_warchest: Mapped[int] = mapped_column(Integer, default=0) + net_warchest_change: Mapped[int] = mapped_column(Integer, default=0) + missions_completed: Mapped[int] = mapped_column(Integer, default=0) + units: Mapped[dict] = mapped_column(JSON, default=dict) + + +class FullSnapshot(Base): + __tablename__ = "full_snapshots" + + id: Mapped[str] = mapped_column(String, primary_key=True) + force_id: Mapped[str] = mapped_column(String, ForeignKey("forces.id"), index=True) + snapshot_id: Mapped[str] = mapped_column(String, default="") + force_data: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[str] = mapped_column(String, default="") diff --git a/backend/requirements.txt b/backend/requirements.txt index d63ce97..3a06844 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -93,6 +93,7 @@ PyJWT==2.13.0 pymongo==4.6.3 pyparsing==3.3.2 pytest==9.1.1 +pytest-asyncio==1.4.0 pytest-xdist==3.8.0 python-dateutil==2.9.0.post0 python-dotenv==1.2.2 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..04a2837 --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +# backend routers package diff --git a/backend/routers/forces.py b/backend/routers/forces.py new file mode 100644 index 0000000..a480fa3 --- /dev/null +++ b/backend/routers/forces.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Force, Mech, Pilot, Elemental, Mission, Snapshot, FullSnapshot +from serializers import force_summary_to_dict, force_detail_to_dict + +router = APIRouter(prefix="/api") + + +async def count_for_force(session, model, force_id): + result = await session.execute( + select(func.count()).select_from(model).where(model.force_id == force_id) + ) + return result.scalar_one() + + +@router.get("/forces") +async def list_forces(session: AsyncSession = Depends(get_session)): + forces = (await session.execute(select(Force))).scalars().all() + summaries = [] + for force in forces: + mech_count = await count_for_force(session, Mech, force.id) + pilot_count = await count_for_force(session, Pilot, force.id) + elemental_count = await count_for_force(session, Elemental, force.id) + mission_count = await count_for_force(session, Mission, force.id) + summaries.append( + force_summary_to_dict(force, mech_count, pilot_count, elemental_count, mission_count) + ) + return summaries + + +@router.get("/forces/{force_id}") +async def get_force(force_id: str, session: AsyncSession = Depends(get_session)): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + mechs = (await session.execute(select(Mech).where(Mech.force_id == force_id))).scalars().all() + pilots = (await session.execute(select(Pilot).where(Pilot.force_id == force_id))).scalars().all() + elementals = ( + await session.execute(select(Elemental).where(Elemental.force_id == force_id)) + ).scalars().all() + missions = ( + await session.execute(select(Mission).where(Mission.force_id == force_id)) + ).scalars().all() + snapshots = ( + await session.execute(select(Snapshot).where(Snapshot.force_id == force_id)) + ).scalars().all() + full_snapshots = ( + await session.execute(select(FullSnapshot).where(FullSnapshot.force_id == force_id)) + ).scalars().all() + + return force_detail_to_dict(force, mechs, pilots, elementals, missions, snapshots, full_snapshots) diff --git a/backend/serializers.py b/backend/serializers.py new file mode 100644 index 0000000..43a3963 --- /dev/null +++ b/backend/serializers.py @@ -0,0 +1,136 @@ +def mech_to_dict(m): + return { + "id": m.id, + "name": m.name, + "status": m.status, + "pilotId": m.pilot_id, + "bv": m.bv, + "weight": m.weight, + "image": m.image, + "history": m.history, + "warchestCost": m.warchest_cost, + "activityLog": m.activity_log or [], + } + + +def elemental_to_dict(e): + return { + "id": e.id, + "name": e.name, + "commander": e.commander, + "gunnery": e.gunnery, + "antimech": e.antimech, + "suitsDestroyed": e.suits_destroyed, + "suitsDamaged": e.suits_damaged, + "bv": e.bv, + "status": e.status, + "image": e.image, + "history": e.history, + "warchestCost": e.warchest_cost, + "activityLog": e.activity_log or [], + } + + +def pilot_to_dict(p): + d = { + "id": p.id, + "name": p.name, + "gunnery": p.gunnery, + "piloting": p.piloting, + "injuries": p.injuries, + "dezgra": p.dezgra, + "history": p.history, + "warchestCost": p.warchest_cost, + "activityLog": p.activity_log or [], + "achievements": p.achievements or [], + } + if p.combat_record: + d["combatRecord"] = p.combat_record + return d + + +def mission_to_dict(m): + d = { + "id": m.id, + "name": m.name, + "cost": m.cost, + "description": m.description, + "objectives": m.objectives or [], + "recap": m.recap, + "completed": m.completed, + "assignedMechs": m.assigned_mechs or [], + "assignedElementals": m.assigned_elementals or [], + "createdAt": m.created_at, + "inGameDate": m.in_game_date, + "completedAt": m.completed_at, + } + if m.sp_budget is not None: + d["spBudget"] = m.sp_budget + if m.sp_purchases: + d["spPurchases"] = m.sp_purchases + if m.total_tonnage is not None: + d["totalTonnage"] = m.total_tonnage + if m.op_for_units: + d["opForUnits"] = m.op_for_units + return d + + +def snapshot_to_dict(s): + return { + "id": s.id, + "type": s.type, + "label": s.label, + "createdAt": s.created_at, + "currentWarchest": s.current_warchest, + "startingWarchest": s.starting_warchest, + "netWarchestChange": s.net_warchest_change, + "missionsCompleted": s.missions_completed, + "units": s.units or {}, + } + + +def full_snapshot_to_dict(fs): + return { + "id": fs.id, + "snapshotId": fs.snapshot_id, + "forceData": fs.force_data, + "createdAt": fs.created_at, + } + + +def force_summary_to_dict(force, mech_count, pilot_count, elemental_count, mission_count): + return { + "id": force.id, + "name": force.name, + "description": force.description, + "image": force.image, + "startingWarchest": force.starting_warchest, + "currentWarchest": force.current_warchest, + "currentDate": force.current_date, + "mechCount": mech_count, + "pilotCount": pilot_count, + "elementalCount": elemental_count, + "missionCount": mission_count, + } + + +def force_detail_to_dict(force, mechs, pilots, elementals, missions, snapshots, full_snapshots): + return { + "id": force.id, + "name": force.name, + "description": force.description, + "image": force.image, + "startingWarchest": force.starting_warchest, + "currentWarchest": force.current_warchest, + "wpMultiplier": force.wp_multiplier, + "specialAbilities": force.special_abilities or [], + "otherActionsLog": force.other_actions_log or [], + "currentDate": force.current_date, + "notes": force.notes, + "mechs": [mech_to_dict(m) for m in mechs], + "pilots": [pilot_to_dict(p) for p in pilots], + "elementals": [elemental_to_dict(e) for e in elementals], + "missions": [mission_to_dict(m) for m in missions], + "snapshots": [snapshot_to_dict(s) for s in snapshots], + "fullSnapshots": [full_snapshot_to_dict(fs) for fs in full_snapshots], + } diff --git a/backend/server.py b/backend/server.py index f9d214c..7fd54bc 100644 --- a/backend/server.py +++ b/backend/server.py @@ -8,6 +8,7 @@ from sqlalchemy import text from database import engine +from routers.forces import router as forces_router @asynccontextmanager @@ -37,10 +38,9 @@ async def health_check(): return {"status": "ok", "db": db_status} -api_router = FastAPI() - app.get("/health")(health_check) router = APIRouter(prefix="/api") router.get("/health")(health_check) app.include_router(router) +app.include_router(forces_router) diff --git a/backend/tests/test_forces_api.py b/backend/tests/test_forces_api.py new file mode 100644 index 0000000..e263c9d --- /dev/null +++ b/backend/tests/test_forces_api.py @@ -0,0 +1,90 @@ +import json +from pathlib import Path + +import pytest +from httpx import AsyncClient, ASGITransport + +from server import app +from database import SessionLocal +from models import Force, Mech, Pilot, Elemental, Mission, Snapshot, FullSnapshot +from sqlalchemy import select, func + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +FORCES_DIR = REPO_ROOT / "data" / "forces" +MANIFEST_PATH = FORCES_DIR / "manifest.json" + + +def source_forces(): + manifest = json.loads(MANIFEST_PATH.read_text()) + return [json.loads((FORCES_DIR / f).read_text()) for f in manifest["forces"]] + + +@pytest.mark.asyncio +async def test_migration_row_counts_match_source_json(): + async with SessionLocal() as session: + for raw in source_forces(): + force_id = raw["id"] + + for model, key in ( + (Mech, "mechs"), + (Pilot, "pilots"), + (Elemental, "elementals"), + (Mission, "missions"), + (Snapshot, "snapshots"), + (FullSnapshot, "fullSnapshots"), + ): + result = await session.execute( + select(func.count()).select_from(model).where(model.force_id == force_id) + ) + db_count = result.scalar_one() + assert db_count == len(raw.get(key, [])), ( + f"{key} count mismatch for force {force_id}: " + f"db={db_count} json={len(raw.get(key, []))}" + ) + + force = await session.get(Force, force_id) + assert force is not None + assert force.name == raw.get("name", "") + assert force.current_warchest == raw.get("currentWarchest", 0) + + +@pytest.mark.asyncio +async def test_list_forces_endpoint(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/forces") + assert response.status_code == 200 + data = response.json() + ids = {f["id"] for f in data} + assert "ghost-bear" in ids + + +@pytest.mark.asyncio +async def test_get_force_detail_endpoint_matches_source(): + raw = next(f for f in source_forces() if f["id"] == "ghost-bear") + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/forces/ghost-bear") + assert response.status_code == 200 + data = response.json() + + assert data["name"] == raw["name"] + assert data["currentWarchest"] == raw["currentWarchest"] + assert len(data["mechs"]) == len(raw["mechs"]) + assert len(data["pilots"]) == len(raw["pilots"]) + assert len(data["missions"]) == len(raw["missions"]) + assert len(data["snapshots"]) == len(raw["snapshots"]) + assert len(data["fullSnapshots"]) == len(raw["fullSnapshots"]) + + source_mech_ids = {m["id"] for m in raw["mechs"]} + returned_mech_ids = {m["id"] for m in data["mechs"]} + assert source_mech_ids == returned_mech_ids + + +@pytest.mark.asyncio +async def test_get_force_detail_404_for_unknown_force(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/forces/does-not-exist") + assert response.status_code == 404 diff --git a/test_reports/iteration_2.json b/test_reports/iteration_2.json new file mode 100644 index 0000000..7a3027d --- /dev/null +++ b/test_reports/iteration_2.json @@ -0,0 +1,22 @@ +{ + "summary": "Phase 2 (Core Force Schema + Read-Only API) fully validated. Migration script imports correct row counts and is idempotent on re-run. Both GET /api/forces and GET /api/forces/{id} endpoints return correctly shaped camelCase JSON matching the original contract. 404 handling works. All 10 pytest tests pass (4 new forces tests + 6 Phase 1 health tests). Static frontend still loads and renders forces correctly - Phase 1 health checks & frontend regression clean.", + "backend_issues": {"critical": [], "minor": []}, + "frontend_issues": {"ui_bugs": [], "integration_issues": [], "design_issues": []}, + "test_report_links": [ + "/app/backend/tests/test_forces_api.py", + "/app/backend/tests/test_health.py", + "/app/test_reports/pytest/pytest_results.xml" + ], + "action_items": [], + "critical_code_review_comments": [ + "Idempotency in import_legacy_data.py wipes rows per-force but does not remove force rows that were previously imported and later dropped from manifest.json - fine for current scope, worth noting for future write phases.", + "server.py uses allow_origins=['*'] with allow_credentials=True which is invalid per CORS spec (browsers ignore credentials with wildcard). Harmless now (no auth), but should be tightened before Phase with writes/sessions." + ], + "updated_files": [], + "success_rate": {"backend": "100%", "frontend": "100%"}, + "test_credentials": "N/A - no auth in this phase", + "seed_data_creation": "Ran /app/backend/import_legacy_data.py twice; both runs produced identical row counts: ghost-bear (18/18/3/2/5/3), 91st-division-vision-of-words (24/24/0/0/0/0).", + "retest_needed": false, + "main_agent_can_self_test": true, + "context_for_next_testing_agent": "Phase 2 read-only forces API is stable. Data is loaded in /app/backend/data/btforcemanager.db for 2 forces per manifest. Frontend is not yet wired to /api/forces - still reads static JSON. Known transient console error on cold load ('Failed to fetch ghost-bear.json') is pre-existing frontend race and non-blocking. External preview URL: https://74f0460d-8c6c-427d-90e1-61960e96d92f.preview.emergentagent.com" +} diff --git a/test_reports/pytest/pytest_results.xml b/test_reports/pytest/pytest_results.xml index 591d272..77624f0 100644 --- a/test_reports/pytest/pytest_results.xml +++ b/test_reports/pytest/pytest_results.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 80c7ddbfd346627dae2a8e90c5d1f73b8db59e42 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 12:49:11 +0000 Subject: [PATCH 06/34] auto-commit for 53bafc98-867b-4e4b-a6a1-ea3238af0c22 --- memory/PRD.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/memory/PRD.md b/memory/PRD.md index 0d12d8e..7b4b56e 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -19,19 +19,30 @@ Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremen - `.gitignore` updated: ignores `backend/data/`, `*.db`, `__pycache__`; keeps `.env.example`/`.env.docker.example` committed. - Verified: supervisor backend RUNNING, `/health` + `/api/health` (internal and external via ingress) both return `{"status":"ok","db":"connected"}`, `alembic upgrade head` creates `alembic_version` table in SQLite. -## Known Pre-existing Issue (not caused by Phase 1) -- Transient console error "Failed to fetch ghost-bear.json" on cold load in `useForceManager.js` `loadForces()` - app still functions correctly (other forces load fine). Flagged by testing agent, not blocking, not part of this migration scope yet. +### Phase 2 (Core Force Schema + Read-Only API) - Done, tested 100% pass +- `backend/models.py`: 7 SQLAlchemy tables - `forces`, `mechs`, `pilots`, `elementals`, `missions`, `snapshots`, `full_snapshots`. Nested/variable-shape sub-structures (activityLog, combatRecord, achievements, objectives, spPurchases, opForUnits, snapshot units, fullSnapshot forceData) stored as JSON columns rather than further-normalized tables (intentional scope decision for this phase). +- Alembic migration `4bce84c5ebae_core_force_schema` (autogenerated) creates all 7 tables. +- `backend/import_legacy_data.py`: one-time, idempotent migration script - reads `data/forces/manifest.json` + each listed force JSON, wipes+reinserts per-force. Verified twice with identical row counts: ghost-bear (18 mechs/18 pilots/3 elementals/2 missions/5 snapshots/3 fullSnapshots), 91st-division-vision-of-words (24/24/0/0/0/0). +- `backend/serializers.py` + `backend/routers/forces.py`: read-only `GET /api/forces` (summary list w/ counts) and `GET /api/forces/{id}` (full detail, camelCase, matches original JSON contract) + 404 handling. +- `backend/tests/test_forces_api.py`: 4 pytest tests (row-count-vs-source-JSON, list endpoint, detail endpoint, 404). All 10 backend tests pass (Phase 1 + Phase 2). +- Note: only forces listed in `data/forces/manifest.json` are imported (matches frontend's own runtime loading behavior); `19th-great-white.json`/`31th-comstar.json` on disk but not in manifest are intentionally not imported. + +## Known Pre-existing Issue (not caused by this migration) +- Transient console error "Failed to fetch ghost-bear.json" on cold load in `useForceManager.js` `loadForces()` - app still functions correctly (other forces load fine). Flagged by testing agent, not blocking. + +## Code Review Notes (flagged by testing agent, non-blocking for current phase) +- CORS in `server.py` uses `allow_origins=["*"]` with `allow_credentials=True` (spec-invalid combo, harmless now with no auth) - tighten before any phase introducing auth/sessions. +- Migration script doesn't remove forces previously imported but later dropped from manifest.json - fine for read-only phase, revisit once writes are trusted. ## Prioritized Backlog ### P0 (next phases per migration roadmap) -- Phase 2: Define SQLAlchemy models mirroring the current force/mech/pilot/mission JSON data contracts; Alembic migration for real schema. -- Phase 3: Data migration script - import existing `data/forces/*.json` into SQLite. -- Phase 4: REST API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js` (ported or called via API). -- Phase 5: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. -- Phase 6: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. +- Phase 3: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js` (ported or called via API). +- Phase 4: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. +- Phase 5: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. ### P1 - Investigate the pre-existing `ghost-bear.json` fetch race in `useForceManager.js`. +- Tighten CORS policy once auth/writes are introduced. ## Next Tasks -- Await user's next user-story (Phase 2 scope) before proceeding. +- Await user's next user-story (Phase 3 scope) before proceeding. From fd52a598f5ba4c53b92d337969ebed0e894f1cd4 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 12:52:53 +0000 Subject: [PATCH 07/34] Auto-generated changes --- .emergent/emergent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index 54e3087..e9aec1a 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", - "created_at": "2026-07-19T12:42:06.034001+00:00Z" + "created_at": "2026-07-19T12:52:53.046033+00:00Z" } From 41d0b545cead3de4d38b52335b49ecbb98f0d453 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 12:59:43 +0000 Subject: [PATCH 08/34] auto-commit for 6ee4aaaa-f670-478c-86f6-4a7adc49b452 --- ...0_special_abilities_pool_and_join_table.py | 47 +++++++ backend/migrate_special_abilities.py | 83 ++++++++++++ backend/models.py | 17 +++ backend/routers/forces.py | 13 +- backend/routers/special_abilities.py | 106 +++++++++++++++ backend/serializers.py | 8 +- backend/server.py | 10 +- backend/tests/test_special_abilities.py | 125 ++++++++++++++++++ test_reports/iteration_3.json | 20 +++ test_reports/pytest/pytest_results.xml | 2 +- 10 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/27b52250a900_special_abilities_pool_and_join_table.py create mode 100644 backend/migrate_special_abilities.py create mode 100644 backend/routers/special_abilities.py create mode 100644 backend/tests/test_special_abilities.py create mode 100644 test_reports/iteration_3.json diff --git a/backend/alembic/versions/27b52250a900_special_abilities_pool_and_join_table.py b/backend/alembic/versions/27b52250a900_special_abilities_pool_and_join_table.py new file mode 100644 index 0000000..97ea918 --- /dev/null +++ b/backend/alembic/versions/27b52250a900_special_abilities_pool_and_join_table.py @@ -0,0 +1,47 @@ +"""special abilities pool and join table + +Revision ID: 27b52250a900 +Revises: 4bce84c5ebae +Create Date: 2026-07-19 12:55:59.449360 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '27b52250a900' +down_revision: Union[str, Sequence[str], None] = '4bce84c5ebae' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('special_abilities', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_special_abilities_name'), 'special_abilities', ['name'], unique=True) + op.create_table('force_special_abilities', + sa.Column('force_id', sa.String(), nullable=False), + sa.Column('ability_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['ability_id'], ['special_abilities.id'], ), + sa.ForeignKeyConstraint(['force_id'], ['forces.id'], ), + sa.PrimaryKeyConstraint('force_id', 'ability_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('force_special_abilities') + op.drop_index(op.f('ix_special_abilities_name'), table_name='special_abilities') + op.drop_table('special_abilities') + # ### end Alembic commands ### diff --git a/backend/migrate_special_abilities.py b/backend/migrate_special_abilities.py new file mode 100644 index 0000000..e747873 --- /dev/null +++ b/backend/migrate_special_abilities.py @@ -0,0 +1,83 @@ +"""One-time migration: parse each Force's specialAbilities JSON (populated by +Phase 2's import_legacy_data.py) into a deduped special_abilities pool and a +force_special_abilities join table. + +Idempotent: uses get-or-create semantics for both the pool row (by name) and +the join row (by force_id + ability_id), so re-running never creates +duplicates and never touches rows created independently via the API. + +Usage: + cd backend && python migrate_special_abilities.py +""" +import asyncio + +from dotenv import load_dotenv + +load_dotenv() + +from sqlalchemy import select + +from database import SessionLocal, engine +from models import Force, SpecialAbility, ForceSpecialAbility + + +async def get_or_create_ability(session, name, description): + ability = ( + await session.execute(select(SpecialAbility).where(SpecialAbility.name == name)) + ).scalar_one_or_none() + if ability: + return ability, False + ability = SpecialAbility(name=name, description=description) + session.add(ability) + await session.flush() + return ability, True + + +async def link_if_missing(session, force_id, ability_id): + link = ( + await session.execute( + select(ForceSpecialAbility).where( + ForceSpecialAbility.force_id == force_id, + ForceSpecialAbility.ability_id == ability_id, + ) + ) + ).scalar_one_or_none() + if link: + return False + session.add(ForceSpecialAbility(force_id=force_id, ability_id=ability_id)) + return True + + +async def migrate(session): + """Run the dedupe + link migration against all forces currently in the DB.""" + pool_created = 0 + links_created = 0 + + forces = (await session.execute(select(Force))).scalars().all() + for force in forces: + for entry in force.special_abilities or []: + name = (entry.get("title") or "").strip() + if not name: + continue + description = entry.get("description", "") + + ability, was_created = await get_or_create_ability(session, name, description) + if was_created: + pool_created += 1 + + if await link_if_missing(session, force.id, ability.id): + links_created += 1 + + return pool_created, links_created + + +async def main(): + async with SessionLocal() as session: + async with session.begin(): + pool_created, links_created = await migrate(session) + await engine.dispose() + print(f"Done. Created {pool_created} new pool row(s), {links_created} new link row(s).") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/models.py b/backend/models.py index 7b673af..f3baf08 100644 --- a/backend/models.py +++ b/backend/models.py @@ -117,3 +117,20 @@ class FullSnapshot(Base): snapshot_id: Mapped[str] = mapped_column(String, default="") force_data: Mapped[dict] = mapped_column(JSON, default=dict) created_at: Mapped[str] = mapped_column(String, default="") + + +class SpecialAbility(Base): + __tablename__ = "special_abilities" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String, unique=True, index=True) + description: Mapped[str] = mapped_column(Text, default="") + + +class ForceSpecialAbility(Base): + __tablename__ = "force_special_abilities" + + force_id: Mapped[str] = mapped_column(String, ForeignKey("forces.id"), primary_key=True) + ability_id: Mapped[int] = mapped_column( + Integer, ForeignKey("special_abilities.id"), primary_key=True + ) diff --git a/backend/routers/forces.py b/backend/routers/forces.py index a480fa3..0450b1b 100644 --- a/backend/routers/forces.py +++ b/backend/routers/forces.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database import get_session -from models import Force, Mech, Pilot, Elemental, Mission, Snapshot, FullSnapshot +from models import Force, Mech, Pilot, Elemental, Mission, Snapshot, FullSnapshot, SpecialAbility, ForceSpecialAbility from serializers import force_summary_to_dict, force_detail_to_dict router = APIRouter(prefix="/api") @@ -51,5 +51,14 @@ async def get_force(force_id: str, session: AsyncSession = Depends(get_session)) full_snapshots = ( await session.execute(select(FullSnapshot).where(FullSnapshot.force_id == force_id)) ).scalars().all() + special_abilities = ( + await session.execute( + select(SpecialAbility) + .join(ForceSpecialAbility, ForceSpecialAbility.ability_id == SpecialAbility.id) + .where(ForceSpecialAbility.force_id == force_id) + ) + ).scalars().all() - return force_detail_to_dict(force, mechs, pilots, elementals, missions, snapshots, full_snapshots) + return force_detail_to_dict( + force, mechs, pilots, elementals, missions, snapshots, full_snapshots, special_abilities + ) diff --git a/backend/routers/special_abilities.py b/backend/routers/special_abilities.py new file mode 100644 index 0000000..d2158fb --- /dev/null +++ b/backend/routers/special_abilities.py @@ -0,0 +1,106 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Force, SpecialAbility, ForceSpecialAbility + +router = APIRouter(prefix="/api") + + +class SpecialAbilityIn(BaseModel): + name: str + description: str = "" + + +class ForceAbilityLinksIn(BaseModel): + abilityIds: List[int] = [] + + +def ability_to_dict(a): + return {"id": a.id, "name": a.name, "description": a.description} + + +async def get_abilities_for_force(session, force_id): + result = await session.execute( + select(SpecialAbility) + .join(ForceSpecialAbility, ForceSpecialAbility.ability_id == SpecialAbility.id) + .where(ForceSpecialAbility.force_id == force_id) + ) + return result.scalars().all() + + +@router.get("/special-abilities") +async def list_special_abilities(session: AsyncSession = Depends(get_session)): + abilities = (await session.execute(select(SpecialAbility))).scalars().all() + return [ability_to_dict(a) for a in abilities] + + +@router.post("/special-abilities", status_code=201) +async def create_special_ability( + payload: SpecialAbilityIn, session: AsyncSession = Depends(get_session) +): + existing = ( + await session.execute(select(SpecialAbility).where(SpecialAbility.name == payload.name)) + ).scalar_one_or_none() + if existing: + raise HTTPException(status_code=409, detail="Special ability with this name already exists") + + ability = SpecialAbility(name=payload.name, description=payload.description) + session.add(ability) + await session.commit() + await session.refresh(ability) + return ability_to_dict(ability) + + +@router.delete("/special-abilities/{ability_id}", status_code=204) +async def delete_special_ability(ability_id: int, session: AsyncSession = Depends(get_session)): + ability = await session.get(SpecialAbility, ability_id) + if not ability: + raise HTTPException(status_code=404, detail="Special ability not found") + + await session.execute(delete(ForceSpecialAbility).where(ForceSpecialAbility.ability_id == ability_id)) + await session.delete(ability) + await session.commit() + return Response(status_code=204) + + +@router.get("/forces/{force_id}/special-abilities") +async def get_force_special_abilities(force_id: str, session: AsyncSession = Depends(get_session)): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + abilities = await get_abilities_for_force(session, force_id) + return [ability_to_dict(a) for a in abilities] + + +@router.put("/forces/{force_id}/special-abilities") +async def set_force_special_abilities( + force_id: str, payload: ForceAbilityLinksIn, session: AsyncSession = Depends(get_session) +): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + if payload.abilityIds: + result = await session.execute( + select(SpecialAbility.id).where(SpecialAbility.id.in_(payload.abilityIds)) + ) + found_ids = {row[0] for row in result.all()} + missing = set(payload.abilityIds) - found_ids + if missing: + raise HTTPException( + status_code=404, detail=f"Unknown special ability id(s): {sorted(missing)}" + ) + + await session.execute(delete(ForceSpecialAbility).where(ForceSpecialAbility.force_id == force_id)) + for ability_id in payload.abilityIds: + session.add(ForceSpecialAbility(force_id=force_id, ability_id=ability_id)) + await session.commit() + + abilities = await get_abilities_for_force(session, force_id) + return [ability_to_dict(a) for a in abilities] diff --git a/backend/serializers.py b/backend/serializers.py index 43a3963..8e7dea5 100644 --- a/backend/serializers.py +++ b/backend/serializers.py @@ -114,7 +114,9 @@ def force_summary_to_dict(force, mech_count, pilot_count, elemental_count, missi } -def force_detail_to_dict(force, mechs, pilots, elementals, missions, snapshots, full_snapshots): +def force_detail_to_dict( + force, mechs, pilots, elementals, missions, snapshots, full_snapshots, special_abilities=None +): return { "id": force.id, "name": force.name, @@ -123,7 +125,9 @@ def force_detail_to_dict(force, mechs, pilots, elementals, missions, snapshots, "startingWarchest": force.starting_warchest, "currentWarchest": force.current_warchest, "wpMultiplier": force.wp_multiplier, - "specialAbilities": force.special_abilities or [], + "specialAbilities": [ + {"id": a.id, "title": a.name, "description": a.description} for a in (special_abilities or []) + ], "otherActionsLog": force.other_actions_log or [], "currentDate": force.current_date, "notes": force.notes, diff --git a/backend/server.py b/backend/server.py index 7fd54bc..df6f26b 100644 --- a/backend/server.py +++ b/backend/server.py @@ -9,6 +9,7 @@ from database import engine from routers.forces import router as forces_router +from routers.special_abilities import router as special_abilities_router @asynccontextmanager @@ -17,7 +18,13 @@ async def lifespan(app: FastAPI): await engine.dispose() -app = FastAPI(title="BTForceManager API", lifespan=lifespan) +app = FastAPI( + title="BTForceManager API", + lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", +) app.add_middleware( CORSMiddleware, @@ -44,3 +51,4 @@ async def health_check(): router.get("/health")(health_check) app.include_router(router) app.include_router(forces_router) +app.include_router(special_abilities_router) diff --git a/backend/tests/test_special_abilities.py b/backend/tests/test_special_abilities.py new file mode 100644 index 0000000..549c859 --- /dev/null +++ b/backend/tests/test_special_abilities.py @@ -0,0 +1,125 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy import select, delete + +from server import app +from database import SessionLocal +from models import Force, SpecialAbility, ForceSpecialAbility +from migrate_special_abilities import migrate + +TEST_FORCE_A = "test-force-alpha" +TEST_FORCE_B = "test-force-beta" +SHARED_ABILITY_NAME = "Zellbrigen" + + +async def _cleanup_test_forces(session): + for force_id in (TEST_FORCE_A, TEST_FORCE_B): + await session.execute(delete(ForceSpecialAbility).where(ForceSpecialAbility.force_id == force_id)) + await session.execute(delete(Force).where(Force.id == force_id)) + await session.execute(delete(SpecialAbility).where(SpecialAbility.name == SHARED_ABILITY_NAME)) + await session.commit() + + +@pytest.mark.asyncio +async def test_migration_dedupes_shared_ability_across_two_forces(): + async with SessionLocal() as session: + await _cleanup_test_forces(session) + + session.add( + Force( + id=TEST_FORCE_A, + name="Test Force Alpha", + special_abilities=[{"title": SHARED_ABILITY_NAME, "description": "Clan Honor Dueling Protocols"}], + ) + ) + session.add( + Force( + id=TEST_FORCE_B, + name="Test Force Beta", + special_abilities=[{"title": SHARED_ABILITY_NAME, "description": "Clan Honor Dueling Protocols"}], + ) + ) + await session.commit() + + pool_created, links_created = await migrate(session) + await session.commit() + assert pool_created == 1 + assert links_created == 2 + + # Re-running is idempotent: no new rows created. + pool_created_again, links_created_again = await migrate(session) + await session.commit() + assert pool_created_again == 0 + assert links_created_again == 0 + + pool_rows = ( + await session.execute(select(SpecialAbility).where(SpecialAbility.name == SHARED_ABILITY_NAME)) + ).scalars().all() + assert len(pool_rows) == 1 + + join_rows = ( + await session.execute( + select(ForceSpecialAbility).where(ForceSpecialAbility.ability_id == pool_rows[0].id) + ) + ).scalars().all() + assert len(join_rows) == 2 + assert {j.force_id for j in join_rows} == {TEST_FORCE_A, TEST_FORCE_B} + + await _cleanup_test_forces(session) + + +@pytest.mark.asyncio +async def test_special_abilities_pool_crud_and_force_linking(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + create_resp = await client.post( + "/api/special-abilities", json={"name": "Blood Fury", "description": "+1 Initiative when outnumbered"} + ) + assert create_resp.status_code == 201 + ability = create_resp.json() + ability_id = ability["id"] + assert ability["name"] == "Blood Fury" + + dup_resp = await client.post( + "/api/special-abilities", json={"name": "Blood Fury", "description": "dup"} + ) + assert dup_resp.status_code == 409 + + list_resp = await client.get("/api/special-abilities") + assert list_resp.status_code == 200 + assert any(a["id"] == ability_id for a in list_resp.json()) + + link_resp = await client.put( + "/api/forces/ghost-bear/special-abilities", json={"abilityIds": [ability_id]} + ) + assert link_resp.status_code == 200 + linked = link_resp.json() + assert len(linked) == 1 + assert linked[0]["id"] == ability_id + + get_link_resp = await client.get("/api/forces/ghost-bear/special-abilities") + assert get_link_resp.status_code == 200 + assert len(get_link_resp.json()) == 1 + + force_detail_resp = await client.get("/api/forces/ghost-bear") + assert force_detail_resp.status_code == 200 + special_abilities = force_detail_resp.json()["specialAbilities"] + assert special_abilities == [{"id": ability_id, "title": "Blood Fury", "description": "+1 Initiative when outnumbered"}] + + unlink_resp = await client.put("/api/forces/ghost-bear/special-abilities", json={"abilityIds": []}) + assert unlink_resp.status_code == 200 + assert unlink_resp.json() == [] + + bad_force_resp = await client.get("/api/forces/does-not-exist/special-abilities") + assert bad_force_resp.status_code == 404 + + bad_link_resp = await client.put( + "/api/forces/ghost-bear/special-abilities", json={"abilityIds": [999999]} + ) + assert bad_link_resp.status_code == 404 + + delete_resp = await client.delete(f"/api/special-abilities/{ability_id}") + assert delete_resp.status_code == 204 + + delete_missing_resp = await client.delete(f"/api/special-abilities/{ability_id}") + assert delete_missing_resp.status_code == 404 diff --git a/test_reports/iteration_3.json b/test_reports/iteration_3.json new file mode 100644 index 0000000..d1a25fa --- /dev/null +++ b/test_reports/iteration_3.json @@ -0,0 +1,20 @@ +{ + "summary": "Phase 3 (Special Abilities reference pool + join table) validated end-to-end. All 12 pytest tests pass (4 Phase 2 forces + 6 Phase 1 health + 2 new Phase 3). All new endpoints verified against the external preview URL via curl (create/duplicate/list/delete pool ops, force-link GET/PUT with clear + unknown-id + unknown-force cases, and confirmed GET /api/forces/{id} now returns specialAbilities as [{id,title,description}] from the join). Swagger UI reachable at /api/docs both internally (200) and externally (200) and lists all 3 special-abilities paths. migrate_special_abilities.py is idempotent (both consecutive runs report 0/0). DB left clean after all curl tests (pool and links empty, only the 2 manifest forces present). Frontend root still serves 200 - no regression.", + "backend_issues": {"critical": [], "minor": []}, + "frontend_issues": {"ui_bugs": [], "integration_issues": [], "design_issues": []}, + "test_report_links": ["/app/backend/tests/test_special_abilities.py", "/app/backend/tests/test_forces_api.py", "/app/backend/tests/test_health.py", "/app/test_reports/pytest/pytest_results.xml"], + "action_items": [], + "critical_code_review_comments": [ + "Serializer contract change (specialAbilities item key 'title' instead of 'name') is correctly aligned with the README frontend contract - good call-out in the PR.", + "PUT /api/forces/{id}/special-abilities correctly validates all ability ids exist before replacing links (avoids partial-write). Consider deduping abilityIds in the payload defensively if not already done.", + "409 conflict on duplicate POST is well-handled with a clear message. Consider case-insensitive uniqueness if downstream UI allows free-text entry (not blocking)." + ], + "updated_files": [], + "success_rate": {"backend": "100%", "frontend": "100% (static, no changes)"}, + "test_credentials": "N/A - no auth on API", + "seed_data_creation": "None - all curl-based tests created their own row (TESTAbility_XYZ) and cleaned it up; pytest suite also self-cleans. Final DB state verified: 0 rows in special_abilities and force_special_abilities.", + "retest_needed": false, + "main_agent_can_self_test": true, + "context_for_next_testing_agent": "Phase 3 fully green. External preview base URL: https://74f0460d-8c6c-427d-90e1-61960e96d92f.preview.emergentagent.com. Swagger at /api/docs. When Phase 4+ adds more mutation endpoints, remember DB is SQLite at /app/backend/data/btforcemanager.db and can be inspected via sqlite3 python module (sqlite3 CLI is NOT installed in the sandbox).", + "rca of the issue": "No issues found." +} diff --git a/test_reports/pytest/pytest_results.xml b/test_reports/pytest/pytest_results.xml index 77624f0..1169853 100644 --- a/test_reports/pytest/pytest_results.xml +++ b/test_reports/pytest/pytest_results.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 693522d7ec0e1348db24f8ec6e0ac5370e948ffd Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 13:00:22 +0000 Subject: [PATCH 09/34] auto-commit for bbd558d9-440a-4068-a3a9-d0e01868cc4e --- memory/PRD.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/memory/PRD.md b/memory/PRD.md index 7b4b56e..81f2b2b 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -36,13 +36,22 @@ Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremen ## Prioritized Backlog ### P0 (next phases per migration roadmap) -- Phase 3: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js` (ported or called via API). -- Phase 4: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. -- Phase 5: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. +- Phase 4: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. +- Phase 5: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. +- Phase 6: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. ### P1 - Investigate the pre-existing `ghost-bear.json` fetch race in `useForceManager.js`. - Tighten CORS policy once auth/writes are introduced. +- Consider case-insensitive uniqueness for special-abilities pool names if free-text entry is exposed in UI later. ## Next Tasks -- Await user's next user-story (Phase 3 scope) before proceeding. +- Await user's next user-story (Phase 4 scope) before proceeding. + +### Phase 3 (Reference Pools: Special Abilities) - Done, tested 100% pass +- `backend/models.py`: added `SpecialAbility` (id, name unique, description) and `ForceSpecialAbility` join table (composite PK force_id+ability_id). Migration `27b52250a900`. +- `backend/migrate_special_abilities.py`: idempotent, get-or-create dedupe migration (importable `migrate(session)`); parses each Force's legacy `special_abilities` JSON column into the pool + join rows. Currently 0/0 for real data since neither manifest-imported force has specialAbilities yet. +- `backend/routers/special_abilities.py`: `GET/POST /api/special-abilities`, `DELETE /api/special-abilities/{id}`, `GET/PUT /api/forces/{id}/special-abilities`. +- `GET /api/forces/{id}` now sources `specialAbilities` from the join table (serialized as `[{id, title, description}]`, `title` key preserved for frontend contract compatibility) instead of the raw JSON blob. +- Swagger UI moved under `/api/docs` (+ `/api/redoc`, `/api/openapi.json`) so it's externally reachable through the ingress (which only proxies `/api/*`). +- `backend/tests/test_special_abilities.py`: dedup-across-two-forces test (1 pool row + 2 join rows, self-cleaning) + full CRUD/linking flow test. All 12 backend tests pass (Phase 1+2+3). From 83773e2930cbd569951f9629b65654a126fd5401 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 13:44:29 +0000 Subject: [PATCH 10/34] Auto-generated changes --- .emergent/emergent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index e9aec1a..44f26b2 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", - "created_at": "2026-07-19T12:52:53.046033+00:00Z" + "created_at": "2026-07-19T13:44:26.486547+00:00Z" } From 51e0365ecc13b4ae12f2f1dc10681f561f2f7cc6 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 13:47:18 +0000 Subject: [PATCH 11/34] Auto-generated changes --- .emergent/emergent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index 44f26b2..935e614 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", - "created_at": "2026-07-19T13:44:26.486547+00:00Z" + "created_at": "2026-07-19T13:47:18.554405+00:00Z" } From fa057a9b41a42023b8361f542215257bf60f7212 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 14:00:45 +0000 Subject: [PATCH 12/34] auto-commit for eb1664bf-15c5-48e0-bd97-67b76d4ab532 --- ...chievements_and_sp_purchases_reference_.py | 71 +++++++ backend/migrate_reference_data.py | 149 +++++++++++++ backend/models.py | 39 +++- backend/routers/achievements.py | 83 ++++++++ backend/routers/forces.py | 45 +++- backend/routers/sp_choices.py | 62 ++++++ backend/serializers.py | 39 +++- backend/server.py | 4 + backend/tests/test_reference_pools.py | 195 ++++++++++++++++++ test_reports/iteration_4.json | 28 +++ test_reports/pytest/pytest_results.xml | 2 +- 11 files changed, 705 insertions(+), 12 deletions(-) create mode 100644 backend/alembic/versions/a28c833e7254_achievements_and_sp_purchases_reference_.py create mode 100644 backend/migrate_reference_data.py create mode 100644 backend/routers/achievements.py create mode 100644 backend/routers/sp_choices.py create mode 100644 backend/tests/test_reference_pools.py create mode 100644 test_reports/iteration_4.json diff --git a/backend/alembic/versions/a28c833e7254_achievements_and_sp_purchases_reference_.py b/backend/alembic/versions/a28c833e7254_achievements_and_sp_purchases_reference_.py new file mode 100644 index 0000000..2cc650f --- /dev/null +++ b/backend/alembic/versions/a28c833e7254_achievements_and_sp_purchases_reference_.py @@ -0,0 +1,71 @@ +"""achievements and sp purchases reference pools + +Revision ID: a28c833e7254 +Revises: 27b52250a900 +Create Date: 2026-07-19 13:55:51.609055 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a28c833e7254' +down_revision: Union[str, Sequence[str], None] = '27b52250a900' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('achievement_definitions', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('icon', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('condition', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('sp_choices', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('cost', sa.Float(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('mission_sp_purchases', + sa.Column('id', sa.String(), nullable=False), + sa.Column('mission_id', sa.String(), nullable=False), + sa.Column('choice_id', sa.String(), nullable=True), + sa.Column('cost_at_purchase', sa.Float(), nullable=False), + sa.Column('name_at_purchase', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['choice_id'], ['sp_choices.id'], ), + sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_mission_sp_purchases_mission_id'), 'mission_sp_purchases', ['mission_id'], unique=False) + op.create_table('pilot_achievements', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('pilot_id', sa.String(), nullable=False), + sa.Column('achievement_id', sa.String(), nullable=False), + sa.Column('earned_at', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['achievement_id'], ['achievement_definitions.id'], ), + sa.ForeignKeyConstraint(['pilot_id'], ['pilots.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_pilot_achievements_pilot_id'), 'pilot_achievements', ['pilot_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_pilot_achievements_pilot_id'), table_name='pilot_achievements') + op.drop_table('pilot_achievements') + op.drop_index(op.f('ix_mission_sp_purchases_mission_id'), table_name='mission_sp_purchases') + op.drop_table('mission_sp_purchases') + op.drop_table('sp_choices') + op.drop_table('achievement_definitions') + # ### end Alembic commands ### diff --git a/backend/migrate_reference_data.py b/backend/migrate_reference_data.py new file mode 100644 index 0000000..3553d12 --- /dev/null +++ b/backend/migrate_reference_data.py @@ -0,0 +1,149 @@ +"""One-time migration: seeds the achievement_definitions and sp_choices +catalogs from data/achievements.json and data/sp-choices.json, then parses +each Pilot's legacy `achievements[]` and each Mission's legacy `spPurchases[]` +JSON columns (populated by Phase 2's import_legacy_data.py) into the new +normalized tables: + - pilot_achievements: link row per pilot+achievement (earned_at unknown + for historical data, left null - future POSTs can supply a real date) + - mission_sp_purchases: one row per historical purchase line item, with + cost_at_purchase/name_at_purchase snapshotted from the JSON at import + time so later catalog price changes never retroactively alter history + +Idempotent: catalogs are upserted by id; pilot_achievements/mission_sp_purchases +use get-or-create (by pilot+achievement, or by purchase id) so re-running never +duplicates rows. + +Usage: + cd backend && python migrate_reference_data.py +""" +import asyncio +import json +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +from sqlalchemy import select + +from database import SessionLocal, engine +from models import ( + Pilot, + Mission, + AchievementDefinition, + PilotAchievement, + SpChoice, + MissionSpPurchase, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +ACHIEVEMENTS_PATH = REPO_ROOT / "data" / "achievements.json" +SP_CHOICES_PATH = REPO_ROOT / "data" / "sp-choices.json" + + +async def seed_achievement_definitions(session): + data = json.loads(ACHIEVEMENTS_PATH.read_text()) + created, updated = 0, 0 + for entry in data.get("achievements", []): + existing = await session.get(AchievementDefinition, entry["id"]) + if existing: + existing.name = entry.get("name", "") + existing.icon = entry.get("icon", "") + existing.description = entry.get("description", "") + existing.condition = entry.get("condition", "") + updated += 1 + else: + session.add( + AchievementDefinition( + id=entry["id"], + name=entry.get("name", ""), + icon=entry.get("icon", ""), + description=entry.get("description", ""), + condition=entry.get("condition", ""), + ) + ) + created += 1 + await session.flush() + return created, updated + + +async def seed_sp_choices(session): + data = json.loads(SP_CHOICES_PATH.read_text()) + created, updated = 0, 0 + for entry in data.get("spChoices", []): + existing = await session.get(SpChoice, entry["id"]) + if existing: + existing.name = entry.get("name", "") + existing.cost = entry.get("cost", 0) + updated += 1 + else: + session.add(SpChoice(id=entry["id"], name=entry.get("name", ""), cost=entry.get("cost", 0))) + created += 1 + await session.flush() + return created, updated + + +async def migrate_pilot_achievements(session): + links_created = 0 + pilots = (await session.execute(select(Pilot))).scalars().all() + for pilot in pilots: + for achievement_id in pilot.achievements or []: + existing = ( + await session.execute( + select(PilotAchievement).where( + PilotAchievement.pilot_id == pilot.id, + PilotAchievement.achievement_id == achievement_id, + ) + ) + ).scalar_one_or_none() + if existing: + continue + definition = await session.get(AchievementDefinition, achievement_id) + if not definition: + print(f"Warning: pilot {pilot.id} has unknown achievement id '{achievement_id}', skipping") + continue + session.add(PilotAchievement(pilot_id=pilot.id, achievement_id=achievement_id, earned_at=None)) + links_created += 1 + return links_created + + +async def migrate_mission_sp_purchases(session): + purchases_created = 0 + missions = (await session.execute(select(Mission))).scalars().all() + for mission in missions: + for purchase in mission.sp_purchases or []: + purchase_id = purchase.get("id") + if not purchase_id: + continue + existing = await session.get(MissionSpPurchase, purchase_id) + if existing: + continue + session.add( + MissionSpPurchase( + id=purchase_id, + mission_id=mission.id, + choice_id=purchase.get("choiceId"), + cost_at_purchase=purchase.get("cost", 0), + name_at_purchase=purchase.get("name", ""), + ) + ) + purchases_created += 1 + return purchases_created + + +async def main(): + async with SessionLocal() as session: + async with session.begin(): + ach_created, ach_updated = await seed_achievement_definitions(session) + sp_created, sp_updated = await seed_sp_choices(session) + pilot_links_created = await migrate_pilot_achievements(session) + sp_purchases_created = await migrate_mission_sp_purchases(session) + await engine.dispose() + print(f"Achievement definitions: {ach_created} created, {ach_updated} updated.") + print(f"SP choices: {sp_created} created, {sp_updated} updated.") + print(f"Pilot achievement links created: {pilot_links_created}") + print(f"Mission SP purchase line items created: {sp_purchases_created}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/models.py b/backend/models.py index f3baf08..dd6c417 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Integer, Boolean, Text, JSON, ForeignKey +from sqlalchemy import String, Integer, Boolean, Float, Text, JSON, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from database import Base @@ -134,3 +134,40 @@ class ForceSpecialAbility(Base): ability_id: Mapped[int] = mapped_column( Integer, ForeignKey("special_abilities.id"), primary_key=True ) + + +class AchievementDefinition(Base): + __tablename__ = "achievement_definitions" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, default="") + icon: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(Text, default="") + condition: Mapped[str] = mapped_column(String, default="") + + +class PilotAchievement(Base): + __tablename__ = "pilot_achievements" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + pilot_id: Mapped[str] = mapped_column(String, ForeignKey("pilots.id"), index=True) + achievement_id: Mapped[str] = mapped_column(String, ForeignKey("achievement_definitions.id")) + earned_at: Mapped[str] = mapped_column(String, nullable=True) + + +class SpChoice(Base): + __tablename__ = "sp_choices" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, default="") + cost: Mapped[float] = mapped_column(Float, default=0) + + +class MissionSpPurchase(Base): + __tablename__ = "mission_sp_purchases" + + id: Mapped[str] = mapped_column(String, primary_key=True) + mission_id: Mapped[str] = mapped_column(String, ForeignKey("missions.id"), index=True) + choice_id: Mapped[str] = mapped_column(String, ForeignKey("sp_choices.id"), nullable=True) + cost_at_purchase: Mapped[float] = mapped_column(Float, default=0) + name_at_purchase: Mapped[str] = mapped_column(String, default="") diff --git a/backend/routers/achievements.py b/backend/routers/achievements.py new file mode 100644 index 0000000..cd4adbc --- /dev/null +++ b/backend/routers/achievements.py @@ -0,0 +1,83 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Pilot, AchievementDefinition, PilotAchievement + +router = APIRouter(prefix="/api") + + +class PilotAchievementIn(BaseModel): + achievementId: str + earnedAt: Optional[str] = None + + +def definition_to_dict(a): + return {"id": a.id, "name": a.name, "icon": a.icon, "description": a.description, "condition": a.condition} + + +def pilot_achievement_to_dict(link, definition): + return { + "id": link.id, + "achievementId": link.achievement_id, + "earnedAt": link.earned_at, + "name": definition.name if definition else None, + "icon": definition.icon if definition else None, + "description": definition.description if definition else None, + } + + +@router.get("/achievement-definitions") +async def list_achievement_definitions(session: AsyncSession = Depends(get_session)): + definitions = (await session.execute(select(AchievementDefinition))).scalars().all() + return [definition_to_dict(d) for d in definitions] + + +@router.get("/pilots/{pilot_id}/achievements") +async def get_pilot_achievements(pilot_id: str, session: AsyncSession = Depends(get_session)): + pilot = await session.get(Pilot, pilot_id) + if not pilot: + raise HTTPException(status_code=404, detail="Pilot not found") + + links = ( + await session.execute(select(PilotAchievement).where(PilotAchievement.pilot_id == pilot_id)) + ).scalars().all() + result = [] + for link in links: + definition = await session.get(AchievementDefinition, link.achievement_id) + result.append(pilot_achievement_to_dict(link, definition)) + return result + + +@router.post("/pilots/{pilot_id}/achievements", status_code=201) +async def add_pilot_achievement( + pilot_id: str, payload: PilotAchievementIn, session: AsyncSession = Depends(get_session) +): + pilot = await session.get(Pilot, pilot_id) + if not pilot: + raise HTTPException(status_code=404, detail="Pilot not found") + + definition = await session.get(AchievementDefinition, payload.achievementId) + if not definition: + raise HTTPException(status_code=404, detail="Achievement definition not found") + + existing = ( + await session.execute( + select(PilotAchievement).where( + PilotAchievement.pilot_id == pilot_id, + PilotAchievement.achievement_id == payload.achievementId, + ) + ) + ).scalar_one_or_none() + if existing: + raise HTTPException(status_code=409, detail="Pilot already has this achievement") + + link = PilotAchievement(pilot_id=pilot_id, achievement_id=payload.achievementId, earned_at=payload.earnedAt) + session.add(link) + await session.commit() + await session.refresh(link) + return pilot_achievement_to_dict(link, definition) diff --git a/backend/routers/forces.py b/backend/routers/forces.py index 0450b1b..a2fcd69 100644 --- a/backend/routers/forces.py +++ b/backend/routers/forces.py @@ -3,7 +3,19 @@ from sqlalchemy.ext.asyncio import AsyncSession from database import get_session -from models import Force, Mech, Pilot, Elemental, Mission, Snapshot, FullSnapshot, SpecialAbility, ForceSpecialAbility +from models import ( + Force, + Mech, + Pilot, + Elemental, + Mission, + Snapshot, + FullSnapshot, + SpecialAbility, + ForceSpecialAbility, + PilotAchievement, + MissionSpPurchase, +) from serializers import force_summary_to_dict, force_detail_to_dict router = APIRouter(prefix="/api") @@ -59,6 +71,35 @@ async def get_force(force_id: str, session: AsyncSession = Depends(get_session)) ) ).scalars().all() + achievements_by_pilot = {p.id: [] for p in pilots} + if pilots: + pilot_achv_rows = ( + await session.execute( + select(PilotAchievement).where(PilotAchievement.pilot_id.in_(achievements_by_pilot.keys())) + ) + ).scalars().all() + for row in pilot_achv_rows: + achievements_by_pilot[row.pilot_id].append(row.achievement_id) + + sp_purchases_by_mission = {m.id: [] for m in missions} + if missions: + sp_purchase_rows = ( + await session.execute( + select(MissionSpPurchase).where(MissionSpPurchase.mission_id.in_(sp_purchases_by_mission.keys())) + ) + ).scalars().all() + for row in sp_purchase_rows: + sp_purchases_by_mission[row.mission_id].append(row) + return force_detail_to_dict( - force, mechs, pilots, elementals, missions, snapshots, full_snapshots, special_abilities + force, + mechs, + pilots, + elementals, + missions, + snapshots, + full_snapshots, + special_abilities, + achievements_by_pilot, + sp_purchases_by_mission, ) diff --git a/backend/routers/sp_choices.py b/backend/routers/sp_choices.py new file mode 100644 index 0000000..e0ed9ef --- /dev/null +++ b/backend/routers/sp_choices.py @@ -0,0 +1,62 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Mission, SpChoice, MissionSpPurchase + +router = APIRouter(prefix="/api") + + +class MissionSpPurchaseIn(BaseModel): + choiceId: str + + +def sp_choice_to_dict(c): + return {"id": c.id, "name": c.name, "cost": c.cost} + + +def sp_purchase_to_dict(p): + return { + "id": p.id, + "missionId": p.mission_id, + "choiceId": p.choice_id, + "name": p.name_at_purchase, + "cost": p.cost_at_purchase, + } + + +@router.get("/sp-choices") +async def list_sp_choices(session: AsyncSession = Depends(get_session)): + choices = (await session.execute(select(SpChoice))).scalars().all() + return [sp_choice_to_dict(c) for c in choices] + + +@router.post("/missions/{mission_id}/sp-purchases", status_code=201) +async def create_mission_sp_purchase( + mission_id: str, payload: MissionSpPurchaseIn, session: AsyncSession = Depends(get_session) +): + mission = await session.get(Mission, mission_id) + if not mission: + raise HTTPException(status_code=404, detail="Mission not found") + + choice = await session.get(SpChoice, payload.choiceId) + if not choice: + raise HTTPException(status_code=404, detail="SP choice not found in catalog") + + # Snapshot the catalog's current name/cost at the moment of purchase - + # this value never changes even if the catalog price is edited later. + purchase = MissionSpPurchase( + id=f"sp-{uuid.uuid4().hex[:12]}", + mission_id=mission_id, + choice_id=choice.id, + cost_at_purchase=choice.cost, + name_at_purchase=choice.name, + ) + session.add(purchase) + await session.commit() + await session.refresh(purchase) + return sp_purchase_to_dict(purchase) diff --git a/backend/serializers.py b/backend/serializers.py index 8e7dea5..7e461e1 100644 --- a/backend/serializers.py +++ b/backend/serializers.py @@ -31,7 +31,7 @@ def elemental_to_dict(e): } -def pilot_to_dict(p): +def pilot_to_dict(p, achievement_ids=None): d = { "id": p.id, "name": p.name, @@ -42,14 +42,23 @@ def pilot_to_dict(p): "history": p.history, "warchestCost": p.warchest_cost, "activityLog": p.activity_log or [], - "achievements": p.achievements or [], + "achievements": achievement_ids if achievement_ids is not None else (p.achievements or []), } if p.combat_record: d["combatRecord"] = p.combat_record return d -def mission_to_dict(m): +def sp_purchase_to_dict(sp): + return { + "id": sp.id, + "choiceId": sp.choice_id, + "name": sp.name_at_purchase, + "cost": sp.cost_at_purchase, + } + + +def mission_to_dict(m, sp_purchases=None): d = { "id": m.id, "name": m.name, @@ -66,8 +75,11 @@ def mission_to_dict(m): } if m.sp_budget is not None: d["spBudget"] = m.sp_budget - if m.sp_purchases: - d["spPurchases"] = m.sp_purchases + resolved_sp_purchases = sp_purchases if sp_purchases is not None else m.sp_purchases + if resolved_sp_purchases: + d["spPurchases"] = ( + [sp_purchase_to_dict(sp) for sp in sp_purchases] if sp_purchases is not None else resolved_sp_purchases + ) if m.total_tonnage is not None: d["totalTonnage"] = m.total_tonnage if m.op_for_units: @@ -115,8 +127,19 @@ def force_summary_to_dict(force, mech_count, pilot_count, elemental_count, missi def force_detail_to_dict( - force, mechs, pilots, elementals, missions, snapshots, full_snapshots, special_abilities=None + force, + mechs, + pilots, + elementals, + missions, + snapshots, + full_snapshots, + special_abilities=None, + achievements_by_pilot=None, + sp_purchases_by_mission=None, ): + achievements_by_pilot = achievements_by_pilot or {} + sp_purchases_by_mission = sp_purchases_by_mission or {} return { "id": force.id, "name": force.name, @@ -132,9 +155,9 @@ def force_detail_to_dict( "currentDate": force.current_date, "notes": force.notes, "mechs": [mech_to_dict(m) for m in mechs], - "pilots": [pilot_to_dict(p) for p in pilots], + "pilots": [pilot_to_dict(p, achievements_by_pilot.get(p.id)) for p in pilots], "elementals": [elemental_to_dict(e) for e in elementals], - "missions": [mission_to_dict(m) for m in missions], + "missions": [mission_to_dict(m, sp_purchases_by_mission.get(m.id)) for m in missions], "snapshots": [snapshot_to_dict(s) for s in snapshots], "fullSnapshots": [full_snapshot_to_dict(fs) for fs in full_snapshots], } diff --git a/backend/server.py b/backend/server.py index df6f26b..033a2b4 100644 --- a/backend/server.py +++ b/backend/server.py @@ -10,6 +10,8 @@ from database import engine from routers.forces import router as forces_router from routers.special_abilities import router as special_abilities_router +from routers.achievements import router as achievements_router +from routers.sp_choices import router as sp_choices_router @asynccontextmanager @@ -52,3 +54,5 @@ async def health_check(): app.include_router(router) app.include_router(forces_router) app.include_router(special_abilities_router) +app.include_router(achievements_router) +app.include_router(sp_choices_router) diff --git a/backend/tests/test_reference_pools.py b/backend/tests/test_reference_pools.py new file mode 100644 index 0000000..5941dd4 --- /dev/null +++ b/backend/tests/test_reference_pools.py @@ -0,0 +1,195 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy import select, delete + +from server import app +from database import SessionLocal +from models import ( + Force, + Pilot, + Mission, + AchievementDefinition, + PilotAchievement, + SpChoice, + MissionSpPurchase, +) +from migrate_reference_data import ( + seed_achievement_definitions, + seed_sp_choices, + migrate_pilot_achievements, + migrate_mission_sp_purchases, +) + +TEST_FORCE_ID = "test-force-refdata" +TEST_PILOT_ID = "test-pilot-refdata" +TEST_MISSION_ID = "test-mission-refdata" +TEST_CHOICE_ID = "test-choice-refdata" + + +async def _cleanup(session): + await session.execute(delete(PilotAchievement).where(PilotAchievement.pilot_id == TEST_PILOT_ID)) + await session.execute(delete(MissionSpPurchase).where(MissionSpPurchase.mission_id == TEST_MISSION_ID)) + await session.execute(delete(Pilot).where(Pilot.id == TEST_PILOT_ID)) + await session.execute(delete(Mission).where(Mission.id == TEST_MISSION_ID)) + await session.execute(delete(Force).where(Force.id == TEST_FORCE_ID)) + await session.execute(delete(SpChoice).where(SpChoice.id == TEST_CHOICE_ID)) + await session.commit() + + +@pytest.mark.asyncio +async def test_catalog_seed_is_idempotent_and_covers_real_json_files(): + async with SessionLocal() as session: + created1, updated1 = await seed_achievement_definitions(session) + sp_created1, sp_updated1 = await seed_sp_choices(session) + await session.commit() + + created2, updated2 = await seed_achievement_definitions(session) + sp_created2, sp_updated2 = await seed_sp_choices(session) + await session.commit() + + # Second run should create nothing new (upsert-by-id, idempotent). + assert created2 == 0 + assert sp_created2 == 0 + + all_definitions = (await session.execute(select(AchievementDefinition))).scalars().all() + assert len(all_definitions) == 16 # matches data/achievements.json + + all_choices = (await session.execute(select(SpChoice))).scalars().all() + assert len(all_choices) == 25 # matches data/sp-choices.json + + +@pytest.mark.asyncio +async def test_repeated_sp_purchase_of_same_choice_creates_two_separate_line_items(): + async with SessionLocal() as session: + await _cleanup(session) + + session.add(Force(id=TEST_FORCE_ID, name="Test Force RefData")) + session.add(SpChoice(id=TEST_CHOICE_ID, name="Test Strike", cost=10)) + session.add( + Mission( + id=TEST_MISSION_ID, + force_id=TEST_FORCE_ID, + name="Test Mission", + sp_purchases=[ + {"id": "sp-line-1", "choiceId": TEST_CHOICE_ID, "name": "Test Strike", "cost": 10}, + {"id": "sp-line-2", "choiceId": TEST_CHOICE_ID, "name": "Test Strike", "cost": 10}, + ], + ) + ) + await session.commit() + + created = await migrate_mission_sp_purchases(session) + await session.commit() + assert created == 2 + + rows = ( + await session.execute( + select(MissionSpPurchase).where(MissionSpPurchase.mission_id == TEST_MISSION_ID) + ) + ).scalars().all() + assert len(rows) == 2 + assert {r.id for r in rows} == {"sp-line-1", "sp-line-2"} + assert all(r.choice_id == TEST_CHOICE_ID for r in rows) + + await _cleanup(session) + + +@pytest.mark.asyncio +async def test_catalog_price_change_does_not_retroactively_alter_historical_cost(): + async with SessionLocal() as session: + await _cleanup(session) + + choice = SpChoice(id=TEST_CHOICE_ID, name="Test Strike", cost=10) + session.add(choice) + session.add(Force(id=TEST_FORCE_ID, name="Test Force RefData")) + session.add(Mission(id=TEST_MISSION_ID, force_id=TEST_FORCE_ID, name="Test Mission")) + await session.commit() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + purchase_resp = await client.post( + f"/api/missions/{TEST_MISSION_ID}/sp-purchases", json={"choiceId": TEST_CHOICE_ID} + ) + assert purchase_resp.status_code == 201 + purchase = purchase_resp.json() + assert purchase["cost"] == 10 + + # Catalog price changes after the purchase was made. + choice_row = await session.get(SpChoice, TEST_CHOICE_ID) + choice_row.cost = 999 + await session.commit() + + purchase_row = await session.get(MissionSpPurchase, purchase["id"]) + assert purchase_row.cost_at_purchase == 10, "historical purchase cost must not change" + + current_catalog = (await session.execute(select(SpChoice).where(SpChoice.id == TEST_CHOICE_ID))).scalar_one() + assert current_catalog.cost == 999 + + await session.execute(delete(MissionSpPurchase).where(MissionSpPurchase.id == purchase["id"])) + await session.commit() + await _cleanup(session) + + +@pytest.mark.asyncio +async def test_pilot_achievements_api_flow(): + async with SessionLocal() as session: + await _cleanup(session) + session.add(Force(id=TEST_FORCE_ID, name="Test Force RefData")) + session.add(Pilot(id=TEST_PILOT_ID, force_id=TEST_FORCE_ID, name="Test Pilot")) + await session.commit() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + empty_resp = await client.get(f"/api/pilots/{TEST_PILOT_ID}/achievements") + assert empty_resp.status_code == 200 + assert empty_resp.json() == [] + + create_resp = await client.post( + f"/api/pilots/{TEST_PILOT_ID}/achievements", + json={"achievementId": "first-blood", "earnedAt": "3052-05-01"}, + ) + assert create_resp.status_code == 201 + body = create_resp.json() + assert body["achievementId"] == "first-blood" + assert body["earnedAt"] == "3052-05-01" + assert body["name"] == "First Blood" + + dup_resp = await client.post( + f"/api/pilots/{TEST_PILOT_ID}/achievements", json={"achievementId": "first-blood"} + ) + assert dup_resp.status_code == 409 + + unknown_resp = await client.post( + f"/api/pilots/{TEST_PILOT_ID}/achievements", json={"achievementId": "does-not-exist"} + ) + assert unknown_resp.status_code == 404 + + list_resp = await client.get(f"/api/pilots/{TEST_PILOT_ID}/achievements") + assert list_resp.status_code == 200 + assert len(list_resp.json()) == 1 + + missing_pilot_resp = await client.get("/api/pilots/does-not-exist/achievements") + assert missing_pilot_resp.status_code == 404 + + async with SessionLocal() as session: + await _cleanup(session) + + +@pytest.mark.asyncio +async def test_forces_detail_serializes_normalized_achievements_and_sp_purchases(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/forces/ghost-bear") + assert resp.status_code == 200 + data = resp.json() + + pilots_with_achievements = [p for p in data["pilots"] if p["achievements"]] + assert len(pilots_with_achievements) > 0 + assert "survivor" in pilots_with_achievements[0]["achievements"] or all( + isinstance(a, str) for a in pilots_with_achievements[0]["achievements"] + ) + + missions_with_purchases = [m for m in data["missions"] if m.get("spPurchases")] + assert len(missions_with_purchases) > 0 + for purchase in missions_with_purchases[0]["spPurchases"]: + assert set(purchase.keys()) == {"id", "choiceId", "name", "cost"} diff --git a/test_reports/iteration_4.json b/test_reports/iteration_4.json new file mode 100644 index 0000000..30d28ed --- /dev/null +++ b/test_reports/iteration_4.json @@ -0,0 +1,28 @@ +{ + "summary": "Phase 4 (Achievements & SP Purchases reference pools) fully validated. Migration is idempotent (second run: 16/25 catalog upserts, 0 new links/purchases). All 17 pytest tests pass (10 Phases 1-2 + 2 Phase 3 + 5 new Phase 4). All Phase 4 REST endpoints verified via external ingress URL. Core acceptance criteria confirmed: (a) historical SP purchase cost is snapshotted and not affected by later catalog price changes (pytest test_catalog_price_change_does_not_retroactively_alter_historical_cost), (b) repeated SP purchases of the same choiceId create distinct line items (ghost-bear M02 has 4 separate air_hvstrike entries each with a unique sp-* id). Force detail (ghost-bear) now serves normalized achievements (['survivor'] for pilot-1774842818222) and normalized spPurchases from the new tables. Frontend regression: root loads, renders Bluefang Trinary force with all tabs; pre-existing cold-fetch race on ghost-bear.json/91st-division-vision-of-words.json is a known frontend issue explicitly out of scope per review request.", + "backend_issues": {"critical": [], "minor": []}, + "frontend_issues": {"ui_bugs": [], "integration_issues": [], "design_issues": []}, + "test_report_links": [ + "/app/backend/tests/test_reference_pools.py", + "/app/backend/tests/test_forces_api.py", + "/app/backend/tests/test_special_abilities.py", + "/app/backend/tests/test_health.py", + "/app/test_reports/pytest/pytest_results.xml" + ], + "action_items": [], + "critical_code_review_comments": [ + "serializers.py force_detail_to_dict pre-builds achievements_by_pilot / sp_purchases_by_mission dicts up-front - clean, avoids N+1 in the per-force detail path.", + "POST /api/pilots/{pilot_id}/achievements correctly returns 409 on duplicate (pilot_id, achievement_id) and 404 for unknown achievement definition/pilot - verified.", + "MissionSpPurchase.cost_at_purchase and name_at_purchase snapshot pattern is correct; choice_id kept as nullable FK so future catalog deletions won't cascade-corrupt history.", + "Cost stored as Float supports fractional values like mine_lt=0.5 - verified end-to-end.", + "No auth on POST endpoints - acceptable for current NAS-hosted single-user phase but should be flagged before multi-user exposure (documented risk, not a Phase 4 blocker)." + ], + "updated_files": [], + "success_rate": {"backend": "100%", "frontend": "100%"}, + "test_credentials": "N/A - no auth on API in this phase", + "seed_data_creation": "No new data created; migration was already run by main agent (idempotent second run verified 0 new links/purchases). No cleanup required.", + "retest_needed": false, + "should_main_agent_self_test": true, + "context_for_next_testing_agent": "Phases 1-4 all green. DB at /app/backend/data/btforcemanager.db has 16 achievement_definitions, 25 sp_choices, 6 pilot_achievements, 6 mission_sp_purchases. Real campaign data (ghost-bear pilot-1774842818222 has 'survivor'; ghost-bear M02 has 4x air_hvstrike sp purchases) is intact. Pre-existing frontend cold-fetch race on ghost-bear.json / 91st-division-vision-of-words.json is out of scope and documented in prior iterations.", + "rca of the issue": "N/A - no issues found" +} diff --git a/test_reports/pytest/pytest_results.xml b/test_reports/pytest/pytest_results.xml index 1169853..ed6dfa9 100644 --- a/test_reports/pytest/pytest_results.xml +++ b/test_reports/pytest/pytest_results.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From e9f72eb7243cf21e382060dcd607fb9670471b37 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 14:01:16 +0000 Subject: [PATCH 13/34] auto-commit for e161dfda-e304-4153-86f8-222d1833c294 --- memory/PRD.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/memory/PRD.md b/memory/PRD.md index 81f2b2b..d6a1343 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -36,17 +36,25 @@ Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremen ## Prioritized Backlog ### P0 (next phases per migration roadmap) -- Phase 4: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. -- Phase 5: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. -- Phase 6: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. +- Phase 5: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. +- Phase 6: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. +- Phase 7: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. ### P1 -- Investigate the pre-existing `ghost-bear.json` fetch race in `useForceManager.js`. -- Tighten CORS policy once auth/writes are introduced. +- Investigate the pre-existing `ghost-bear.json`/`91st-division-vision-of-words.json` fetch race in `useForceManager.js`. +- Tighten CORS policy and add auth once writes/multi-user exposure are introduced. - Consider case-insensitive uniqueness for special-abilities pool names if free-text entry is exposed in UI later. ## Next Tasks -- Await user's next user-story (Phase 4 scope) before proceeding. +- Await user's next user-story (Phase 5 scope) before proceeding. + +### Phase 4 (Reference Pools: Achievements & SP Purchases) - Done, tested 100% pass +- `backend/models.py`: added `AchievementDefinition` (id, name, icon, description, condition), `PilotAchievement` (autoincrement PK, pilot_id FK, achievement_id FK, earned_at nullable), `SpChoice` (id, name, cost as Float to support fractional prices like 0.5), `MissionSpPurchase` (id, mission_id FK, choice_id FK nullable, cost_at_purchase/name_at_purchase snapshots). Migration `a28c833e7254`. +- `backend/migrate_reference_data.py`: idempotent script - upserts global catalogs from `data/achievements.json` (16 defs) and `data/sp-choices.json` (25 choices), then get-or-create migrates each pilot's/mission's legacy JSON into `pilot_achievements`/`mission_sp_purchases` (6 links + 6 purchases from real data). +- `backend/routers/achievements.py`: `GET /api/achievement-definitions`, `GET/POST /api/pilots/{id}/achievements` (409 on duplicate, 404 on unknown pilot/definition). +- `backend/routers/sp_choices.py`: `GET /api/sp-choices`, `POST /api/missions/{id}/sp-purchases` (snapshots catalog name/cost at creation time - historical cost never changes even if catalog price is later edited). +- `GET /api/forces/{id}` pilots[].achievements and missions[].spPurchases now sourced from the normalized tables instead of raw JSON blobs. +- Verified core acceptance criteria: repeated purchases of the same choice create distinct line items (real example: ghost-bear M02 has 4 separate `air_hvstrike` entries); catalog price changes don't retroactively alter `cost_at_purchase`. All 17 backend tests pass (Phases 1-4). ### Phase 3 (Reference Pools: Special Abilities) - Done, tested 100% pass - `backend/models.py`: added `SpecialAbility` (id, name unique, description) and `ForceSpecialAbility` join table (composite PK force_id+ability_id). Migration `27b52250a900`. From cd16b67c9e04c54b0b3c4f56425c457cdc1196e5 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 14:40:25 +0000 Subject: [PATCH 14/34] Auto-generated changes --- .emergent/emergent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index 935e614..cdb3340 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", - "created_at": "2026-07-19T13:47:18.554405+00:00Z" + "created_at": "2026-07-19T14:40:24.987776+00:00Z" } From 6cb7ee3344f773f669de171635e78727f1eb0983 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 14:46:11 +0000 Subject: [PATCH 15/34] auto-commit for 7a7c7e05-f417-4c5c-8696-98a6bee9da73 --- ...c5b91ac451_pilot_special_abilities_pool.py | 47 ++++++++ backend/models.py | 17 +++ backend/routers/pilot_special_abilities.py | 106 +++++++++++++++++ backend/server.py | 2 + backend/tests/test_pilot_special_abilities.py | 108 ++++++++++++++++++ test_reports/iteration_5.json | 37 ++++++ test_reports/pytest/pytest_results.xml | 2 +- 7 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/81c5b91ac451_pilot_special_abilities_pool.py create mode 100644 backend/routers/pilot_special_abilities.py create mode 100644 backend/tests/test_pilot_special_abilities.py create mode 100644 test_reports/iteration_5.json diff --git a/backend/alembic/versions/81c5b91ac451_pilot_special_abilities_pool.py b/backend/alembic/versions/81c5b91ac451_pilot_special_abilities_pool.py new file mode 100644 index 0000000..6d1e4a5 --- /dev/null +++ b/backend/alembic/versions/81c5b91ac451_pilot_special_abilities_pool.py @@ -0,0 +1,47 @@ +"""pilot special abilities pool + +Revision ID: 81c5b91ac451 +Revises: a28c833e7254 +Create Date: 2026-07-19 14:43:57.714147 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '81c5b91ac451' +down_revision: Union[str, Sequence[str], None] = 'a28c833e7254' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('pilot_special_abilities', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_pilot_special_abilities_name'), 'pilot_special_abilities', ['name'], unique=True) + op.create_table('pilot_spa_assignments', + sa.Column('pilot_id', sa.String(), nullable=False), + sa.Column('spa_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['pilot_id'], ['pilots.id'], ), + sa.ForeignKeyConstraint(['spa_id'], ['pilot_special_abilities.id'], ), + sa.PrimaryKeyConstraint('pilot_id', 'spa_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('pilot_spa_assignments') + op.drop_index(op.f('ix_pilot_special_abilities_name'), table_name='pilot_special_abilities') + op.drop_table('pilot_special_abilities') + # ### end Alembic commands ### diff --git a/backend/models.py b/backend/models.py index dd6c417..9014eaf 100644 --- a/backend/models.py +++ b/backend/models.py @@ -171,3 +171,20 @@ class MissionSpPurchase(Base): choice_id: Mapped[str] = mapped_column(String, ForeignKey("sp_choices.id"), nullable=True) cost_at_purchase: Mapped[float] = mapped_column(Float, default=0) name_at_purchase: Mapped[str] = mapped_column(String, default="") + + +class PilotSpecialAbility(Base): + __tablename__ = "pilot_special_abilities" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String, unique=True, index=True) + description: Mapped[str] = mapped_column(Text, default="") + + +class PilotSpaAssignment(Base): + __tablename__ = "pilot_spa_assignments" + + pilot_id: Mapped[str] = mapped_column(String, ForeignKey("pilots.id"), primary_key=True) + spa_id: Mapped[int] = mapped_column( + Integer, ForeignKey("pilot_special_abilities.id"), primary_key=True + ) diff --git a/backend/routers/pilot_special_abilities.py b/backend/routers/pilot_special_abilities.py new file mode 100644 index 0000000..ff10fb3 --- /dev/null +++ b/backend/routers/pilot_special_abilities.py @@ -0,0 +1,106 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Pilot, PilotSpecialAbility, PilotSpaAssignment + +router = APIRouter(prefix="/api") + + +class PilotSpecialAbilityIn(BaseModel): + name: str + description: str = "" + + +class PilotSpaLinksIn(BaseModel): + spaIds: List[int] = [] + + +def spa_to_dict(a): + return {"id": a.id, "name": a.name, "description": a.description} + + +async def get_spas_for_pilot(session, pilot_id): + result = await session.execute( + select(PilotSpecialAbility) + .join(PilotSpaAssignment, PilotSpaAssignment.spa_id == PilotSpecialAbility.id) + .where(PilotSpaAssignment.pilot_id == pilot_id) + ) + return result.scalars().all() + + +@router.get("/pilot-special-abilities") +async def list_pilot_special_abilities(session: AsyncSession = Depends(get_session)): + abilities = (await session.execute(select(PilotSpecialAbility))).scalars().all() + return [spa_to_dict(a) for a in abilities] + + +@router.post("/pilot-special-abilities", status_code=201) +async def create_pilot_special_ability( + payload: PilotSpecialAbilityIn, session: AsyncSession = Depends(get_session) +): + existing = ( + await session.execute( + select(PilotSpecialAbility).where(PilotSpecialAbility.name == payload.name) + ) + ).scalar_one_or_none() + if existing: + raise HTTPException(status_code=409, detail="Pilot special ability with this name already exists") + + ability = PilotSpecialAbility(name=payload.name, description=payload.description) + session.add(ability) + await session.commit() + await session.refresh(ability) + return spa_to_dict(ability) + + +@router.delete("/pilot-special-abilities/{spa_id}", status_code=204) +async def delete_pilot_special_ability(spa_id: int, session: AsyncSession = Depends(get_session)): + ability = await session.get(PilotSpecialAbility, spa_id) + if not ability: + raise HTTPException(status_code=404, detail="Pilot special ability not found") + + await session.execute(delete(PilotSpaAssignment).where(PilotSpaAssignment.spa_id == spa_id)) + await session.delete(ability) + await session.commit() + return Response(status_code=204) + + +@router.get("/pilots/{pilot_id}/spa") +async def get_pilot_spa(pilot_id: str, session: AsyncSession = Depends(get_session)): + pilot = await session.get(Pilot, pilot_id) + if not pilot: + raise HTTPException(status_code=404, detail="Pilot not found") + + abilities = await get_spas_for_pilot(session, pilot_id) + return [spa_to_dict(a) for a in abilities] + + +@router.put("/pilots/{pilot_id}/spa") +async def set_pilot_spa( + pilot_id: str, payload: PilotSpaLinksIn, session: AsyncSession = Depends(get_session) +): + pilot = await session.get(Pilot, pilot_id) + if not pilot: + raise HTTPException(status_code=404, detail="Pilot not found") + + if payload.spaIds: + result = await session.execute( + select(PilotSpecialAbility.id).where(PilotSpecialAbility.id.in_(payload.spaIds)) + ) + found_ids = {row[0] for row in result.all()} + missing = set(payload.spaIds) - found_ids + if missing: + raise HTTPException(status_code=404, detail=f"Unknown pilot special ability id(s): {sorted(missing)}") + + await session.execute(delete(PilotSpaAssignment).where(PilotSpaAssignment.pilot_id == pilot_id)) + for spa_id in payload.spaIds: + session.add(PilotSpaAssignment(pilot_id=pilot_id, spa_id=spa_id)) + await session.commit() + + abilities = await get_spas_for_pilot(session, pilot_id) + return [spa_to_dict(a) for a in abilities] diff --git a/backend/server.py b/backend/server.py index 033a2b4..50c1cfc 100644 --- a/backend/server.py +++ b/backend/server.py @@ -12,6 +12,7 @@ from routers.special_abilities import router as special_abilities_router from routers.achievements import router as achievements_router from routers.sp_choices import router as sp_choices_router +from routers.pilot_special_abilities import router as pilot_special_abilities_router @asynccontextmanager @@ -56,3 +57,4 @@ async def health_check(): app.include_router(special_abilities_router) app.include_router(achievements_router) app.include_router(sp_choices_router) +app.include_router(pilot_special_abilities_router) diff --git a/backend/tests/test_pilot_special_abilities.py b/backend/tests/test_pilot_special_abilities.py new file mode 100644 index 0000000..f5714c0 --- /dev/null +++ b/backend/tests/test_pilot_special_abilities.py @@ -0,0 +1,108 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy import select, delete + +from server import app +from database import SessionLocal +from models import Force, Pilot, PilotSpecialAbility, PilotSpaAssignment + +TEST_FORCE_ID = "test-force-spa" +TEST_PILOT_ID = "test-pilot-spa" +TEST_SPA_NAME = "Weapon Specialist" + + +async def _cleanup(session): + await session.execute(delete(PilotSpaAssignment).where(PilotSpaAssignment.pilot_id == TEST_PILOT_ID)) + await session.execute(delete(Pilot).where(Pilot.id == TEST_PILOT_ID)) + await session.execute(delete(Force).where(Force.id == TEST_FORCE_ID)) + await session.execute(delete(PilotSpecialAbility).where(PilotSpecialAbility.name == TEST_SPA_NAME)) + await session.commit() + + +@pytest.mark.asyncio +async def test_pilot_spa_pool_crud_and_pilot_linking(): + async with SessionLocal() as session: + await _cleanup(session) + session.add(Force(id=TEST_FORCE_ID, name="Test Force SPA")) + session.add(Pilot(id=TEST_PILOT_ID, force_id=TEST_FORCE_ID, name="Test Pilot")) + await session.commit() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + empty_list_resp = await client.get(f"/api/pilots/{TEST_PILOT_ID}/spa") + assert empty_list_resp.status_code == 200 + assert empty_list_resp.json() == [] + + create_resp = await client.post( + "/api/pilot-special-abilities", + json={"name": TEST_SPA_NAME, "description": "+2 to-hit vs a chosen target type"}, + ) + assert create_resp.status_code == 201 + spa = create_resp.json() + spa_id = spa["id"] + assert spa["name"] == TEST_SPA_NAME + + dup_resp = await client.post( + "/api/pilot-special-abilities", json={"name": TEST_SPA_NAME, "description": "dup"} + ) + assert dup_resp.status_code == 409 + + list_resp = await client.get("/api/pilot-special-abilities") + assert list_resp.status_code == 200 + assert any(a["id"] == spa_id for a in list_resp.json()) + + link_resp = await client.put(f"/api/pilots/{TEST_PILOT_ID}/spa", json={"spaIds": [spa_id]}) + assert link_resp.status_code == 200 + linked = link_resp.json() + assert len(linked) == 1 + assert linked[0]["id"] == spa_id + + get_link_resp = await client.get(f"/api/pilots/{TEST_PILOT_ID}/spa") + assert get_link_resp.status_code == 200 + assert len(get_link_resp.json()) == 1 + + unlink_resp = await client.put(f"/api/pilots/{TEST_PILOT_ID}/spa", json={"spaIds": []}) + assert unlink_resp.status_code == 200 + assert unlink_resp.json() == [] + + bad_pilot_resp = await client.get("/api/pilots/does-not-exist/spa") + assert bad_pilot_resp.status_code == 404 + + bad_link_resp = await client.put(f"/api/pilots/{TEST_PILOT_ID}/spa", json={"spaIds": [999999]}) + assert bad_link_resp.status_code == 404 + + delete_resp = await client.delete(f"/api/pilot-special-abilities/{spa_id}") + assert delete_resp.status_code == 204 + + delete_missing_resp = await client.delete(f"/api/pilot-special-abilities/{spa_id}") + assert delete_missing_resp.status_code == 404 + + async with SessionLocal() as session: + await _cleanup(session) + + +@pytest.mark.asyncio +async def test_deleting_pilot_spa_cascades_assignments(): + async with SessionLocal() as session: + await _cleanup(session) + session.add(Force(id=TEST_FORCE_ID, name="Test Force SPA")) + session.add(Pilot(id=TEST_PILOT_ID, force_id=TEST_FORCE_ID, name="Test Pilot")) + ability = PilotSpecialAbility(name=TEST_SPA_NAME, description="test") + session.add(ability) + await session.commit() + await session.refresh(ability) + session.add(PilotSpaAssignment(pilot_id=TEST_PILOT_ID, spa_id=ability.id)) + await session.commit() + spa_id = ability.id + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + delete_resp = await client.delete(f"/api/pilot-special-abilities/{spa_id}") + assert delete_resp.status_code == 204 + + async with SessionLocal() as session: + remaining_links = ( + await session.execute(select(PilotSpaAssignment).where(PilotSpaAssignment.spa_id == spa_id)) + ).scalars().all() + assert remaining_links == [] + await _cleanup(session) diff --git a/test_reports/iteration_5.json b/test_reports/iteration_5.json new file mode 100644 index 0000000..4302dae --- /dev/null +++ b/test_reports/iteration_5.json @@ -0,0 +1,37 @@ +{ + "summary": "Phase 5 (Pilot SPA Pool) fully validated. All 19 pytest tests pass (17 prior + 2 new). All new endpoints verified via external ingress URL. Confirmed additive-only: GET /api/forces/{id} pilots do NOT contain 'spa' or 'specialAbilities' keys. Swagger UI and frontend still load 200. Zero regressions.", + "backend_issues": {"critical": [], "minor": []}, + "frontend_issues": {"ui_bugs": [], "integration_issues": [], "design_issues": []}, + "test_report_links": [ + "/app/backend/tests/test_pilot_special_abilities.py", + "/app/test_reports/pytest/pytest_results.xml" + ], + "action_items": [], + "critical_code_review_comments": [ + "PUT /api/pilots/{pilot_id}/spa correctly validates all spa ids exist before applying (no partial apply). Good.", + "DELETE cascades PilotSpaAssignment rows explicitly before deleting the SPA row - correct given SQLite FK cascade is not defined at schema level.", + "Router is clean and mirrors Phase 3 pattern; response models could optionally be Pydantic (currently returning dicts) but not blocking." + ], + "updated_files": [], + "success_rate": {"backend": "100%", "frontend": "100%"}, + "test_credentials": "N/A - no auth on API", + "seed_data_creation": "Created and cleaned up TEST_Marksman SPA via curl on external URL. Pool ended empty (verified).", + "retest_needed": false, + "should_main_agent_self_test": false, + "context_for_next_testing_agent": "Phase 5 additive-only. Pool tables: pilot_special_abilities, pilot_spa_assignments. Not wired into force serializer by design - do not flag its absence as a bug in future phases unless explicitly requested to wire it in.", + "verification_details": { + "pytest": "19/19 passed in 1.09s", + "external_url_checks": { + "GET /api/pilot-special-abilities": "200 []", + "POST /api/pilot-special-abilities": "201 with {id,name,description}", + "POST duplicate name": "409", + "DELETE /api/pilot-special-abilities/{id}": "204", + "DELETE again": "404", + "GET /api/pilots/{unknown}/spa": "404", + "PUT /api/pilots/{unknown}/spa": "404", + "GET /api/forces/{id} pilots keys": "['achievements','activityLog','combatRecord','dezgra','gunnery','history','id','injuries','name','piloting','warchestCost'] - no spa key (correct)", + "GET /api/docs": "200", + "GET /": "200" + } + } +} diff --git a/test_reports/pytest/pytest_results.xml b/test_reports/pytest/pytest_results.xml index ed6dfa9..ff4ccf0 100644 --- a/test_reports/pytest/pytest_results.xml +++ b/test_reports/pytest/pytest_results.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From a834395bbf09552712406237bacc34a1aa72ba35 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 14:46:44 +0000 Subject: [PATCH 16/34] auto-commit for c58fa6d1-117c-4aab-86f6-2a32b25ba8c0 --- memory/PRD.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/memory/PRD.md b/memory/PRD.md index d6a1343..125a94b 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -36,17 +36,23 @@ Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremen ## Prioritized Backlog ### P0 (next phases per migration roadmap) -- Phase 5: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. -- Phase 6: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. -- Phase 7: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. +- Phase 6: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. +- Phase 7: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. +- Phase 8: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. ### P1 - Investigate the pre-existing `ghost-bear.json`/`91st-division-vision-of-words.json` fetch race in `useForceManager.js`. - Tighten CORS policy and add auth once writes/multi-user exposure are introduced. - Consider case-insensitive uniqueness for special-abilities pool names if free-text entry is exposed in UI later. +- Pilot SPA pool (Phase 5) is intentionally not wired into `GET /api/forces/{id}` pilot serialization yet - wire in when a phase actually needs it. ## Next Tasks -- Await user's next user-story (Phase 5 scope) before proceeding. +- Await user's next user-story (Phase 6 scope) before proceeding. + +### Phase 5 (Pilot SPA Pool - Future-Proofing) - Done, tested 100% pass +- `backend/models.py`: added `PilotSpecialAbility` (id, name unique, description) and `PilotSpaAssignment` join table (composite PK pilot_id+spa_id) - mirrors Phase 3's force-special-abilities pattern but for pilots. Migration `81c5b91ac451`. +- `backend/routers/pilot_special_abilities.py`: `GET/POST /api/pilot-special-abilities`, `DELETE /api/pilot-special-abilities/{id}` (cascades assignment rows), `GET/PUT /api/pilots/{id}/spa`. +- Additive-only, zero-risk: no existing serializer/router touched; `GET /api/forces/{id}` pilots intentionally do NOT expose SPA data yet. All 19 backend tests pass (Phases 1-5), no regressions. ### Phase 4 (Reference Pools: Achievements & SP Purchases) - Done, tested 100% pass - `backend/models.py`: added `AchievementDefinition` (id, name, icon, description, condition), `PilotAchievement` (autoincrement PK, pilot_id FK, achievement_id FK, earned_at nullable), `SpChoice` (id, name, cost as Float to support fractional prices like 0.5), `MissionSpPurchase` (id, mission_id FK, choice_id FK nullable, cost_at_purchase/name_at_purchase snapshots). Migration `a28c833e7254`. From 30cab5f7e88b96a1d8d28d61aaf652b8b84a8458 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 15:22:51 +0000 Subject: [PATCH 17/34] Auto-generated changes --- .emergent/emergent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index cdb3340..a9ad3f8 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", - "created_at": "2026-07-19T14:40:24.987776+00:00Z" + "created_at": "2026-07-19T15:22:47.336363+00:00Z" } From 3447890806fb439bb5b23a559969d4939f09779b Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 15:29:22 +0000 Subject: [PATCH 18/34] auto-commit for 09e566fb-e34f-429a-8d01-fd9300070436 --- .../be34ee216040_mech_catalog_table.py | 48 ++++++++ backend/import_mech_catalog.py | 107 ++++++++++++++++++ backend/models.py | 15 +++ backend/routers/mech_catalog.py | 49 ++++++++ backend/server.py | 2 + backend/tests/test_mech_catalog.py | 99 ++++++++++++++++ test_reports/iteration_6.json | 32 ++++++ test_reports/pytest/pytest_results.xml | 2 +- 8 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/be34ee216040_mech_catalog_table.py create mode 100644 backend/import_mech_catalog.py create mode 100644 backend/routers/mech_catalog.py create mode 100644 backend/tests/test_mech_catalog.py create mode 100644 test_reports/iteration_6.json diff --git a/backend/alembic/versions/be34ee216040_mech_catalog_table.py b/backend/alembic/versions/be34ee216040_mech_catalog_table.py new file mode 100644 index 0000000..f05db3b --- /dev/null +++ b/backend/alembic/versions/be34ee216040_mech_catalog_table.py @@ -0,0 +1,48 @@ +"""mech catalog table + +Revision ID: be34ee216040 +Revises: 81c5b91ac451 +Create Date: 2026-07-19 15:24:42.340407 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'be34ee216040' +down_revision: Union[str, Sequence[str], None] = '81c5b91ac451' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('mech_catalog', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('mul_id', sa.Integer(), nullable=True), + sa.Column('chassis', sa.String(), nullable=False), + sa.Column('model', sa.String(), nullable=False), + sa.Column('bv', sa.Integer(), nullable=False), + sa.Column('tonnage', sa.Integer(), nullable=False), + sa.Column('year', sa.Integer(), nullable=True), + sa.Column('techbase', sa.String(), nullable=True), + sa.Column('role', sa.String(), nullable=True), + sa.Column('updated_at', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_mech_catalog_chassis'), 'mech_catalog', ['chassis'], unique=False) + op.create_index(op.f('ix_mech_catalog_mul_id'), 'mech_catalog', ['mul_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_mech_catalog_mul_id'), table_name='mech_catalog') + op.drop_index(op.f('ix_mech_catalog_chassis'), table_name='mech_catalog') + op.drop_table('mech_catalog') + # ### end Alembic commands ### diff --git a/backend/import_mech_catalog.py b/backend/import_mech_catalog.py new file mode 100644 index 0000000..59d0b43 --- /dev/null +++ b/backend/import_mech_catalog.py @@ -0,0 +1,107 @@ +"""One-time bulk-load of data/mek_catalog.csv into the mech_catalog table. + +Idempotent re-import: entries with a mul_id are matched/updated by mul_id; +entries without a mul_id (some catalog rows have none) are matched/updated +by (chassis, model) instead, so re-running never creates duplicate rows. + +Usage: + cd backend && python import_mech_catalog.py +""" +import asyncio +import csv +from datetime import datetime, timezone +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +from sqlalchemy import select + +from database import SessionLocal, engine +from models import MechCatalogEntry + +REPO_ROOT = Path(__file__).resolve().parent.parent +CSV_PATH = REPO_ROOT / "data" / "mek_catalog.csv" + + +def parse_int(value): + if value is None: + return None + value = value.strip() + if not value: + return None + try: + return int(float(value)) + except ValueError: + return None + + +async def import_catalog(session): + created, updated = 0, 0 + now = datetime.now(timezone.utc).isoformat() + + existing_rows = (await session.execute(select(MechCatalogEntry))).scalars().all() + by_mul_id = {row.mul_id: row for row in existing_rows if row.mul_id is not None} + by_chassis_model = { + (row.chassis, row.model): row for row in existing_rows if row.mul_id is None + } + + with open(CSV_PATH, encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + for row in reader: + chassis = (row.get("chassis") or "").strip() + if not chassis: + continue + model = (row.get("model") or "").strip() + mul_id = parse_int(row.get("mul_id")) + bv = parse_int(row.get("BV")) or 0 + tonnage = parse_int(row.get("tonnage")) or 0 + year = parse_int(row.get("year")) + techbase = (row.get("techBase") or "").strip() or None + role = (row.get("role") or "").strip() or None + + existing = by_mul_id.get(mul_id) if mul_id is not None else by_chassis_model.get((chassis, model)) + + if existing: + existing.chassis = chassis + existing.model = model + existing.bv = bv + existing.tonnage = tonnage + existing.year = year + existing.techbase = techbase + existing.role = role + existing.updated_at = now + updated += 1 + else: + entry = MechCatalogEntry( + mul_id=mul_id, + chassis=chassis, + model=model, + bv=bv, + tonnage=tonnage, + year=year, + techbase=techbase, + role=role, + updated_at=now, + ) + session.add(entry) + if mul_id is not None: + by_mul_id[mul_id] = entry + else: + by_chassis_model[(chassis, model)] = entry + created += 1 + + return created, updated + + +async def main(): + async with SessionLocal() as session: + async with session.begin(): + created, updated = await import_catalog(session) + await engine.dispose() + print(f"Mech catalog import done. Created {created}, updated {updated}.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/models.py b/backend/models.py index 9014eaf..af4f171 100644 --- a/backend/models.py +++ b/backend/models.py @@ -188,3 +188,18 @@ class PilotSpaAssignment(Base): spa_id: Mapped[int] = mapped_column( Integer, ForeignKey("pilot_special_abilities.id"), primary_key=True ) + + +class MechCatalogEntry(Base): + __tablename__ = "mech_catalog" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + mul_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=True, index=True) + chassis: Mapped[str] = mapped_column(String, index=True, default="") + model: Mapped[str] = mapped_column(String, default="") + bv: Mapped[int] = mapped_column(Integer, default=0) + tonnage: Mapped[int] = mapped_column(Integer, default=0) + year: Mapped[int] = mapped_column(Integer, nullable=True) + techbase: Mapped[str] = mapped_column(String, nullable=True) + role: Mapped[str] = mapped_column(String, nullable=True) + updated_at: Mapped[str] = mapped_column(String, default="") diff --git a/backend/routers/mech_catalog.py b/backend/routers/mech_catalog.py new file mode 100644 index 0000000..3abdf2c --- /dev/null +++ b/backend/routers/mech_catalog.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import MechCatalogEntry + +router = APIRouter(prefix="/api") + +MAX_RESULTS = 50 +MIN_SEARCH_LENGTH = 2 + + +def catalog_entry_name(chassis, model): + return f"{chassis} {model}" if model else chassis + + +def catalog_entry_to_dict(entry): + return { + "id": entry.id, + "mulId": entry.mul_id, + "chassis": entry.chassis, + "model": entry.model, + "name": catalog_entry_name(entry.chassis, entry.model), + "bv": entry.bv, + "tonnage": entry.tonnage, + "year": entry.year, + "techbase": entry.techbase, + "role": entry.role, + } + + +@router.get("/mech-catalog") +async def search_mech_catalog(search: str = "", session: AsyncSession = Depends(get_session)): + if len(search.strip()) < MIN_SEARCH_LENGTH: + return [] + + search_lower = search.strip().lower() + entries = (await session.execute(select(MechCatalogEntry))).scalars().all() + + matches = [ + entry + for entry in entries + if search_lower in catalog_entry_name(entry.chassis, entry.model).lower() + or search_lower in (entry.chassis or "").lower() + or search_lower in (entry.model or "").lower() + ] + + return [catalog_entry_to_dict(e) for e in matches[:MAX_RESULTS]] diff --git a/backend/server.py b/backend/server.py index 50c1cfc..f2bd423 100644 --- a/backend/server.py +++ b/backend/server.py @@ -13,6 +13,7 @@ from routers.achievements import router as achievements_router from routers.sp_choices import router as sp_choices_router from routers.pilot_special_abilities import router as pilot_special_abilities_router +from routers.mech_catalog import router as mech_catalog_router @asynccontextmanager @@ -58,3 +59,4 @@ async def health_check(): app.include_router(achievements_router) app.include_router(sp_choices_router) app.include_router(pilot_special_abilities_router) +app.include_router(mech_catalog_router) diff --git a/backend/tests/test_mech_catalog.py b/backend/tests/test_mech_catalog.py new file mode 100644 index 0000000..670fb9b --- /dev/null +++ b/backend/tests/test_mech_catalog.py @@ -0,0 +1,99 @@ +import csv +from pathlib import Path + +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy import select, func + +from server import app +from database import SessionLocal +from models import MechCatalogEntry +from import_mech_catalog import import_catalog + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +CSV_PATH = REPO_ROOT / "data" / "mek_catalog.csv" + + +def count_unique_csv_entries(): + with open(CSV_PATH, encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + keys = set() + for row in reader: + chassis = (row.get("chassis") or "").strip() + if not chassis: + continue + model = (row.get("model") or "").strip() + mul_id = (row.get("mul_id") or "").strip() + key = ("mul", mul_id) if mul_id else ("cm", chassis, model) + keys.add(key) + return len(keys) + + +@pytest.mark.asyncio +async def test_reimport_is_idempotent_and_row_count_matches_unique_csv_entries(): + expected_unique = count_unique_csv_entries() + + async with SessionLocal() as session: + async with session.begin(): + await import_catalog(session) + + async with SessionLocal() as session: + count_after_first = ( + await session.execute(select(func.count()).select_from(MechCatalogEntry)) + ).scalar_one() + + async with SessionLocal() as session: + async with session.begin(): + created2, _updated2 = await import_catalog(session) + + async with SessionLocal() as session: + count_after_second = ( + await session.execute(select(func.count()).select_from(MechCatalogEntry)) + ).scalar_one() + + assert count_after_first == expected_unique + assert count_after_second == expected_unique + assert created2 == 0, "second import must not create any new rows" + assert count_after_first == count_after_second + + +@pytest.mark.asyncio +async def test_search_below_min_length_returns_empty(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/mech-catalog", params={"search": "a"}) + assert resp.status_code == 200 + assert resp.json() == [] + + +@pytest.mark.asyncio +async def test_search_accuracy_matches_chassis_and_model(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/mech-catalog", params={"search": "atlas"}) + assert resp.status_code == 200 + results = resp.json() + assert len(results) > 0 + assert all("atlas" in r["name"].lower() for r in results) + + resp2 = await client.get("/api/mech-catalog", params={"search": "AS7-D"}) + assert resp2.status_code == 200 + assert any(r["model"] == "AS7-D" for r in resp2.json()) + + +@pytest.mark.asyncio +async def test_search_results_capped_at_50(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/mech-catalog", params={"search": "e"}) + assert resp.status_code == 200 + assert len(resp.json()) <= 50 + + +@pytest.mark.asyncio +async def test_search_no_results_for_unknown_term(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/mech-catalog", params={"search": "zzzznotamechzzzz"}) + assert resp.status_code == 200 + assert resp.json() == [] diff --git a/test_reports/iteration_6.json b/test_reports/iteration_6.json new file mode 100644 index 0000000..004dc10 --- /dev/null +++ b/test_reports/iteration_6.json @@ -0,0 +1,32 @@ +{ + "summary": "Phase 6 (Mech Catalog Table) validated end-to-end. Import script idempotent with expected counts, all 5 new pytest tests pass, full backend suite 24/24 passing, live /api/mech-catalog endpoint returns correct data through the public ingress, and the untouched client-side MechAutocomplete regression still works.", + "backend_issues": {"critical": [], "minor": []}, + "frontend_issues": {"ui_bugs": [], "integration_issues": [], "design_issues": []}, + "verification_details": { + "import_fresh_run": "Created 3861, updated 6 (matches spec exactly)", + "import_second_run": "Created 0, updated 3867 (fully idempotent)", + "final_row_count": 3861, + "api_health": "{\"status\":\"ok\",\"db\":\"connected\"}", + "search_atlas": "39 results; Atlas AS7-D confirmed mulId=140, bv=1897, tonnage=100", + "search_1_char_a": "0 results (min-length rule enforced)", + "search_no_match_zzzznotamechzzzz": "0 results", + "search_er_cap": "exactly 50 results (cap enforced)", + "search_AS7-D_model_match": "13 results; Atlas AS7-D returned by model-only match", + "swagger_docs": "HTTP 200 at /api/docs", + "regression_forces": "GET /api/forces returns 2 forces", + "frontend_regression": "Home page loads (title=BattleTech Forces Manager); Add Mech modal opens; typing 'atlas' in client-side autocomplete returns Atlas AS7-00 (Jurn), AS7-A, AS7-C, AS7-CM, AS7-D (BV 1897), AS7-D (Danielle), AS7-D-DC etc. Client-side CSV path untouched and working." + }, + "pytest_results": "24 passed in 1.65s (5 new Phase 6 tests + 19 prior)", + "test_report_links": ["/app/backend/tests/test_mech_catalog.py", "/app/test_reports/pytest/pytest_results.xml"], + "action_items": [], + "critical_code_review_comments": [ + "routers/mech_catalog.py, models.py, import_mech_catalog.py all read clean: async-safe patterns, indexed columns (mul_id unique+nullable, chassis indexed), correct BOM-tolerant CSV parsing, dedupe fallback by (chassis, model) for the ~89 mul_id-less rows. Result cap and min-length replicate MechAutocomplete.jsx semantics exactly.", + "Endpoint is unauthenticated as noted in the request; fine for LAN NAS scope but worth flagging when auth phase lands." + ], + "updated_files": [], + "success_rate": {"backend": "100%", "frontend": "100%"}, + "seed_data_creation": "None. Import script only touches mech_catalog reference table (idempotent).", + "retest_needed": false, + "should_main_agent_self_test": false, + "context_for_next_testing_agent": "Phase 6 fully green. mech_catalog table has 3861 rows. Backend endpoint /api/mech-catalog is live but not yet wired into the SPA (MechAutocomplete.jsx still loads the CSV client-side). Future phase will swap the frontend to consume this endpoint." +} diff --git a/test_reports/pytest/pytest_results.xml b/test_reports/pytest/pytest_results.xml index ff4ccf0..79d500c 100644 --- a/test_reports/pytest/pytest_results.xml +++ b/test_reports/pytest/pytest_results.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From d3ecfba1446117baffed9cdd4e6b7eac8f4675bb Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 15:29:56 +0000 Subject: [PATCH 19/34] auto-commit for d00c667d-9a7d-4a81-8892-e3ef08aee6a3 --- memory/PRD.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/memory/PRD.md b/memory/PRD.md index 125a94b..d84dc4c 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -36,9 +36,9 @@ Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremen ## Prioritized Backlog ### P0 (next phases per migration roadmap) -- Phase 6: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. -- Phase 7: Wire frontend (`useForceManager.js`) to consume the new API instead of static JSON fetch; add `REACT_APP_BACKEND_URL`. -- Phase 8: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. +- Phase 7: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. +- Phase 8: Wire frontend to consume the new API instead of static JSON fetch + client-side CSV parsing (incl. swapping `MechAutocomplete.jsx` to `/api/mech-catalog`); add `REACT_APP_BACKEND_URL`. +- Phase 9: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. ### P1 - Investigate the pre-existing `ghost-bear.json`/`91st-division-vision-of-words.json` fetch race in `useForceManager.js`. @@ -47,7 +47,13 @@ Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremen - Pilot SPA pool (Phase 5) is intentionally not wired into `GET /api/forces/{id}` pilot serialization yet - wire in when a phase actually needs it. ## Next Tasks -- Await user's next user-story (Phase 6 scope) before proceeding. +- Await user's next user-story (Phase 7 scope) before proceeding. + +### Phase 6 (Mech Catalog Table) - Done, tested 100% pass +- `backend/models.py`: added `MechCatalogEntry` (id, mul_id unique+nullable, chassis, model, bv, tonnage, year, techbase, role, updated_at). Migration `be34ee216040`. +- `backend/import_mech_catalog.py`: idempotent bulk-import from `data/mek_catalog.csv` (3867 rows -> 3861 unique entries; dedupes by mul_id, falls back to (chassis, model) for the ~89 rows without a mul_id). Verified: fresh run "Created 3861, updated 6"; re-run "Created 0, updated 3867" - fully idempotent, correct final count. +- `backend/routers/mech_catalog.py`: `GET /api/mech-catalog?search=` - replicates `MechAutocomplete.jsx`'s exact UX (min 2 chars, case-insensitive substring on chassis/model/combined name, capped at 50 results). Verified live via ingress. +- Additive-only: frontend's `MechAutocomplete.jsx` still does its own client-side CSV fetch+parse, untouched and confirmed still working (regression-tested). All 24 backend tests pass (Phases 1-6). ### Phase 5 (Pilot SPA Pool - Future-Proofing) - Done, tested 100% pass - `backend/models.py`: added `PilotSpecialAbility` (id, name unique, description) and `PilotSpaAssignment` join table (composite PK pilot_id+spa_id) - mirrors Phase 3's force-special-abilities pattern but for pilots. Migration `81c5b91ac451`. From 72db81cd18321d50d13c1711ac5207471dea8919 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 16:54:46 +0000 Subject: [PATCH 20/34] Auto-generated changes --- .emergent/emergent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index a9ad3f8..18500af 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", - "created_at": "2026-07-19T15:22:47.336363+00:00Z" + "created_at": "2026-07-19T16:54:43.085644+00:00Z" } From 300d7e09777b100c84212cf0c62dbcc73b50b0d3 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 17:03:34 +0000 Subject: [PATCH 21/34] auto-commit for 9170fce3-b451-48f9-a0ab-64e10724eced --- backend/requirements.txt | 1 + backend/routers/mech_catalog.py | 6 + backend/server.py | 4 + backend/tests/test_watcher.py | 179 ++++++++++++++++++ backend/watcher.py | 249 +++++++++++++++++++++++++ test_reports/iteration_7.json | 23 +++ test_reports/pytest/pytest_results.xml | 2 +- 7 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_watcher.py create mode 100644 backend/watcher.py create mode 100644 test_reports/iteration_7.json diff --git a/backend/requirements.txt b/backend/requirements.txt index 3a06844..2bb5879 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -128,6 +128,7 @@ uritemplate==4.2.0 urllib3==2.7.0 uvicorn==0.25.0 uvloop==0.22.1 +watchdog==6.0.0 watchfiles==1.2.0 websockets==16.1 yarl==1.24.2 diff --git a/backend/routers/mech_catalog.py b/backend/routers/mech_catalog.py index 3abdf2c..ee8043e 100644 --- a/backend/routers/mech_catalog.py +++ b/backend/routers/mech_catalog.py @@ -4,6 +4,7 @@ from database import get_session from models import MechCatalogEntry +import watcher router = APIRouter(prefix="/api") @@ -47,3 +48,8 @@ async def search_mech_catalog(search: str = "", session: AsyncSession = Depends( ] return [catalog_entry_to_dict(e) for e in matches[:MAX_RESULTS]] + + +@router.get("/mech-catalog/import-status") +async def get_mech_catalog_import_status(): + return watcher.get_status() diff --git a/backend/server.py b/backend/server.py index f2bd423..71cd84a 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +import asyncio from dotenv import load_dotenv load_dotenv() @@ -8,6 +9,7 @@ from sqlalchemy import text from database import engine +import watcher from routers.forces import router as forces_router from routers.special_abilities import router as special_abilities_router from routers.achievements import router as achievements_router @@ -18,7 +20,9 @@ @asynccontextmanager async def lifespan(app: FastAPI): + watcher.start_watcher(asyncio.get_event_loop()) yield + watcher.stop_watcher() await engine.dispose() diff --git a/backend/tests/test_watcher.py b/backend/tests/test_watcher.py new file mode 100644 index 0000000..e9a2e8f --- /dev/null +++ b/backend/tests/test_watcher.py @@ -0,0 +1,179 @@ +import asyncio +import tempfile +import threading +import time +from pathlib import Path + +import pytest +from sqlalchemy import select, delete +from watchdog.observers import Observer + +from dotenv import load_dotenv +load_dotenv() + +from database import SessionLocal +from models import MechCatalogEntry +from watcher import ( + validate_header, + process_csv_file, + handle_dropped_file, + start_watcher, + stop_watcher, + _DebouncedCsvHandler, +) + +TEST_MUL_IDS = [990001, 990002] + +VALID_CSV = """chassis,model,mul_id,year,BV,tonnage,techBase,role +Test Watcher Mech,TW-1,990001,3050,1500,50,Inner Sphere,Skirmisher +Test Watcher Mech,TW-1,990001,3055,1600,50,Inner Sphere,Skirmisher +""" + +MALFORMED_CSV = """foo,bar,baz +1,2,3 +""" + + +async def _cleanup(): + async with SessionLocal() as session: + await session.execute(delete(MechCatalogEntry).where(MechCatalogEntry.mul_id.in_(TEST_MUL_IDS))) + await session.commit() + + +def test_validate_header(): + assert validate_header(["chassis", "model", "mul_id", "BV", "tonnage", "year"]) is True + assert validate_header(["chassis", "model"]) is False + assert validate_header(None) is False + assert validate_header([]) is False + + +@pytest.mark.asyncio +async def test_process_csv_file_upserts_by_mul_id_within_same_file(): + await _cleanup() + with tempfile.TemporaryDirectory() as tmp: + csv_path = Path(tmp) / "drop.csv" + csv_path.write_text(VALID_CSV) + + async with SessionLocal() as session: + async with session.begin(): + result = await process_csv_file(session, csv_path) + + assert result["status"] == "ok" + assert result["rows"] == 2 + assert result["created"] == 1 + assert result["updated"] == 1 + + async with SessionLocal() as session: + entries = ( + await session.execute(select(MechCatalogEntry).where(MechCatalogEntry.mul_id == 990001)) + ).scalars().all() + assert len(entries) == 1 + assert entries[0].bv == 1600 + + await _cleanup() + + +@pytest.mark.asyncio +async def test_process_csv_file_rejects_malformed_header(): + with tempfile.TemporaryDirectory() as tmp: + csv_path = Path(tmp) / "bad.csv" + csv_path.write_text(MALFORMED_CSV) + + async with SessionLocal() as session: + result = await process_csv_file(session, csv_path) + + assert result["status"] == "error" + assert "Missing required header" in result["reason"] + + +@pytest.mark.asyncio +async def test_handle_dropped_file_archives_valid_file_with_timestamp(): + await _cleanup() + with tempfile.TemporaryDirectory() as tmp: + watch_dir = Path(tmp) + csv_path = watch_dir / "good.csv" + csv_path.write_text(VALID_CSV) + + async with SessionLocal() as session: + async with session.begin(): + result = await handle_dropped_file(session, csv_path, watch_dir) + + assert result["status"] == "ok" + assert not csv_path.exists() + archived = Path(result["archivedTo"]) + assert archived.exists() + assert archived.parent == watch_dir / "processed" + assert archived.name.startswith("good_") + + await _cleanup() + + +@pytest.mark.asyncio +async def test_handle_dropped_file_quarantines_malformed_file_with_log(): + with tempfile.TemporaryDirectory() as tmp: + watch_dir = Path(tmp) + csv_path = watch_dir / "bad.csv" + csv_path.write_text(MALFORMED_CSV) + + async with SessionLocal() as session: + result = await handle_dropped_file(session, csv_path, watch_dir) + + assert result["status"] == "error" + assert not csv_path.exists() + moved = Path(result["movedTo"]) + log_path = Path(result["logPath"]) + assert moved.exists() + assert moved.parent == watch_dir / "errors" + assert log_path.exists() + assert "Missing required header" in log_path.read_text() + + +def test_real_filesystem_drop_is_detected_and_processed_end_to_end(): + """Full watchdog.Observer integration test against a temp directory - + no real NAS folder needed, verifiable in CI.""" + with tempfile.TemporaryDirectory() as tmp: + watch_dir = Path(tmp) + loop = asyncio.new_event_loop() + + def run_loop(): + asyncio.set_event_loop(loop) + loop.run_forever() + + thread = threading.Thread(target=run_loop, daemon=True) + thread.start() + + handler = _DebouncedCsvHandler(loop, watch_dir, debounce_seconds=0.3) + observer = Observer() + observer.schedule(handler, str(watch_dir), recursive=False) + observer.start() + + try: + csv_path = watch_dir / "live_drop.csv" + csv_path.write_text(VALID_CSV) + + deadline = time.time() + 5 + processed_dir = watch_dir / "processed" + while time.time() < deadline: + if processed_dir.exists() and any(processed_dir.iterdir()): + break + time.sleep(0.2) + + assert processed_dir.exists() + assert any(processed_dir.iterdir()), "dropped file was not picked up and processed in time" + assert not csv_path.exists() + finally: + observer.stop() + observer.join(timeout=5) + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) + + asyncio.run(_cleanup()) + + +def test_start_watcher_is_disabled_when_env_var_not_set(monkeypatch): + monkeypatch.delenv("MEK_CATALOG_WATCH_DIR", raising=False) + loop = asyncio.new_event_loop() + observer = start_watcher(loop) + assert observer is None + stop_watcher() + loop.close() diff --git a/backend/watcher.py b/backend/watcher.py new file mode 100644 index 0000000..febabf0 --- /dev/null +++ b/backend/watcher.py @@ -0,0 +1,249 @@ +"""Watched-folder auto-import for the mech catalog. + +Monitors MEK_CATALOG_WATCH_DIR (if set) for dropped *.csv files, debounced on +write-completion, and upserts rows into mech_catalog keyed on mul_id. +Processed files are archived with a timestamp; malformed files (missing +required header columns) are moved to an errors/ subfolder alongside a log +explaining why. + +The file-processing logic (`process_csv_file`, `handle_dropped_file`) is +pure/async and takes no dependency on watchdog, so it's directly unit +testable against a temp directory without spinning up a real filesystem +watcher. `start_watcher`/`stop_watcher` wire that logic to a real +`watchdog.Observer` for the running app. +""" +import asyncio +import csv +import logging +import os +import shutil +import threading +from datetime import datetime, timezone +from pathlib import Path + +from sqlalchemy import select +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +from database import SessionLocal +from models import MechCatalogEntry + +logger = logging.getLogger("mech_catalog_watcher") + +REQUIRED_HEADERS = {"chassis", "model", "mul_id", "BV", "tonnage"} +MAX_HISTORY = 20 + +_observer = None +_status = { + "enabled": False, + "watchDir": None, + "running": False, + "debounceSeconds": None, +} +_history = [] + + +def get_status(): + return {**_status, "recentImports": list(reversed(_history[-MAX_HISTORY:]))} + + +def _record_history(entry): + _history.append(entry) + del _history[:-MAX_HISTORY] + + +def _parse_int(value): + if value is None: + return None + value = value.strip() + if not value: + return None + try: + return int(float(value)) + except ValueError: + return None + + +def validate_header(fieldnames): + if not fieldnames: + return False + return REQUIRED_HEADERS.issubset(set(fieldnames)) + + +async def upsert_rows_by_mul_id(session, rows): + """Upsert catalog rows keyed strictly on mul_id. Rows missing chassis or + mul_id are counted as skipped (this watcher assumes incoming drops always + carry a mul_id, unlike Phase 6's bulk loader which also handles blanks).""" + created = updated = skipped = 0 + for row in rows: + chassis = (row.get("chassis") or "").strip() + mul_id = _parse_int(row.get("mul_id")) + if not chassis or mul_id is None: + skipped += 1 + continue + + model = (row.get("model") or "").strip() + bv = _parse_int(row.get("BV")) or 0 + tonnage = _parse_int(row.get("tonnage")) or 0 + year = _parse_int(row.get("year")) + techbase = (row.get("techBase") or "").strip() or None + role = (row.get("role") or "").strip() or None + + existing = ( + await session.execute(select(MechCatalogEntry).where(MechCatalogEntry.mul_id == mul_id)) + ).scalar_one_or_none() + + now = datetime.now(timezone.utc).isoformat() + if existing: + existing.chassis = chassis + existing.model = model + existing.bv = bv + existing.tonnage = tonnage + existing.year = year + existing.techbase = techbase + existing.role = role + existing.updated_at = now + updated += 1 + else: + session.add( + MechCatalogEntry( + mul_id=mul_id, + chassis=chassis, + model=model, + bv=bv, + tonnage=tonnage, + year=year, + techbase=techbase, + role=role, + updated_at=now, + ) + ) + created += 1 + + return created, updated, skipped + + +async def process_csv_file(session, filepath: Path) -> dict: + """Validate + import a single CSV file. Does not touch the filesystem + beyond reading, so this is directly unit testable.""" + with open(filepath, encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + if not validate_header(reader.fieldnames): + return { + "status": "error", + "reason": f"Missing required header column(s). Found: {reader.fieldnames}", + } + rows = list(reader) + + created, updated, skipped = await upsert_rows_by_mul_id(session, rows) + return {"status": "ok", "rows": len(rows), "created": created, "updated": updated, "skipped": skipped} + + +async def handle_dropped_file(session, filepath: Path, watch_dir: Path) -> dict: + """Process a dropped file end-to-end: validate/import, then archive + (processed/) or quarantine (errors/ + a .log) depending on the outcome.""" + processed_dir = watch_dir / "processed" + errors_dir = watch_dir / "errors" + processed_dir.mkdir(parents=True, exist_ok=True) + errors_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + result = await process_csv_file(session, filepath) + result["filename"] = filepath.name + result["timestamp"] = timestamp + + if result["status"] == "ok": + dest = processed_dir / f"{filepath.stem}_{timestamp}{filepath.suffix}" + shutil.move(str(filepath), str(dest)) + result["archivedTo"] = str(dest) + else: + dest = errors_dir / f"{filepath.stem}_{timestamp}{filepath.suffix}" + shutil.move(str(filepath), str(dest)) + log_path = errors_dir / f"{filepath.stem}_{timestamp}.log" + log_path.write_text(f"{timestamp} - {result['reason']}\n") + result["movedTo"] = str(dest) + result["logPath"] = str(log_path) + + _record_history(result) + return result + + +class _DebouncedCsvHandler(FileSystemEventHandler): + def __init__(self, loop, watch_dir, debounce_seconds): + self.loop = loop + self.watch_dir = watch_dir + self.debounce_seconds = debounce_seconds + self._timers = {} + self._lock = threading.Lock() + + def _schedule(self, src_path): + if not src_path.lower().endswith(".csv"): + return + with self._lock: + existing_timer = self._timers.get(src_path) + if existing_timer: + existing_timer.cancel() + timer = threading.Timer(self.debounce_seconds, self._fire, args=(src_path,)) + self._timers[src_path] = timer + timer.daemon = True + timer.start() + + def _fire(self, src_path): + with self._lock: + self._timers.pop(src_path, None) + path = Path(src_path) + if not path.exists(): + return + asyncio.run_coroutine_threadsafe(self._process(path), self.loop) + + async def _process(self, path): + try: + async with SessionLocal() as session: + async with session.begin(): + await handle_dropped_file(session, path, self.watch_dir) + except Exception: + logger.exception("Failed to process dropped mech catalog file %s", path) + + def on_created(self, event): + if not event.is_directory: + self._schedule(event.src_path) + + def on_modified(self, event): + if not event.is_directory: + self._schedule(event.src_path) + + +def start_watcher(loop): + global _observer + + watch_dir_env = os.environ.get("MEK_CATALOG_WATCH_DIR") + _status["debounceSeconds"] = float(os.environ.get("MEK_CATALOG_WATCH_DEBOUNCE_SECONDS", "2")) + + if not watch_dir_env: + _status["enabled"] = False + _status["watchDir"] = None + _status["running"] = False + return None + + watch_dir = Path(watch_dir_env) + watch_dir.mkdir(parents=True, exist_ok=True) + + handler = _DebouncedCsvHandler(loop, watch_dir, _status["debounceSeconds"]) + observer = Observer() + observer.schedule(handler, str(watch_dir), recursive=False) + observer.start() + + _observer = observer + _status["enabled"] = True + _status["watchDir"] = str(watch_dir) + _status["running"] = True + return observer + + +def stop_watcher(): + global _observer + if _observer: + _observer.stop() + _observer.join(timeout=5) + _observer = None + _status["running"] = False diff --git a/test_reports/iteration_7.json b/test_reports/iteration_7.json new file mode 100644 index 0000000..9238488 --- /dev/null +++ b/test_reports/iteration_7.json @@ -0,0 +1,23 @@ +{ + "summary": "Phase 7 watched-folder auto-import validated end-to-end: 31/31 pytest tests pass (24 regression + 7 new watcher tests including real watchdog.Observer integration). Live drop tests against the real /app/backend/data/mech_catalog_watch/ dir all succeed: valid CSV is archived to processed/ with timestamp, malformed CSV is quarantined to errors/ with matching .log, import-status endpoint reflects recent history correctly, and duplicate mul_id within one file upserts (created=1, updated=1). Regression: Phase 1-6 endpoints and Swagger (/api/openapi.json now lists /api/mech-catalog/import-status) all still work. Frontend root returns HTTP 200.", + "backend_issues": {"critical": [], "minor": []}, + "frontend_issues": {"ui_bugs": [], "integration_issues": [], "design_issues": []}, + "test_report_links": [ + "/app/backend/tests/test_watcher.py", + "/app/test_reports/pytest/pytest_results.xml" + ], + "action_items": [], + "critical_code_review_comments": [ + "watcher._history is a module-level list guarded implicitly by GIL only; concurrent debounce timers could race. Not a bug in practice for CPython append/slice but worth a threading.Lock if traffic grows.", + "process_csv_file opens the file synchronously inside an async function (blocking). Fine for small NAS drops but could stall the event loop for very large CSVs - consider aiofiles or run_in_executor.", + "handle_dropped_file processes even when the file has zero data rows successfully (rows=0 counted as 'ok'). Acceptable, but a warning-level history entry might be friendlier." + ], + "updated_files": [], + "success_rate": {"backend": "100%", "frontend": "100%"}, + "test_credentials": "N/A - no auth on this API", + "seed_data_creation": "Live drop tests created mul_id 999555 (LiveTestMech) and 999556 (UpsertMech); both DB rows and their archived files under processed/ were deleted after verification. Watch dir is back to empty processed/ and errors/ subfolders.", + "retest_needed": false, + "main_agent_can_self_test": true, + "context_for_next_testing_agent": "Phase 7 watcher is fully wired and running in the sandbox (MEK_CATALOG_WATCH_DIR=/app/backend/data/mech_catalog_watch, debounce 2s). To re-test live, just drop a CSV with header 'chassis,model,mul_id,BV,tonnage' (plus optional year,techBase,role) into the watch dir and wait ~4s. Clean up any test rows via MechCatalogEntry delete on the sqlite DB (dotenv must be loaded first - DATABASE_URL isn't exported in shell env). No frontend surface for this feature yet.", + "rca of the issue": "N/A - no issues found" +} diff --git a/test_reports/pytest/pytest_results.xml b/test_reports/pytest/pytest_results.xml index 79d500c..cc921d0 100644 --- a/test_reports/pytest/pytest_results.xml +++ b/test_reports/pytest/pytest_results.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From b1d4a07c42bdfccad9ce0df76227d77a25d03eca Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 17:04:10 +0000 Subject: [PATCH 22/34] auto-commit for 8bb839ba-c65b-460c-8b88-9a915fe779c5 --- memory/PRD.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/memory/PRD.md b/memory/PRD.md index d84dc4c..6fe4f16 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -36,18 +36,25 @@ Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremen ## Prioritized Backlog ### P0 (next phases per migration roadmap) -- Phase 7: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. -- Phase 8: Wire frontend to consume the new API instead of static JSON fetch + client-side CSV parsing (incl. swapping `MechAutocomplete.jsx` to `/api/mech-catalog`); add `REACT_APP_BACKEND_URL`. -- Phase 9: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. +- Phase 8: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. +- Phase 9: Wire frontend to consume the new API instead of static JSON fetch + client-side CSV parsing (incl. swapping `MechAutocomplete.jsx` to `/api/mech-catalog`); add `REACT_APP_BACKEND_URL`. +- Phase 10: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. ### P1 - Investigate the pre-existing `ghost-bear.json`/`91st-division-vision-of-words.json` fetch race in `useForceManager.js`. - Tighten CORS policy and add auth once writes/multi-user exposure are introduced. - Consider case-insensitive uniqueness for special-abilities pool names if free-text entry is exposed in UI later. - Pilot SPA pool (Phase 5) is intentionally not wired into `GET /api/forces/{id}` pilot serialization yet - wire in when a phase actually needs it. +- Minor code-review notes from Phase 7 (non-blocking): `watcher._history` list has no explicit lock around concurrent debounce timers; `process_csv_file` does a blocking file read inside an async function (fine at NAS-drop scale, revisit with aiofiles/run_in_executor if files get large); zero-data-row files count as a successful "ok" import with rows=0. ## Next Tasks -- Await user's next user-story (Phase 7 scope) before proceeding. +- Await user's next user-story (Phase 8 scope) before proceeding. + +### Phase 7 (Watched-Folder Auto-Import Code) - Done, tested 100% pass +- `backend/watcher.py`: background `watchdog.Observer` monitoring `MEK_CATALOG_WATCH_DIR` (opt-in via env var, no-op if unset) for `*.csv` drops, debounced via per-file `threading.Timer` (default 2s, `MEK_CATALOG_WATCH_DEBOUNCE_SECONDS`). Core logic split into pure/testable functions: `validate_header`, `process_csv_file` (upserts strictly by mul_id, rows without mul_id counted as skipped), `handle_dropped_file` (archives to `processed/_.csv` or quarantines to `errors/` + a `.log` explaining why). +- Wired into FastAPI `lifespan` (start on startup, stop on shutdown). New `GET /api/mech-catalog/import-status` reports enabled/running/watchDir/debounceSeconds + last 20 import results. +- `.env`/`.env.example`/`.env.docker.example` updated with `MEK_CATALOG_WATCH_DIR`. +- Verified live end-to-end in the sandbox (valid + malformed drops both processed correctly, cleaned up after). 7 new pytest tests including a real `watchdog.Observer` integration test against a temp dir (no NAS needed, CI-verifiable). All 31 backend tests pass. ### Phase 6 (Mech Catalog Table) - Done, tested 100% pass - `backend/models.py`: added `MechCatalogEntry` (id, mul_id unique+nullable, chassis, model, bv, tonnage, year, techbase, role, updated_at). Migration `be34ee216040`. From 2ce0c327ae3fa811c7ac53bf85fb9461bbf0b613 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 17:05:07 +0000 Subject: [PATCH 23/34] Auto-generated changes --- .emergent/emergent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index 18500af..e3357e3 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", - "created_at": "2026-07-19T16:54:43.085644+00:00Z" + "created_at": "2026-07-19T17:05:07.547959+00:00Z" } From c3160f2069a612ef4b51fca1ae9beee27e8a2235 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 17:20:43 +0000 Subject: [PATCH 24/34] auto-commit for bfd26479-dd48-4781-88b4-26d00bfb322a --- backend/domain/__init__.py | 1 + backend/domain/achievements_logic.py | 143 +++++++++ backend/domain/downtime_logic.py | 151 +++++++++ backend/domain/mechs_logic.py | 41 +++ backend/domain/missions_logic.py | 55 ++++ backend/routers/downtime.py | 149 +++++++++ backend/routers/elementals.py | 109 +++++++ backend/routers/forces_write.py | 145 +++++++++ backend/routers/mechs.py | 93 ++++++ backend/routers/missions_write.py | 360 ++++++++++++++++++++++ backend/routers/pilots.py | 97 ++++++ backend/server.py | 12 + backend/tests/test_write_api_lifecycle.py | 233 ++++++++++++++ test_reports/iteration_8.json | 22 ++ test_reports/pytest/iteration_8.xml | 1 + 15 files changed, 1612 insertions(+) create mode 100644 backend/domain/__init__.py create mode 100644 backend/domain/achievements_logic.py create mode 100644 backend/domain/downtime_logic.py create mode 100644 backend/domain/mechs_logic.py create mode 100644 backend/domain/missions_logic.py create mode 100644 backend/routers/downtime.py create mode 100644 backend/routers/elementals.py create mode 100644 backend/routers/forces_write.py create mode 100644 backend/routers/mechs.py create mode 100644 backend/routers/missions_write.py create mode 100644 backend/routers/pilots.py create mode 100644 backend/tests/test_write_api_lifecycle.py create mode 100644 test_reports/iteration_8.json create mode 100644 test_reports/pytest/iteration_8.xml diff --git a/backend/domain/__init__.py b/backend/domain/__init__.py new file mode 100644 index 0000000..96cf926 --- /dev/null +++ b/backend/domain/__init__.py @@ -0,0 +1 @@ +# domain: pure business logic ported from frontend/src/lib/*.js diff --git a/backend/domain/achievements_logic.py b/backend/domain/achievements_logic.py new file mode 100644 index 0000000..fdf8945 --- /dev/null +++ b/backend/domain/achievements_logic.py @@ -0,0 +1,143 @@ +"""Ported from frontend/src/lib/achievements.js - combat stats & achievement checks.""" +import re + +WEIGHT_CLASSES = { + "light": (20, 35), + "medium": (40, 55), + "heavy": (60, 75), + "assault": (80, 100), +} + + +def get_weight_class(tonnage): + for name, (lo, hi) in WEIGHT_CLASSES.items(): + if lo <= tonnage <= hi: + return name + return None + + +def compute_combat_stats(combat_record): + combat_record = combat_record or {} + kills = combat_record.get("kills") or [] + assists = combat_record.get("assists") or 0 + missions_completed = combat_record.get("missionsCompleted") or 0 + missions_without_injury = combat_record.get("missionsWithoutInjury") or 0 + total_injuries_healed = combat_record.get("totalInjuriesHealed") or 0 + + light_kills = medium_kills = heavy_kills = assault_kills = 0 + total_tonnage_destroyed = 0 + max_tonnage_kill = 0 + + for kill in kills: + tonnage = kill.get("tonnage") or 0 + total_tonnage_destroyed += tonnage + if tonnage > max_tonnage_kill: + max_tonnage_kill = tonnage + weight_class = get_weight_class(tonnage) + if weight_class == "light": + light_kills += 1 + elif weight_class == "medium": + medium_kills += 1 + elif weight_class == "heavy": + heavy_kills += 1 + elif weight_class == "assault": + assault_kills += 1 + + return { + "killCount": len(kills), + "assists": assists, + "missionsCompleted": missions_completed, + "missionsWithoutInjury": missions_without_injury, + "totalInjuriesHealed": total_injuries_healed, + "lightKills": light_kills, + "mediumKills": medium_kills, + "heavyKills": heavy_kills, + "assaultKills": assault_kills, + "totalTonnageDestroyed": total_tonnage_destroyed, + "maxTonnageKill": max_tonnage_kill, + } + + +_CONDITION_RE = re.compile(r"^(\w+)\s*(>=|===|>|<|<=)\s*(\d+)$") + + +def check_condition(condition, stats): + try: + parts = [p.strip() for p in condition.split("&&")] + for part in parts: + match = _CONDITION_RE.match(part) + if not match: + return False + variable, operator, value_str = match.groups() + stat_value = stats.get(variable, 0) or 0 + target = int(value_str) + if operator == ">=": + ok = stat_value >= target + elif operator == ">": + ok = stat_value > target + elif operator == "<=": + ok = stat_value <= target + elif operator == "<": + ok = stat_value < target + elif operator == "===": + ok = stat_value == target + else: + ok = False + if not ok: + return False + return True + except Exception: + return False + + +def check_achievements(combat_record, achievement_definitions): + """achievement_definitions: list of dicts/objects with 'id' and 'condition'.""" + stats = compute_combat_stats(combat_record) + earned = [] + for achievement in achievement_definitions: + condition = achievement["condition"] if isinstance(achievement, dict) else achievement.condition + achievement_id = achievement["id"] if isinstance(achievement, dict) else achievement.id + if check_condition(condition, stats): + earned.append(achievement_id) + return earned + + +def find_new_achievements(previous_ids, current_ids): + prev = set(previous_ids or []) + return [aid for aid in current_ids if aid not in prev] + + +def create_empty_combat_record(): + return { + "kills": [], + "assists": 0, + "missionsCompleted": 0, + "missionsWithoutInjury": 0, + "totalInjuriesHealed": 0, + } + + +def add_kill(combat_record, kill): + record = combat_record or create_empty_combat_record() + kills = list(record.get("kills") or []) + kills.append(kill) + return {**record, "kills": kills} + + +def add_assists(combat_record, count): + record = combat_record or create_empty_combat_record() + return {**record, "assists": (record.get("assists") or 0) + count} + + +def record_mission_completion(combat_record, was_injured): + record = combat_record or create_empty_combat_record() + return { + **record, + "missionsCompleted": (record.get("missionsCompleted") or 0) + 1, + "missionsWithoutInjury": 0 if was_injured else (record.get("missionsWithoutInjury") or 0) + 1, + } + + +def record_injuries_healed(combat_record, count): + record = combat_record or create_empty_combat_record() + return {**record, "totalInjuriesHealed": (record.get("totalInjuriesHealed") or 0) + count} diff --git a/backend/domain/downtime_logic.py b/backend/domain/downtime_logic.py new file mode 100644 index 0000000..be8ea71 --- /dev/null +++ b/backend/domain/downtime_logic.py @@ -0,0 +1,151 @@ +"""Ported from frontend/src/lib/downtime.js - formula evaluator + action catalog. + +Formulas are not user input - they come only from data/downtime-actions.json, +checked into version control, same trust boundary as the original JS. +""" +import json +import math +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +DOWNTIME_ACTIONS_PATH = REPO_ROOT / "data" / "downtime-actions.json" + +_actions_cache = None + + +def load_downtime_actions(): + global _actions_cache + if _actions_cache is None: + _actions_cache = json.loads(DOWNTIME_ACTIONS_PATH.read_text()) + return _actions_cache + + +def get_action(category, action_id): + actions = load_downtime_actions() + for action in actions.get(category, []): + if action["id"] == action_id: + return action + return None + + +def _tokenize(expression): + tokens = [] + i = 0 + n = len(expression) + while i < n: + ch = expression[i] + if ch in " \t\n\r": + i += 1 + continue + if ch.isdigit() or (ch == "." and i + 1 < n and expression[i + 1].isdigit()): + num = ch + i += 1 + while i < n and (expression[i].isdigit() or expression[i] == "."): + num += expression[i] + i += 1 + tokens.append(("number", num)) + continue + if ch.isalpha() or ch == "_": + ident = ch + i += 1 + while i < n and (expression[i].isalnum() or expression[i] == "_"): + ident += expression[i] + i += 1 + tokens.append(("identifier", ident)) + continue + if ch in "+-*/": + tokens.append(("operator", ch)) + i += 1 + continue + if ch in "()": + tokens.append(("paren", ch)) + i += 1 + continue + raise ValueError(f"Unsupported character in expression: {ch}") + return tokens + + +_OP_PRECEDENCE = {"+": 1, "-": 1, "*": 2, "/": 2} + + +def _to_rpn(tokens): + output = [] + ops = [] + for token in tokens: + ttype, tval = token + if ttype in ("number", "identifier"): + output.append(token) + elif ttype == "operator": + while ops and ops[-1][0] == "operator" and _OP_PRECEDENCE[ops[-1][1]] >= _OP_PRECEDENCE[tval]: + output.append(ops.pop()) + ops.append(token) + elif ttype == "paren" and tval == "(": + ops.append(token) + elif ttype == "paren" and tval == ")": + found_left = False + while ops: + top = ops.pop() + if top[0] == "paren" and top[1] == "(": + found_left = True + break + output.append(top) + if not found_left: + raise ValueError("Mismatched parentheses") + while ops: + top = ops.pop() + if top[0] == "paren": + raise ValueError("Mismatched parentheses") + output.append(top) + return output + + +def _eval_rpn(rpn, context): + stack = [] + for ttype, tval in rpn: + if ttype == "number": + stack.append(float(tval)) + elif ttype == "identifier": + value = context.get(tval) + stack.append(value if isinstance(value, (int, float)) else 0) + elif ttype == "operator": + if len(stack) < 2: + raise ValueError("Insufficient values in expression") + b = stack.pop() + a = stack.pop() + if tval == "+": + result = a + b + elif tval == "-": + result = a - b + elif tval == "*": + result = a * b + elif tval == "/": + if b == 0: + raise ValueError("Division by zero") + result = a / b + else: + raise ValueError(f"Unknown operator: {tval}") + stack.append(result) + if len(stack) != 1: + raise ValueError("Invalid expression") + return stack[0] + + +_SAFE_PATTERN = re.compile(r"^[\w\d\s+\-*/().]+$") + + +def evaluate_downtime_cost(formula, context): + try: + if not isinstance(formula, str) or formula.strip() == "": + return 0 + if not _SAFE_PATTERN.match(formula): + return 0 + tokens = _tokenize(formula) + rpn = _to_rpn(tokens) + raw_result = _eval_rpn(rpn, context or {}) + if not isinstance(raw_result, (int, float)): + return 0 + rounded = math.ceil(raw_result) + return max(0, rounded) + except Exception: + return 0 diff --git a/backend/domain/mechs_logic.py b/backend/domain/mechs_logic.py new file mode 100644 index 0000000..50ba32d --- /dev/null +++ b/backend/domain/mechs_logic.py @@ -0,0 +1,41 @@ +"""Ported from frontend/src/lib/mechs.js - BV adjustment based on pilot skill.""" +import math + +BV_MULTIPLIER_TABLE = [ + [2.42, 2.31, 2.21, 2.10, 1.93, 1.75, 1.68, 1.59, 1.50], + [2.21, 2.11, 2.02, 1.92, 1.76, 1.60, 1.54, 1.46, 1.38], + [1.93, 1.85, 1.76, 1.68, 1.54, 1.40, 1.35, 1.28, 1.21], + [1.66, 1.58, 1.51, 1.44, 1.32, 1.20, 1.16, 1.10, 1.04], + [1.38, 1.32, 1.26, 1.20, 1.10, 1.00, 0.95, 0.90, 0.85], + [1.31, 1.19, 1.13, 1.08, 0.99, 0.90, 0.86, 0.81, 0.77], + [1.24, 1.12, 1.07, 1.02, 0.94, 0.85, 0.81, 0.77, 0.72], + [1.17, 1.06, 1.01, 0.96, 0.88, 0.80, 0.76, 0.72, 0.68], + [1.10, 0.99, 0.95, 0.90, 0.83, 0.75, 0.71, 0.68, 0.64], +] + + +def _round_half_up(value): + """Match JS Math.round (round-half-up) rather than Python's banker's rounding.""" + return math.floor(value + 0.5) + + +def get_bv_multiplier(gunnery, piloting): + g = max(0, min(8, int(gunnery if gunnery is not None else 4))) + p = max(0, min(8, int(piloting if piloting is not None else 5))) + return BV_MULTIPLIER_TABLE[g][p] + + +def get_adjusted_bv(base_bv, gunnery, piloting): + if not base_bv: + return 0 + if gunnery is None or piloting is None: + return _round_half_up(base_bv) + return _round_half_up(base_bv * get_bv_multiplier(gunnery, piloting)) + + +def get_mech_adjusted_bv(mech, pilot): + if not mech or not mech.bv: + return 0 + if not pilot: + return _round_half_up(mech.bv) + return get_adjusted_bv(mech.bv, pilot.gunnery, pilot.piloting) diff --git a/backend/domain/missions_logic.py b/backend/domain/missions_logic.py new file mode 100644 index 0000000..958c958 --- /dev/null +++ b/backend/domain/missions_logic.py @@ -0,0 +1,55 @@ +"""Ported from frontend/src/lib/missions.js - availability + BV/tonnage calculations.""" +from domain.mechs_logic import get_mech_adjusted_bv + + +def is_mech_available_for_mission(mech, pilot): + if not mech: + return False + if mech.status == "Destroyed": + return False + if mech.status not in ("Operational", "Damaged"): + return False + if not pilot: + return False + if pilot.injuries == 6: + return False + return True + + +def is_elemental_available_for_mission(elemental): + if not elemental: + return False + if (elemental.suits_destroyed or 0) >= 6: + return False + if elemental.status not in ("Operational", "Damaged"): + return False + if (elemental.suits_destroyed or 0) >= 5: + return False + return True + + +def calculate_mission_total_tonnage(mechs_by_id, mech_ids): + total = 0 + for mech_id in mech_ids: + mech = mechs_by_id.get(mech_id) + if mech: + total += mech.weight or 0 + return total + + +def calculate_mission_total_bv(mechs_by_id, pilots_by_id, mech_ids, elementals_by_id, elemental_ids): + mech_bv = 0 + for mech_id in mech_ids: + mech = mechs_by_id.get(mech_id) + if not mech: + continue + pilot = pilots_by_id.get(mech.pilot_id) if mech.pilot_id else None + mech_bv += get_mech_adjusted_bv(mech, pilot) + + elemental_bv = 0 + for elemental_id in elemental_ids: + elemental = elementals_by_id.get(elemental_id) + if elemental: + elemental_bv += elemental.bv or 0 + + return mech_bv + elemental_bv diff --git a/backend/routers/downtime.py b/backend/routers/downtime.py new file mode 100644 index 0000000..2aa6854 --- /dev/null +++ b/backend/routers/downtime.py @@ -0,0 +1,149 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Force, Mech, Elemental, Pilot, PilotAchievement +from serializers import mech_to_dict, elemental_to_dict, pilot_to_dict +from domain.downtime_logic import get_action, evaluate_downtime_cost +from domain.achievements_logic import record_injuries_healed + +router = APIRouter(prefix="/api") + + +class DowntimeActionIn(BaseModel): + actionId: str + lastMissionName: Optional[str] = None + + +@router.post("/mechs/{mech_id}/downtime") +async def apply_mech_downtime( + mech_id: str, payload: DowntimeActionIn, session: AsyncSession = Depends(get_session) +): + mech = await session.get(Mech, mech_id) + if not mech: + raise HTTPException(status_code=404, detail="Mech not found") + force = await session.get(Force, mech.force_id) + + action = get_action("mechActions", payload.actionId) + if not action: + raise HTTPException(status_code=404, detail="Unknown mech downtime action") + + context = {"weight": mech.weight or 0, "wpMultiplier": force.wp_multiplier or 5} + cost = evaluate_downtime_cost(action["formula"], context) + + timestamp = force.current_date + log = list(mech.activity_log or []) + log.append( + {"timestamp": timestamp, "action": f"{action['name']} performed ({cost} WP)", "mission": payload.lastMissionName, "cost": cost} + ) + mech.activity_log = log + + if action["id"] == "repair-armor" and mech.status == "Damaged": + mech.status = "Operational" + if action.get("makesUnavailable"): + mech.status = "Repairing" if action["id"] == "repair-structure" else "Unavailable" + + force.current_warchest = force.current_warchest - cost + await session.commit() + + return {"mech": mech_to_dict(mech), "currentWarchest": force.current_warchest, "cost": cost} + + +@router.post("/elementals/{elemental_id}/downtime") +async def apply_elemental_downtime( + elemental_id: str, payload: DowntimeActionIn, session: AsyncSession = Depends(get_session) +): + elemental = await session.get(Elemental, elemental_id) + if not elemental: + raise HTTPException(status_code=404, detail="Elemental not found") + force = await session.get(Force, elemental.force_id) + + action = get_action("elementalActions", payload.actionId) + if not action: + raise HTTPException(status_code=404, detail="Unknown elemental downtime action") + + context = { + "suitsDamaged": elemental.suits_damaged or 0, + "suitsDestroyed": elemental.suits_destroyed or 0, + "wpMultiplier": force.wp_multiplier or 5, + } + cost = evaluate_downtime_cost(action["formula"], context) + + timestamp = force.current_date + log = list(elemental.activity_log or []) + log.append( + {"timestamp": timestamp, "action": f"{action['name']} performed ({cost} WP)", "mission": payload.lastMissionName, "cost": cost} + ) + elemental.activity_log = log + + if action["id"] == "repair-elemental": + had_destroyed = (elemental.suits_destroyed or 0) > 0 + elemental.suits_damaged = 0 + if elemental.status == "Damaged" and not had_destroyed: + elemental.status = "Operational" + elif action["id"] == "purchase-elemental": + elemental.suits_destroyed = 0 + elemental.status = "Repairing" + + force.current_warchest = force.current_warchest - cost + await session.commit() + + return {"elemental": elemental_to_dict(elemental), "currentWarchest": force.current_warchest, "cost": cost} + + +@router.post("/pilots/{pilot_id}/downtime") +async def apply_pilot_downtime( + pilot_id: str, payload: DowntimeActionIn, session: AsyncSession = Depends(get_session) +): + pilot = await session.get(Pilot, pilot_id) + if not pilot: + raise HTTPException(status_code=404, detail="Pilot not found") + force = await session.get(Force, pilot.force_id) + + action = get_action("pilotActions", payload.actionId) + if not action: + raise HTTPException(status_code=404, detail="Unknown pilot downtime action") + + context = {"injuries": pilot.injuries or 0, "wpMultiplier": force.wp_multiplier or 5} + cost = evaluate_downtime_cost(action["formula"], context) + + timestamp = force.current_date + mission_suffix = f" after {payload.lastMissionName}" if payload.lastMissionName else "" + log = list(pilot.activity_log or []) + log.append( + { + "timestamp": timestamp, + "action": f"{action['name']} performed ({cost} WP){mission_suffix}", + "mission": payload.lastMissionName, + "cost": cost, + } + ) + pilot.activity_log = log + + if action["id"] == "train-gunnery": + base = pilot.gunnery if pilot.gunnery is not None else 4 + pilot.gunnery = max(0, min(8, base - 1)) + elif action["id"] == "train-piloting": + base = pilot.piloting if pilot.piloting is not None else 5 + pilot.piloting = max(0, min(8, base - 1)) + elif action["id"] == "heal-injury": + injuries_to_heal = pilot.injuries or 0 + if injuries_to_heal > 0: + pilot.combat_record = record_injuries_healed(pilot.combat_record, injuries_to_heal) + pilot.injuries = 0 + + force.current_warchest = force.current_warchest - cost + await session.commit() + + links = ( + await session.execute(select(PilotAchievement).where(PilotAchievement.pilot_id == pilot_id)) + ).scalars().all() + return { + "pilot": pilot_to_dict(pilot, [l.achievement_id for l in links]), + "currentWarchest": force.current_warchest, + "cost": cost, + } diff --git a/backend/routers/elementals.py b/backend/routers/elementals.py new file mode 100644 index 0000000..1d82d1d --- /dev/null +++ b/backend/routers/elementals.py @@ -0,0 +1,109 @@ +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Force, Elemental +from serializers import elemental_to_dict + +router = APIRouter(prefix="/api") + +_FIELD_MAP = { + "name": "name", + "commander": "commander", + "gunnery": "gunnery", + "antimech": "antimech", + "suitsDestroyed": "suits_destroyed", + "suitsDamaged": "suits_damaged", + "bv": "bv", + "status": "status", + "image": "image", + "history": "history", + "warchestCost": "warchest_cost", +} + + +class ElementalCreateIn(BaseModel): + id: Optional[str] = None + name: str + commander: str = "" + gunnery: int = 4 + antimech: int = 4 + suitsDestroyed: int = 0 + suitsDamaged: int = 0 + bv: int = 0 + status: str = "Operational" + image: str = "" + history: str = "" + warchestCost: int = 0 + + +class ElementalUpdateIn(BaseModel): + name: Optional[str] = None + commander: Optional[str] = None + gunnery: Optional[int] = None + antimech: Optional[int] = None + suitsDestroyed: Optional[int] = None + suitsDamaged: Optional[int] = None + bv: Optional[int] = None + status: Optional[str] = None + image: Optional[str] = None + history: Optional[str] = None + warchestCost: Optional[int] = None + + +@router.post("/forces/{force_id}/elementals", status_code=201) +async def create_elemental( + force_id: str, payload: ElementalCreateIn, session: AsyncSession = Depends(get_session) +): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + elemental = Elemental( + id=payload.id or f"elemental-{uuid.uuid4().hex[:12]}", + force_id=force_id, + name=payload.name, + commander=payload.commander, + gunnery=payload.gunnery, + antimech=payload.antimech, + suits_destroyed=payload.suitsDestroyed, + suits_damaged=payload.suitsDamaged, + bv=payload.bv, + status=payload.status, + image=payload.image, + history=payload.history, + warchest_cost=payload.warchestCost, + activity_log=[], + ) + session.add(elemental) + await session.commit() + return elemental_to_dict(elemental) + + +@router.put("/elementals/{elemental_id}") +async def update_elemental( + elemental_id: str, payload: ElementalUpdateIn, session: AsyncSession = Depends(get_session) +): + elemental = await session.get(Elemental, elemental_id) + if not elemental: + raise HTTPException(status_code=404, detail="Elemental not found") + + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(elemental, _FIELD_MAP[key], value) + + await session.commit() + return elemental_to_dict(elemental) + + +@router.delete("/elementals/{elemental_id}", status_code=204) +async def delete_elemental(elemental_id: str, session: AsyncSession = Depends(get_session)): + elemental = await session.get(Elemental, elemental_id) + if not elemental: + raise HTTPException(status_code=404, detail="Elemental not found") + await session.delete(elemental) + await session.commit() + return Response(status_code=204) diff --git a/backend/routers/forces_write.py b/backend/routers/forces_write.py new file mode 100644 index 0000000..0392d79 --- /dev/null +++ b/backend/routers/forces_write.py @@ -0,0 +1,145 @@ +import re +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import ( + Force, + Mech, + Pilot, + Elemental, + Mission, + Snapshot, + FullSnapshot, + ForceSpecialAbility, + PilotAchievement, + PilotSpaAssignment, + MissionSpPurchase, +) + +router = APIRouter(prefix="/api") + + +class ForceCreateIn(BaseModel): + id: Optional[str] = None + name: str + description: str = "" + image: str = "" + startingWarchest: int = 0 + currentWarchest: Optional[int] = None + wpMultiplier: int = 5 + currentDate: str = "" + notes: str = "" + + +class ForceUpdateIn(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + image: Optional[str] = None + startingWarchest: Optional[int] = None + currentWarchest: Optional[int] = None + wpMultiplier: Optional[int] = None + currentDate: Optional[str] = None + notes: Optional[str] = None + + +_FIELD_MAP = { + "name": "name", + "description": "description", + "image": "image", + "startingWarchest": "starting_warchest", + "currentWarchest": "current_warchest", + "wpMultiplier": "wp_multiplier", + "currentDate": "current_date", + "notes": "notes", +} + + +def force_core_dict(force): + return { + "id": force.id, + "name": force.name, + "description": force.description, + "image": force.image, + "startingWarchest": force.starting_warchest, + "currentWarchest": force.current_warchest, + "wpMultiplier": force.wp_multiplier, + "currentDate": force.current_date, + "notes": force.notes, + } + + +def slugify(name): + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug or "force" + + +@router.post("/forces", status_code=201) +async def create_force(payload: ForceCreateIn, session: AsyncSession = Depends(get_session)): + force_id = payload.id or slugify(payload.name) + base_id = force_id + suffix = 1 + while await session.get(Force, force_id): + suffix += 1 + force_id = f"{base_id}-{suffix}" + + force = Force( + id=force_id, + name=payload.name, + description=payload.description, + image=payload.image, + starting_warchest=payload.startingWarchest, + current_warchest=( + payload.currentWarchest if payload.currentWarchest is not None else payload.startingWarchest + ), + wp_multiplier=payload.wpMultiplier, + current_date=payload.currentDate, + notes=payload.notes, + ) + session.add(force) + await session.commit() + return force_core_dict(force) + + +@router.put("/forces/{force_id}") +async def update_force(force_id: str, payload: ForceUpdateIn, session: AsyncSession = Depends(get_session)): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(force, _FIELD_MAP[key], value) + + await session.commit() + return force_core_dict(force) + + +@router.delete("/forces/{force_id}", status_code=204) +async def delete_force(force_id: str, session: AsyncSession = Depends(get_session)): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + pilot_ids = (await session.execute(select(Pilot.id).where(Pilot.force_id == force_id))).scalars().all() + mission_ids = (await session.execute(select(Mission.id).where(Mission.force_id == force_id))).scalars().all() + + if pilot_ids: + await session.execute(delete(PilotAchievement).where(PilotAchievement.pilot_id.in_(pilot_ids))) + await session.execute(delete(PilotSpaAssignment).where(PilotSpaAssignment.pilot_id.in_(pilot_ids))) + if mission_ids: + await session.execute(delete(MissionSpPurchase).where(MissionSpPurchase.mission_id.in_(mission_ids))) + + await session.execute(delete(ForceSpecialAbility).where(ForceSpecialAbility.force_id == force_id)) + await session.execute(delete(Mission).where(Mission.force_id == force_id)) + await session.execute(delete(Mech).where(Mech.force_id == force_id)) + await session.execute(delete(Pilot).where(Pilot.force_id == force_id)) + await session.execute(delete(Elemental).where(Elemental.force_id == force_id)) + await session.execute(delete(Snapshot).where(Snapshot.force_id == force_id)) + await session.execute(delete(FullSnapshot).where(FullSnapshot.force_id == force_id)) + await session.delete(force) + await session.commit() + return Response(status_code=204) diff --git a/backend/routers/mechs.py b/backend/routers/mechs.py new file mode 100644 index 0000000..67b5954 --- /dev/null +++ b/backend/routers/mechs.py @@ -0,0 +1,93 @@ +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Force, Mech +from serializers import mech_to_dict + +router = APIRouter(prefix="/api") + +_FIELD_MAP = { + "name": "name", + "status": "status", + "pilotId": "pilot_id", + "bv": "bv", + "weight": "weight", + "image": "image", + "history": "history", + "warchestCost": "warchest_cost", +} + + +class MechCreateIn(BaseModel): + id: Optional[str] = None + name: str + status: str = "Operational" + pilotId: str = "" + bv: int = 0 + weight: int = 0 + image: str = "" + history: str = "" + warchestCost: int = 0 + + +class MechUpdateIn(BaseModel): + name: Optional[str] = None + status: Optional[str] = None + pilotId: Optional[str] = None + bv: Optional[int] = None + weight: Optional[int] = None + image: Optional[str] = None + history: Optional[str] = None + warchestCost: Optional[int] = None + + +@router.post("/forces/{force_id}/mechs", status_code=201) +async def create_mech(force_id: str, payload: MechCreateIn, session: AsyncSession = Depends(get_session)): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + mech = Mech( + id=payload.id or f"mech-{uuid.uuid4().hex[:12]}", + force_id=force_id, + name=payload.name, + status=payload.status, + pilot_id=payload.pilotId, + bv=payload.bv, + weight=payload.weight, + image=payload.image, + history=payload.history, + warchest_cost=payload.warchestCost, + activity_log=[], + ) + session.add(mech) + await session.commit() + return mech_to_dict(mech) + + +@router.put("/mechs/{mech_id}") +async def update_mech(mech_id: str, payload: MechUpdateIn, session: AsyncSession = Depends(get_session)): + mech = await session.get(Mech, mech_id) + if not mech: + raise HTTPException(status_code=404, detail="Mech not found") + + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(mech, _FIELD_MAP[key], value) + + await session.commit() + return mech_to_dict(mech) + + +@router.delete("/mechs/{mech_id}", status_code=204) +async def delete_mech(mech_id: str, session: AsyncSession = Depends(get_session)): + mech = await session.get(Mech, mech_id) + if not mech: + raise HTTPException(status_code=404, detail="Mech not found") + await session.delete(mech) + await session.commit() + return Response(status_code=204) diff --git a/backend/routers/missions_write.py b/backend/routers/missions_write.py new file mode 100644 index 0000000..a7af4f6 --- /dev/null +++ b/backend/routers/missions_write.py @@ -0,0 +1,360 @@ +import uuid +from typing import Optional, List, Dict + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import ( + Force, + Mech, + Pilot, + Elemental, + Mission, + MissionSpPurchase, + SpChoice, + AchievementDefinition, + PilotAchievement, +) +from serializers import mission_to_dict, mech_to_dict, elemental_to_dict, pilot_to_dict +from domain.missions_logic import calculate_mission_total_tonnage +from domain.achievements_logic import ( + check_achievements, + find_new_achievements, + create_empty_combat_record, + add_kill, + add_assists, + record_mission_completion, +) + +router = APIRouter(prefix="/api") + + +def _new_id(prefix): + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + +class ObjectiveIn(BaseModel): + id: Optional[str] = None + title: str = "" + description: str = "" + wpReward: int = 0 + achieved: bool = False + + +class SpPurchaseChoiceIn(BaseModel): + choiceId: str + + +class MissionCreateIn(BaseModel): + name: str + cost: int = 0 + description: str = "" + objectives: List[ObjectiveIn] = [] + assignedMechs: List[str] = [] + assignedElementals: List[str] = [] + spBudget: int = 0 + spPurchases: List[SpPurchaseChoiceIn] = [] + opForUnits: List[dict] = [] + + +class MissionUpdateIn(BaseModel): + name: Optional[str] = None + cost: Optional[int] = None + description: Optional[str] = None + objectives: Optional[List[ObjectiveIn]] = None + assignedMechs: Optional[List[str]] = None + assignedElementals: Optional[List[str]] = None + spBudget: Optional[int] = None + opForUnits: Optional[List[dict]] = None + + +class KillIn(BaseModel): + mechModel: str + tonnage: int = 0 + + +class PilotCompletionIn(BaseModel): + injuries: Optional[int] = None + kills: List[KillIn] = [] + assists: int = 0 + + +class ElementalCompletionIn(BaseModel): + status: Optional[str] = None + suitsDamaged: Optional[int] = None + suitsDestroyed: Optional[int] = None + + +class MechCompletionIn(BaseModel): + status: Optional[str] = None + + +class MissionCompletionIn(BaseModel): + objectives: List[ObjectiveIn] = [] + recap: str = "" + mechs: Dict[str, MechCompletionIn] = {} + elementals: Dict[str, ElementalCompletionIn] = {} + pilots: Dict[str, PilotCompletionIn] = {} + + +_UPDATE_FIELD_MAP = { + "name": "name", + "cost": "cost", + "description": "description", + "assignedMechs": "assigned_mechs", + "assignedElementals": "assigned_elementals", + "spBudget": "sp_budget", + "opForUnits": "op_for_units", +} + + +@router.post("/forces/{force_id}/missions", status_code=201) +async def create_mission( + force_id: str, payload: MissionCreateIn, session: AsyncSession = Depends(get_session) +): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + timestamp = force.current_date + + mechs = (await session.execute(select(Mech).where(Mech.force_id == force_id))).scalars().all() + mechs_by_id = {m.id: m for m in mechs} + total_tonnage = calculate_mission_total_tonnage(mechs_by_id, payload.assignedMechs) + + mission_id = _new_id("mission") + mission = Mission( + id=mission_id, + force_id=force_id, + name=payload.name, + cost=payload.cost, + description=payload.description, + objectives=[o.model_dump() for o in payload.objectives], + recap="", + completed=False, + assigned_mechs=payload.assignedMechs, + assigned_elementals=payload.assignedElementals, + created_at=timestamp, + in_game_date=timestamp, + completed_at=None, + sp_budget=payload.spBudget, + sp_purchases=[], + total_tonnage=total_tonnage, + op_for_units=payload.opForUnits, + ) + session.add(mission) + + assigned_mech_ids = set(payload.assignedMechs) + for mech in mechs: + if mech.id in assigned_mech_ids: + log = list(mech.activity_log or []) + log.append( + {"timestamp": timestamp, "action": f"Assigned to mission: {payload.name}", "mission": payload.name, "cost": 0} + ) + mech.activity_log = log + + elementals = ( + await session.execute(select(Elemental).where(Elemental.force_id == force_id)) + ).scalars().all() + assigned_elemental_ids = set(payload.assignedElementals) + for elemental in elementals: + if elemental.id in assigned_elemental_ids: + log = list(elemental.activity_log or []) + log.append( + {"timestamp": timestamp, "action": f"Assigned to mission: {payload.name}", "mission": payload.name, "cost": 0} + ) + elemental.activity_log = log + + pilots = (await session.execute(select(Pilot).where(Pilot.force_id == force_id))).scalars().all() + pilots_by_id = {p.id: p for p in pilots} + for mech in mechs: + if mech.id in assigned_mech_ids and mech.pilot_id: + pilot = pilots_by_id.get(mech.pilot_id) + if pilot: + log = list(pilot.activity_log or []) + log.append( + { + "timestamp": timestamp, + "inGameDate": force.current_date, + "action": f"Assigned to mission: {payload.name} (piloting {mech.name})", + "mission": payload.name, + "cost": 0, + } + ) + pilot.activity_log = log + + force.current_warchest = force.current_warchest - payload.cost + + created_purchases = [] + for choice_in in payload.spPurchases: + choice = await session.get(SpChoice, choice_in.choiceId) + if not choice: + raise HTTPException(status_code=404, detail=f"SP choice '{choice_in.choiceId}' not found in catalog") + purchase = MissionSpPurchase( + id=_new_id("sp"), + mission_id=mission_id, + choice_id=choice.id, + cost_at_purchase=choice.cost, + name_at_purchase=choice.name, + ) + session.add(purchase) + created_purchases.append(purchase) + + await session.commit() + return mission_to_dict(mission, created_purchases) + + +@router.put("/missions/{mission_id}") +async def update_mission( + mission_id: str, payload: MissionUpdateIn, session: AsyncSession = Depends(get_session) +): + mission = await session.get(Mission, mission_id) + if not mission: + raise HTTPException(status_code=404, detail="Mission not found") + + data = payload.model_dump(exclude_unset=True) + if "objectives" in data: + mission.objectives = [o.model_dump() for o in payload.objectives] + data.pop("objectives") + + for key, value in data.items(): + setattr(mission, _UPDATE_FIELD_MAP[key], value) + + if "assignedMechs" in data: + mechs = ( + await session.execute(select(Mech).where(Mech.force_id == mission.force_id)) + ).scalars().all() + mechs_by_id = {m.id: m for m in mechs} + mission.total_tonnage = calculate_mission_total_tonnage(mechs_by_id, mission.assigned_mechs) + + await session.commit() + sp_purchases = ( + await session.execute(select(MissionSpPurchase).where(MissionSpPurchase.mission_id == mission_id)) + ).scalars().all() + return mission_to_dict(mission, sp_purchases) + + +@router.post("/missions/{mission_id}/complete") +async def complete_mission( + mission_id: str, payload: MissionCompletionIn, session: AsyncSession = Depends(get_session) +): + mission = await session.get(Mission, mission_id) + if not mission: + raise HTTPException(status_code=404, detail="Mission not found") + if mission.completed: + raise HTTPException(status_code=409, detail="Mission already completed") + + force = await session.get(Force, mission.force_id) + timestamp = force.current_date + + updated_mechs = [] + for mech_id, mech_data in payload.mechs.items(): + mech = await session.get(Mech, mech_id) + if not mech or mech.force_id != force.id: + continue + if mech_data.status is not None: + mech.status = mech_data.status + updated_mechs.append(mech) + + updated_elementals = [] + for elemental_id, e_data in payload.elementals.items(): + elemental = await session.get(Elemental, elemental_id) + if not elemental or elemental.force_id != force.id: + continue + if e_data.status is not None: + elemental.status = e_data.status + if e_data.suitsDamaged is not None: + elemental.suits_damaged = max(0, min(6, e_data.suitsDamaged)) + if e_data.suitsDestroyed is not None: + elemental.suits_destroyed = max(0, min(6, e_data.suitsDestroyed)) + updated_elementals.append(elemental) + + achievement_defs = (await session.execute(select(AchievementDefinition))).scalars().all() + + new_achievements_by_pilot = [] + updated_pilots = [] + + for pilot_id, p_data in payload.pilots.items(): + pilot = await session.get(Pilot, pilot_id) + if not pilot or pilot.force_id != force.id: + continue + + previous_injuries = pilot.injuries or 0 + new_injuries = max(0, min(6, p_data.injuries)) if p_data.injuries is not None else previous_injuries + was_injured = new_injuries > previous_injuries + + combat_record = pilot.combat_record or create_empty_combat_record() + combat_record = record_mission_completion(combat_record, was_injured) + + for kill in p_data.kills: + combat_record = add_kill( + combat_record, + {"mechModel": kill.mechModel, "tonnage": kill.tonnage, "mission": mission.name, "date": timestamp}, + ) + + if p_data.assists: + combat_record = add_assists(combat_record, p_data.assists) + + current_achievement_ids = check_achievements(combat_record, achievement_defs) + + previous_links = ( + await session.execute(select(PilotAchievement).where(PilotAchievement.pilot_id == pilot_id)) + ).scalars().all() + previous_achievement_ids = [link.achievement_id for link in previous_links] + + earned_new = find_new_achievements(previous_achievement_ids, current_achievement_ids) + earned_details = [] + for achievement_id in earned_new: + session.add(PilotAchievement(pilot_id=pilot_id, achievement_id=achievement_id, earned_at=timestamp)) + definition = next((a for a in achievement_defs if a.id == achievement_id), None) + earned_details.append( + { + "id": achievement_id, + "name": definition.name if definition else achievement_id, + "icon": definition.icon if definition else None, + "description": definition.description if definition else None, + } + ) + + if earned_details: + new_achievements_by_pilot.append( + {"pilotId": pilot_id, "pilotName": pilot.name, "achievements": earned_details} + ) + + pilot.injuries = new_injuries + pilot.combat_record = combat_record + updated_pilots.append(pilot) + + mission.objectives = [o.model_dump() for o in payload.objectives] + mission.recap = payload.recap + mission.completed = True + mission.completed_at = timestamp + + reward = sum(o.wpReward for o in payload.objectives if o.achieved and o.wpReward and o.wpReward > 0) + force.current_warchest = force.current_warchest + reward + + await session.commit() + + pilots_response = [] + for pilot in updated_pilots: + links = ( + await session.execute(select(PilotAchievement).where(PilotAchievement.pilot_id == pilot.id)) + ).scalars().all() + pilots_response.append(pilot_to_dict(pilot, [l.achievement_id for l in links])) + + sp_purchases = ( + await session.execute(select(MissionSpPurchase).where(MissionSpPurchase.mission_id == mission_id)) + ).scalars().all() + + return { + "mission": mission_to_dict(mission, sp_purchases), + "currentWarchest": force.current_warchest, + "reward": reward, + "mechs": [mech_to_dict(m) for m in updated_mechs], + "elementals": [elemental_to_dict(e) for e in updated_elementals], + "pilots": pilots_response, + "newAchievements": new_achievements_by_pilot, + } diff --git a/backend/routers/pilots.py b/backend/routers/pilots.py new file mode 100644 index 0000000..ab4f457 --- /dev/null +++ b/backend/routers/pilots.py @@ -0,0 +1,97 @@ +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Force, Pilot, PilotAchievement, PilotSpaAssignment +from serializers import pilot_to_dict + +router = APIRouter(prefix="/api") + +_FIELD_MAP = { + "name": "name", + "gunnery": "gunnery", + "piloting": "piloting", + "injuries": "injuries", + "dezgra": "dezgra", + "history": "history", + "warchestCost": "warchest_cost", +} + + +class PilotCreateIn(BaseModel): + id: Optional[str] = None + name: str + gunnery: int = 4 + piloting: int = 5 + injuries: int = 0 + dezgra: bool = False + history: str = "" + warchestCost: int = 0 + + +class PilotUpdateIn(BaseModel): + name: Optional[str] = None + gunnery: Optional[int] = None + piloting: Optional[int] = None + injuries: Optional[int] = None + dezgra: Optional[bool] = None + history: Optional[str] = None + warchestCost: Optional[int] = None + + +@router.post("/forces/{force_id}/pilots", status_code=201) +async def create_pilot(force_id: str, payload: PilotCreateIn, session: AsyncSession = Depends(get_session)): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + pilot = Pilot( + id=payload.id or f"pilot-{uuid.uuid4().hex[:12]}", + force_id=force_id, + name=payload.name, + gunnery=payload.gunnery, + piloting=payload.piloting, + injuries=payload.injuries, + dezgra=payload.dezgra, + history=payload.history, + warchest_cost=payload.warchestCost, + activity_log=[], + achievements=[], + ) + session.add(pilot) + await session.commit() + return pilot_to_dict(pilot, []) + + +@router.put("/pilots/{pilot_id}") +async def update_pilot(pilot_id: str, payload: PilotUpdateIn, session: AsyncSession = Depends(get_session)): + pilot = await session.get(Pilot, pilot_id) + if not pilot: + raise HTTPException(status_code=404, detail="Pilot not found") + + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(pilot, _FIELD_MAP[key], value) + + await session.commit() + links = ( + await session.execute(select(PilotAchievement).where(PilotAchievement.pilot_id == pilot_id)) + ).scalars().all() + return pilot_to_dict(pilot, [l.achievement_id for l in links]) + + +@router.delete("/pilots/{pilot_id}", status_code=204) +async def delete_pilot(pilot_id: str, session: AsyncSession = Depends(get_session)): + pilot = await session.get(Pilot, pilot_id) + if not pilot: + raise HTTPException(status_code=404, detail="Pilot not found") + + await session.execute(delete(PilotAchievement).where(PilotAchievement.pilot_id == pilot_id)) + await session.execute(delete(PilotSpaAssignment).where(PilotSpaAssignment.pilot_id == pilot_id)) + await session.delete(pilot) + await session.commit() + return Response(status_code=204) diff --git a/backend/server.py b/backend/server.py index 71cd84a..5f5de01 100644 --- a/backend/server.py +++ b/backend/server.py @@ -16,6 +16,12 @@ from routers.sp_choices import router as sp_choices_router from routers.pilot_special_abilities import router as pilot_special_abilities_router from routers.mech_catalog import router as mech_catalog_router +from routers.forces_write import router as forces_write_router +from routers.mechs import router as mechs_router +from routers.pilots import router as pilots_router +from routers.elementals import router as elementals_router +from routers.missions_write import router as missions_write_router +from routers.downtime import router as downtime_router @asynccontextmanager @@ -64,3 +70,9 @@ async def health_check(): app.include_router(sp_choices_router) app.include_router(pilot_special_abilities_router) app.include_router(mech_catalog_router) +app.include_router(forces_write_router) +app.include_router(mechs_router) +app.include_router(pilots_router) +app.include_router(elementals_router) +app.include_router(missions_write_router) +app.include_router(downtime_router) diff --git a/backend/tests/test_write_api_lifecycle.py b/backend/tests/test_write_api_lifecycle.py new file mode 100644 index 0000000..29b4c9b --- /dev/null +++ b/backend/tests/test_write_api_lifecycle.py @@ -0,0 +1,233 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy import select, delete + +from server import app +from database import SessionLocal +from models import ( + Force, + Mech, + Pilot, + Elemental, + Mission, + MissionSpPurchase, + PilotAchievement, +) + +TEST_FORCE_ID = "test-write-api-lance" + + +async def _cleanup(): + async with SessionLocal() as session: + pilot_id_rows = ( + await session.execute(select(Pilot.id).where(Pilot.force_id == TEST_FORCE_ID)) + ).scalars().all() + if pilot_id_rows: + await session.execute(delete(PilotAchievement).where(PilotAchievement.pilot_id.in_(pilot_id_rows))) + mission_id_rows = ( + await session.execute(select(Mission.id).where(Mission.force_id == TEST_FORCE_ID)) + ).scalars().all() + if mission_id_rows: + await session.execute(delete(MissionSpPurchase).where(MissionSpPurchase.mission_id.in_(mission_id_rows))) + await session.execute(delete(Mission).where(Mission.force_id == TEST_FORCE_ID)) + await session.execute(delete(Mech).where(Mech.force_id == TEST_FORCE_ID)) + await session.execute(delete(Elemental).where(Elemental.force_id == TEST_FORCE_ID)) + await session.execute(delete(Pilot).where(Pilot.force_id == TEST_FORCE_ID)) + await session.execute(delete(Force).where(Force.id == TEST_FORCE_ID)) + await session.commit() + + +@pytest_asyncio.fixture(autouse=True) +async def cleanup_before_and_after(): + await _cleanup() + yield + await _cleanup() + + +@pytest.mark.asyncio +async def test_full_lifecycle_create_force_mech_pilot_mission_complete(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + force_resp = await client.post( + "/api/forces", + json={ + "id": TEST_FORCE_ID, + "name": "Test Write API Lance", + "startingWarchest": 1000, + "currentDate": "3052-01-01", + "wpMultiplier": 5, + }, + ) + assert force_resp.status_code == 201 + force = force_resp.json() + assert force["currentWarchest"] == 1000 + + mech_resp = await client.post( + f"/api/forces/{TEST_FORCE_ID}/mechs", + json={"name": "Atlas AS7-D", "bv": 1897, "weight": 100}, + ) + assert mech_resp.status_code == 201 + mech = mech_resp.json() + mech_id = mech["id"] + + pilot_resp = await client.post( + f"/api/forces/{TEST_FORCE_ID}/pilots", + json={"name": "Test Pilot", "gunnery": 3, "piloting": 4}, + ) + assert pilot_resp.status_code == 201 + pilot = pilot_resp.json() + pilot_id = pilot["id"] + + # Assign pilot to mech + assign_resp = await client.put(f"/api/mechs/{mech_id}", json={"pilotId": pilot_id}) + assert assign_resp.status_code == 200 + assert assign_resp.json()["pilotId"] == pilot_id + + # Create mission with an objective, assigned mech, and an SP purchase + mission_resp = await client.post( + f"/api/forces/{TEST_FORCE_ID}/missions", + json={ + "name": "Test Strike", + "cost": 50, + "objectives": [{"title": "Hold the line", "wpReward": 30, "achieved": False}], + "assignedMechs": [mech_id], + "spBudget": 10, + "spPurchases": [{"choiceId": "art_longtom"}], + }, + ) + assert mission_resp.status_code == 201 + mission = mission_resp.json() + mission_id = mission["id"] + assert len(mission["spPurchases"]) == 1 + assert mission["spPurchases"][0]["choiceId"] == "art_longtom" + + # Warchest reduced by mission cost after creation + force_after_creation = ( + await client.get(f"/api/forces/{TEST_FORCE_ID}") + ).json() + assert force_after_creation["currentWarchest"] == 950 + assert len(force_after_creation["mechs"][0]["activityLog"]) == 1 + assert len(force_after_creation["pilots"][0]["activityLog"]) == 1 + + # Complete the mission: objective achieved, pilot scores a kill (-> first-blood achievement) + complete_resp = await client.post( + f"/api/missions/{mission_id}/complete", + json={ + "objectives": [{"title": "Hold the line", "wpReward": 30, "achieved": True}], + "recap": "Victory", + "mechs": {mech_id: {"status": "Damaged"}}, + "pilots": {pilot_id: {"injuries": 1, "kills": [{"mechModel": "Enemy Locust", "tonnage": 20}], "assists": 0}}, + }, + ) + assert complete_resp.status_code == 200 + completion = complete_resp.json() + assert completion["reward"] == 30 + assert completion["currentWarchest"] == 980 # 1000 - 50 + 30 + assert completion["mission"]["completed"] is True + assert completion["mechs"][0]["status"] == "Damaged" + assert completion["pilots"][0]["injuries"] == 1 + assert "first-blood" in completion["pilots"][0]["achievements"] + assert len(completion["newAchievements"]) == 1 + assert completion["newAchievements"][0]["achievements"][0]["id"] == "first-blood" + + # Re-completing must be rejected (idempotency guard) + double_complete_resp = await client.post( + f"/api/missions/{mission_id}/complete", json={"objectives": [], "recap": "x"} + ) + assert double_complete_resp.status_code == 409 + + # Achievement persisted in the normalized pilot_achievements table + pilot_achievements_resp = await client.get(f"/api/pilots/{pilot_id}/achievements") + assert pilot_achievements_resp.status_code == 200 + assert any(a["achievementId"] == "first-blood" for a in pilot_achievements_resp.json()) + + # Downtime: repair the damaged mech's armor + downtime_resp = await client.post(f"/api/mechs/{mech_id}/downtime", json={"actionId": "repair-armor"}) + assert downtime_resp.status_code == 200 + downtime_result = downtime_resp.json() + assert downtime_result["cost"] == 20 # weight(100)/wpMultiplier(5) + assert downtime_result["mech"]["status"] == "Operational" + assert downtime_result["currentWarchest"] == 960 # 980 - 20 + + # Final force state reflects everything + final_force = (await client.get(f"/api/forces/{TEST_FORCE_ID}")).json() + assert final_force["currentWarchest"] == 960 + assert final_force["missions"][0]["completed"] is True + + # Delete the force cascades cleanly + delete_resp = await client.delete(f"/api/forces/{TEST_FORCE_ID}") + assert delete_resp.status_code == 204 + get_after_delete = await client.get(f"/api/forces/{TEST_FORCE_ID}") + assert get_after_delete.status_code == 404 + + +@pytest.mark.asyncio +async def test_pilot_downtime_heal_injury_and_training(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.post("/api/forces", json={"id": TEST_FORCE_ID, "name": "Test Write API Lance", "startingWarchest": 500, "wpMultiplier": 5}) + pilot_resp = await client.post( + f"/api/forces/{TEST_FORCE_ID}/pilots", json={"name": "Rookie", "gunnery": 4, "piloting": 5, "injuries": 2} + ) + pilot_id = pilot_resp.json()["id"] + + heal_resp = await client.post(f"/api/pilots/{pilot_id}/downtime", json={"actionId": "heal-injury"}) + assert heal_resp.status_code == 200 + heal_body = heal_resp.json() + assert heal_body["pilot"]["injuries"] == 0 + assert heal_body["cost"] == 12 # (30*2)/5 + assert heal_body["pilot"]["combatRecord"]["totalInjuriesHealed"] == 2 + + train_resp = await client.post(f"/api/pilots/{pilot_id}/downtime", json={"actionId": "train-gunnery"}) + assert train_resp.status_code == 200 + assert train_resp.json()["pilot"]["gunnery"] == 3 + assert train_resp.json()["cost"] == 40 # 200/5 + + +@pytest.mark.asyncio +async def test_elemental_downtime_repair_and_purchase(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.post("/api/forces", json={"id": TEST_FORCE_ID, "name": "Test Write API Lance", "startingWarchest": 500, "wpMultiplier": 5}) + elemental_resp = await client.post( + f"/api/forces/{TEST_FORCE_ID}/elementals", + json={"name": "Point Alpha", "status": "Damaged", "suitsDamaged": 2, "suitsDestroyed": 0}, + ) + elemental_id = elemental_resp.json()["id"] + + repair_resp = await client.post( + f"/api/elementals/{elemental_id}/downtime", json={"actionId": "repair-elemental"} + ) + assert repair_resp.status_code == 200 + body = repair_resp.json() + assert body["elemental"]["suitsDamaged"] == 0 + assert body["elemental"]["status"] == "Operational" + assert body["cost"] == 1 # ceil((2*2.5)/5) = ceil(1.0) = 1 + + +@pytest.mark.asyncio +async def test_create_mech_and_pilot_requires_existing_force(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/forces/does-not-exist/mechs", json={"name": "Ghost Mech"}) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_and_delete_mech(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.post("/api/forces", json={"id": TEST_FORCE_ID, "name": "Test Write API Lance"}) + mech_resp = await client.post(f"/api/forces/{TEST_FORCE_ID}/mechs", json={"name": "Locust", "bv": 400, "weight": 20}) + mech_id = mech_resp.json()["id"] + + update_resp = await client.put(f"/api/mechs/{mech_id}", json={"status": "Destroyed"}) + assert update_resp.status_code == 200 + assert update_resp.json()["status"] == "Destroyed" + + delete_resp = await client.delete(f"/api/mechs/{mech_id}") + assert delete_resp.status_code == 204 + + delete_missing_resp = await client.delete(f"/api/mechs/{mech_id}") + assert delete_missing_resp.status_code == 404 diff --git a/test_reports/iteration_8.json b/test_reports/iteration_8.json new file mode 100644 index 0000000..9f79ace --- /dev/null +++ b/test_reports/iteration_8.json @@ -0,0 +1,22 @@ +{ + "summary": "Phase 8 Write API tested. All 36 pytest tests pass (31 regression from Phases 1-7 + 5 new Phase 8 lifecycle/downtime tests). External URL smoke test confirmed force CRUD, mech CRUD, cascade delete (204 + subsequent 404), warchest defaulting to startingWarchest, and unknown-force-id -> 404 on mech create. Frontend loads correctly and displays untouched real campaign data (ghost-bear, 91st-division-vision-of-words). Real campaign data is pristine.", + "backend_issues": {"critical": [], "minor": []}, + "frontend_issues": {"ui_bugs": [], "integration_issues": [], "design_issues": []}, + "test_report_links": [ + "/app/backend/tests/test_write_api_lifecycle.py", + "/app/test_reports/pytest/iteration_8.xml" + ], + "action_items": [], + "critical_code_review_comments": [ + "missions_write.py is 361 lines - approaching the 700-line threshold but still manageable. Consider extracting the mission-completion logic (kills/achievements/reward computation, ~90 lines) into domain/missions_logic.py for cleaner separation if this file grows further.", + "Mission completion PUT endpoint intentionally does NOT alter currentWarchest (per problem statement, mirroring lib/missions.js). This is documented in the code — good.", + "Payload schema for mission create uses assignedMechs/assignedElementals (not assignedMechIds); this is consistent internally and covered by pytest. If frontend integration in later phase expects *Ids suffix, be aware." + ], + "updated_files": [], + "success_rate": {"backend": "100%", "frontend": "100%"}, + "test_credentials": "None (no auth on API)", + "seed_data_creation": "Created TEST_Phase8_Force via curl for external URL verification; cleaned up via DELETE cascade (verified 204 + subsequent 404). Real campaign data verified intact.", + "retest_needed": false, + "main_agent_can_self_test": true, + "context_for_next_testing_agent": "Phase 8 write API is fully working. Mission-completion payload schema uses {objectives:[{id,achieved,wpReward,...}], mechs:{id:{status}}, pilots:{id:{kills:[{mechModel,tonnage}], assists, injuries}}, elementals:{id:{status,suitsDamaged,suitsDestroyed}}}. All 36 backend pytest tests pass in 2.67s. Downtime formulas verified via pytest (repair-armor weight/wpMultiplier, heal-injury 30*injuries/wpMultiplier, train-gunnery 200/wpMultiplier, repair-elemental ceil((suitsDamaged*2.5)/wpMultiplier)). Idempotency guard (409) on double-complete confirmed by pytest." +} diff --git a/test_reports/pytest/iteration_8.xml b/test_reports/pytest/iteration_8.xml new file mode 100644 index 0000000..9e85960 --- /dev/null +++ b/test_reports/pytest/iteration_8.xml @@ -0,0 +1 @@ + \ No newline at end of file From 2a58f66ef7fdb0bf8bdd1ec1f80c80311557fc4a Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 17:21:33 +0000 Subject: [PATCH 25/34] auto-commit for 858b2558-cce8-46bb-96ca-78a7482bc038 --- memory/PRD.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/memory/PRD.md b/memory/PRD.md index 6fe4f16..23624b4 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -36,19 +36,28 @@ Enhance BTForceManager (https://github.com/AFoletti/BTForceManager) via incremen ## Prioritized Backlog ### P0 (next phases per migration roadmap) -- Phase 8: Write API (CRUD) for forces/mechs/pilots/missions/downtime, reusing existing pure logic from `frontend/src/lib/*.js`. -- Phase 9: Wire frontend to consume the new API instead of static JSON fetch + client-side CSV parsing (incl. swapping `MechAutocomplete.jsx` to `/api/mech-catalog`); add `REACT_APP_BACKEND_URL`. +- Phase 9: Wire frontend to consume the new write API instead of static JSON fetch + client-side CSV parsing (incl. swapping `MechAutocomplete.jsx` to `/api/mech-catalog`, `useForceManager.js` to the new CRUD/mission/downtime endpoints); add `REACT_APP_BACKEND_URL`. - Phase 10: Docker Compose full stack (frontend + backend) validated on actual Synology NAS. +- Consider snapshot/fullSnapshot creation + `force.currentDate` auto-advance endpoints if a future phase needs to mirror `MissionManager.jsx`'s UI-orchestration behavior (intentionally out of scope for Phase 8, which targeted only the pure lib logic). ### P1 - Investigate the pre-existing `ghost-bear.json`/`91st-division-vision-of-words.json` fetch race in `useForceManager.js`. - Tighten CORS policy and add auth once writes/multi-user exposure are introduced. - Consider case-insensitive uniqueness for special-abilities pool names if free-text entry is exposed in UI later. - Pilot SPA pool (Phase 5) is intentionally not wired into `GET /api/forces/{id}` pilot serialization yet - wire in when a phase actually needs it. -- Minor code-review notes from Phase 7 (non-blocking): `watcher._history` list has no explicit lock around concurrent debounce timers; `process_csv_file` does a blocking file read inside an async function (fine at NAS-drop scale, revisit with aiofiles/run_in_executor if files get large); zero-data-row files count as a successful "ok" import with rows=0. +- Minor code-review notes from Phase 7 (non-blocking): `watcher._history` list has no explicit lock around concurrent debounce timers; `process_csv_file` does a blocking file read inside an async function; zero-data-row files count as a successful "ok" import. +- Minor code-review note from Phase 8 (non-blocking): `routers/missions_write.py` is ~360 lines; consider extracting mission-completion logic into `domain/missions_logic.py` if it grows further. ## Next Tasks -- Await user's next user-story (Phase 8 scope) before proceeding. +- Await user's next user-story (Phase 9 scope) before proceeding. + +### Phase 8 (Write API for Core Entities) - Done, tested 100% pass +- `backend/domain/`: pure business logic ported from `frontend/src/lib/*.js` - `mechs_logic.py` (BV adjustment table), `achievements_logic.py` (combat stats + condition checker), `downtime_logic.py` (formula tokenizer/RPN evaluator over `data/downtime-actions.json`), `missions_logic.py` (tonnage/BV/availability calculations). +- Full CRUD: `POST/PUT/DELETE /api/forces{,/…}`, `.../mechs`, `.../pilots`, `.../elementals` (`routers/forces_write.py`, `mechs.py`, `pilots.py`, `elementals.py`). Force delete cascades all children including Phase 3-5 join tables. +- `routers/missions_write.py`: `POST /api/forces/{id}/missions` (deducts cost, activity-logs assigned units/pilots, snapshots SP purchases via Phase 4 mechanism, computes tonnage), `PUT /api/missions/{id}` (does NOT touch warchest - matches original `lib/missions.js` exactly), `POST /api/missions/{id}/complete` (applies kills/assists to pilot combat records, checks achievements against Phase 4's pool, persists newly-earned ones, computes WP reward from achieved objectives, updates warchest; 409 guard against double-completion). +- `routers/downtime.py`: mech/elemental/pilot downtime actions using the ported formula evaluator (e.g. repair-armor = weight/wpMultiplier, heal-injury = 30\*injuries/wpMultiplier). +- Verified end-to-end manually via curl and via `tests/test_write_api_lifecycle.py` (5 tests): full lifecycle (create force -> mech/pilot -> assign -> mission w/ SP purchase -> complete -> achievement earned + reward applied -> downtime -> cascade delete). All 36 backend tests pass; testing agent also smoke-tested externally via ingress and confirmed real campaign data (ghost-bear, 91st-division-vision-of-words) untouched. +- Scope note: intentionally does NOT create snapshots/fullSnapshots or auto-advance `force.currentDate` - that's `MissionManager.jsx` UI-orchestration logic, out of scope for "mirroring lib/*.js" pure functions. ### Phase 7 (Watched-Folder Auto-Import Code) - Done, tested 100% pass - `backend/watcher.py`: background `watchdog.Observer` monitoring `MEK_CATALOG_WATCH_DIR` (opt-in via env var, no-op if unset) for `*.csv` drops, debounced via per-file `threading.Timer` (default 2s, `MEK_CATALOG_WATCH_DEBOUNCE_SECONDS`). Core logic split into pure/testable functions: `validate_header`, `process_csv_file` (upserts strictly by mul_id, rows without mul_id counted as skipped), `handle_dropped_file` (archives to `processed/_.csv` or quarantines to `errors/` + a `.log` explaining why). From c271a1ee9e9d7bbc20afa6d76a09d7e470ad74fa Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 17:21:51 +0000 Subject: [PATCH 26/34] Auto-generated changes --- .emergent/emergent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index e3357e3..5ea184e 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,4 +1,4 @@ { "job_id": "e8fe6d4b-1b47-4757-a9f4-54ebb4753164", - "created_at": "2026-07-19T17:05:07.547959+00:00Z" + "created_at": "2026-07-19T17:21:51.504242+00:00Z" } From 506b3f5a77d391d4c14734e0eeb314f18085ddba Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Sun, 19 Jul 2026 18:17:34 +0000 Subject: [PATCH 27/34] auto-commit for 972dffac-e45f-481f-9fa3-5eaeb4a01c6a --- .../f666a8ff05f2_mech_catalog_rich_fields.py | 46 +++++ backend/import_mech_catalog.py | 24 +++ backend/models.py | 8 + backend/routers/elementals.py | 7 +- backend/routers/mech_catalog.py | 8 + backend/routers/mechs.py | 7 +- backend/routers/missions_write.py | 25 ++- backend/routers/pilots.py | 11 +- backend/routers/snapshots.py | 95 ++++++++++ backend/server.py | 2 + frontend/src/App.js | 4 +- frontend/src/components/DataEditor.jsx | 13 +- frontend/src/components/MechAutocomplete.jsx | 59 ++++-- frontend/src/hooks/forceSync.js | 176 ++++++++++++++++++ frontend/src/hooks/useForceManager.js | 128 ++++++++----- frontend/src/lib/api.js | 69 +++++++ test_reports/iteration_9.json | 34 ++++ 17 files changed, 632 insertions(+), 84 deletions(-) create mode 100644 backend/alembic/versions/f666a8ff05f2_mech_catalog_rich_fields.py create mode 100644 backend/routers/snapshots.py create mode 100644 frontend/src/hooks/forceSync.js create mode 100644 frontend/src/lib/api.js create mode 100644 test_reports/iteration_9.json diff --git a/backend/alembic/versions/f666a8ff05f2_mech_catalog_rich_fields.py b/backend/alembic/versions/f666a8ff05f2_mech_catalog_rich_fields.py new file mode 100644 index 0000000..b48c6fe --- /dev/null +++ b/backend/alembic/versions/f666a8ff05f2_mech_catalog_rich_fields.py @@ -0,0 +1,46 @@ +"""mech catalog rich fields + +Revision ID: f666a8ff05f2 +Revises: be34ee216040 +Create Date: 2026-07-19 17:46:49.710922 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f666a8ff05f2' +down_revision: Union[str, Sequence[str], None] = 'be34ee216040' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('mech_catalog', sa.Column('walk', sa.Integer(), nullable=False, server_default='0')) + op.add_column('mech_catalog', sa.Column('max_walk', sa.Integer(), nullable=False, server_default='0')) + op.add_column('mech_catalog', sa.Column('jump', sa.Integer(), nullable=False, server_default='0')) + op.add_column('mech_catalog', sa.Column('max_jump', sa.Integer(), nullable=False, server_default='0')) + op.add_column('mech_catalog', sa.Column('heat', sa.Integer(), nullable=False, server_default='0')) + op.add_column('mech_catalog', sa.Column('dissipation', sa.Integer(), nullable=False, server_default='0')) + op.add_column('mech_catalog', sa.Column('dissipation_efficiency', sa.Integer(), nullable=False, server_default='0')) + op.add_column('mech_catalog', sa.Column('components', sa.Text(), nullable=False, server_default='')) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('mech_catalog', 'components') + op.drop_column('mech_catalog', 'dissipation_efficiency') + op.drop_column('mech_catalog', 'dissipation') + op.drop_column('mech_catalog', 'heat') + op.drop_column('mech_catalog', 'max_jump') + op.drop_column('mech_catalog', 'jump') + op.drop_column('mech_catalog', 'max_walk') + op.drop_column('mech_catalog', 'walk') + # ### end Alembic commands ### diff --git a/backend/import_mech_catalog.py b/backend/import_mech_catalog.py index 59d0b43..22b17e4 100644 --- a/backend/import_mech_catalog.py +++ b/backend/import_mech_catalog.py @@ -60,6 +60,14 @@ async def import_catalog(session): year = parse_int(row.get("year")) techbase = (row.get("techBase") or "").strip() or None role = (row.get("role") or "").strip() or None + walk = parse_int(row.get("walk")) or 0 + max_walk = parse_int(row.get("maxWalk")) or walk + jump = parse_int(row.get("jump")) or 0 + max_jump = parse_int(row.get("maxJump")) or jump + heat = parse_int(row.get("heat")) or 0 + dissipation = parse_int(row.get("dissipation")) or 0 + dissipation_efficiency = parse_int(row.get("dissipationEfficiency")) or 0 + components = (row.get("components") or "").strip() existing = by_mul_id.get(mul_id) if mul_id is not None else by_chassis_model.get((chassis, model)) @@ -71,6 +79,14 @@ async def import_catalog(session): existing.year = year existing.techbase = techbase existing.role = role + existing.walk = walk + existing.max_walk = max_walk + existing.jump = jump + existing.max_jump = max_jump + existing.heat = heat + existing.dissipation = dissipation + existing.dissipation_efficiency = dissipation_efficiency + existing.components = components existing.updated_at = now updated += 1 else: @@ -83,6 +99,14 @@ async def import_catalog(session): year=year, techbase=techbase, role=role, + walk=walk, + max_walk=max_walk, + jump=jump, + max_jump=max_jump, + heat=heat, + dissipation=dissipation, + dissipation_efficiency=dissipation_efficiency, + components=components, updated_at=now, ) session.add(entry) diff --git a/backend/models.py b/backend/models.py index af4f171..069bb91 100644 --- a/backend/models.py +++ b/backend/models.py @@ -202,4 +202,12 @@ class MechCatalogEntry(Base): year: Mapped[int] = mapped_column(Integer, nullable=True) techbase: Mapped[str] = mapped_column(String, nullable=True) role: Mapped[str] = mapped_column(String, nullable=True) + walk: Mapped[int] = mapped_column(Integer, default=0) + max_walk: Mapped[int] = mapped_column(Integer, default=0) + jump: Mapped[int] = mapped_column(Integer, default=0) + max_jump: Mapped[int] = mapped_column(Integer, default=0) + heat: Mapped[int] = mapped_column(Integer, default=0) + dissipation: Mapped[int] = mapped_column(Integer, default=0) + dissipation_efficiency: Mapped[int] = mapped_column(Integer, default=0) + components: Mapped[str] = mapped_column(Text, default="") updated_at: Mapped[str] = mapped_column(String, default="") diff --git a/backend/routers/elementals.py b/backend/routers/elementals.py index 1d82d1d..49caeb6 100644 --- a/backend/routers/elementals.py +++ b/backend/routers/elementals.py @@ -1,5 +1,5 @@ import uuid -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import BaseModel @@ -23,6 +23,7 @@ "image": "image", "history": "history", "warchestCost": "warchest_cost", + "activityLog": "activity_log", } @@ -39,6 +40,7 @@ class ElementalCreateIn(BaseModel): image: str = "" history: str = "" warchestCost: int = 0 + activityLog: Optional[List[dict]] = None class ElementalUpdateIn(BaseModel): @@ -53,6 +55,7 @@ class ElementalUpdateIn(BaseModel): image: Optional[str] = None history: Optional[str] = None warchestCost: Optional[int] = None + activityLog: Optional[List[dict]] = None @router.post("/forces/{force_id}/elementals", status_code=201) @@ -77,7 +80,7 @@ async def create_elemental( image=payload.image, history=payload.history, warchest_cost=payload.warchestCost, - activity_log=[], + activity_log=payload.activityLog if payload.activityLog is not None else [], ) session.add(elemental) await session.commit() diff --git a/backend/routers/mech_catalog.py b/backend/routers/mech_catalog.py index ee8043e..c3d7901 100644 --- a/backend/routers/mech_catalog.py +++ b/backend/routers/mech_catalog.py @@ -28,6 +28,14 @@ def catalog_entry_to_dict(entry): "year": entry.year, "techbase": entry.techbase, "role": entry.role, + "walk": entry.walk, + "maxWalk": entry.max_walk, + "jump": entry.jump, + "maxJump": entry.max_jump, + "heat": entry.heat, + "dissipation": entry.dissipation, + "dissipationEfficiency": entry.dissipation_efficiency, + "components": entry.components, } diff --git a/backend/routers/mechs.py b/backend/routers/mechs.py index 67b5954..84e1ae9 100644 --- a/backend/routers/mechs.py +++ b/backend/routers/mechs.py @@ -1,5 +1,5 @@ import uuid -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import BaseModel @@ -20,6 +20,7 @@ "image": "image", "history": "history", "warchestCost": "warchest_cost", + "activityLog": "activity_log", } @@ -33,6 +34,7 @@ class MechCreateIn(BaseModel): image: str = "" history: str = "" warchestCost: int = 0 + activityLog: Optional[List[dict]] = None class MechUpdateIn(BaseModel): @@ -44,6 +46,7 @@ class MechUpdateIn(BaseModel): image: Optional[str] = None history: Optional[str] = None warchestCost: Optional[int] = None + activityLog: Optional[List[dict]] = None @router.post("/forces/{force_id}/mechs", status_code=201) @@ -63,7 +66,7 @@ async def create_mech(force_id: str, payload: MechCreateIn, session: AsyncSessio image=payload.image, history=payload.history, warchest_cost=payload.warchestCost, - activity_log=[], + activity_log=payload.activityLog if payload.activityLog is not None else [], ) session.add(mech) await session.commit() diff --git a/backend/routers/missions_write.py b/backend/routers/missions_write.py index a7af4f6..5e5f22b 100644 --- a/backend/routers/missions_write.py +++ b/backend/routers/missions_write.py @@ -1,9 +1,9 @@ import uuid from typing import Optional, List, Dict -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession from database import get_session @@ -49,6 +49,7 @@ class SpPurchaseChoiceIn(BaseModel): class MissionCreateIn(BaseModel): + id: Optional[str] = None name: str cost: int = 0 description: str = "" @@ -69,6 +70,9 @@ class MissionUpdateIn(BaseModel): assignedElementals: Optional[List[str]] = None spBudget: Optional[int] = None opForUnits: Optional[List[dict]] = None + completed: Optional[bool] = None + completedAt: Optional[str] = None + recap: Optional[str] = None class KillIn(BaseModel): @@ -108,6 +112,9 @@ class MissionCompletionIn(BaseModel): "assignedElementals": "assigned_elementals", "spBudget": "sp_budget", "opForUnits": "op_for_units", + "completed": "completed", + "completedAt": "completed_at", + "recap": "recap", } @@ -125,7 +132,7 @@ async def create_mission( mechs_by_id = {m.id: m for m in mechs} total_tonnage = calculate_mission_total_tonnage(mechs_by_id, payload.assignedMechs) - mission_id = _new_id("mission") + mission_id = payload.id or _new_id("mission") mission = Mission( id=mission_id, force_id=force_id, @@ -237,6 +244,18 @@ async def update_mission( return mission_to_dict(mission, sp_purchases) +@router.delete("/missions/{mission_id}", status_code=204) +async def delete_mission(mission_id: str, session: AsyncSession = Depends(get_session)): + mission = await session.get(Mission, mission_id) + if not mission: + raise HTTPException(status_code=404, detail="Mission not found") + + await session.execute(delete(MissionSpPurchase).where(MissionSpPurchase.mission_id == mission_id)) + await session.delete(mission) + await session.commit() + return Response(status_code=204) + + @router.post("/missions/{mission_id}/complete") async def complete_mission( mission_id: str, payload: MissionCompletionIn, session: AsyncSession = Depends(get_session) diff --git a/backend/routers/pilots.py b/backend/routers/pilots.py index ab4f457..c17f668 100644 --- a/backend/routers/pilots.py +++ b/backend/routers/pilots.py @@ -1,5 +1,5 @@ import uuid -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import BaseModel @@ -20,6 +20,8 @@ "dezgra": "dezgra", "history": "history", "warchestCost": "warchest_cost", + "activityLog": "activity_log", + "combatRecord": "combat_record", } @@ -32,6 +34,8 @@ class PilotCreateIn(BaseModel): dezgra: bool = False history: str = "" warchestCost: int = 0 + activityLog: Optional[List[dict]] = None + combatRecord: Optional[dict] = None class PilotUpdateIn(BaseModel): @@ -42,6 +46,8 @@ class PilotUpdateIn(BaseModel): dezgra: Optional[bool] = None history: Optional[str] = None warchestCost: Optional[int] = None + activityLog: Optional[List[dict]] = None + combatRecord: Optional[dict] = None @router.post("/forces/{force_id}/pilots", status_code=201) @@ -60,7 +66,8 @@ async def create_pilot(force_id: str, payload: PilotCreateIn, session: AsyncSess dezgra=payload.dezgra, history=payload.history, warchest_cost=payload.warchestCost, - activity_log=[], + activity_log=payload.activityLog if payload.activityLog is not None else [], + combat_record=payload.combatRecord, achievements=[], ) session.add(pilot) diff --git a/backend/routers/snapshots.py b/backend/routers/snapshots.py new file mode 100644 index 0000000..2d405dd --- /dev/null +++ b/backend/routers/snapshots.py @@ -0,0 +1,95 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import Force, Snapshot, FullSnapshot +from serializers import snapshot_to_dict, full_snapshot_to_dict + +router = APIRouter(prefix="/api") + + +class SnapshotIn(BaseModel): + id: str + type: str = "" + label: str = "" + createdAt: str = "" + currentWarchest: int = 0 + startingWarchest: int = 0 + netWarchestChange: int = 0 + missionsCompleted: int = 0 + units: dict = {} + + +class FullSnapshotIn(BaseModel): + id: str + snapshotId: str = "" + forceData: dict = {} + createdAt: str = "" + + +@router.post("/forces/{force_id}/snapshots", status_code=201) +async def create_snapshot( + force_id: str, payload: SnapshotIn, session: AsyncSession = Depends(get_session) +): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + snapshot = Snapshot( + id=payload.id, + force_id=force_id, + type=payload.type, + label=payload.label, + created_at=payload.createdAt, + current_warchest=payload.currentWarchest, + starting_warchest=payload.startingWarchest, + net_warchest_change=payload.netWarchestChange, + missions_completed=payload.missionsCompleted, + units=payload.units, + ) + session.add(snapshot) + await session.commit() + return snapshot_to_dict(snapshot) + + +@router.delete("/snapshots/{snapshot_id}", status_code=204) +async def delete_snapshot(snapshot_id: str, session: AsyncSession = Depends(get_session)): + snapshot = await session.get(Snapshot, snapshot_id) + if not snapshot: + raise HTTPException(status_code=404, detail="Snapshot not found") + await session.delete(snapshot) + await session.commit() + return Response(status_code=204) + + +@router.post("/forces/{force_id}/full-snapshots", status_code=201) +async def create_full_snapshot( + force_id: str, payload: FullSnapshotIn, session: AsyncSession = Depends(get_session) +): + force = await session.get(Force, force_id) + if not force: + raise HTTPException(status_code=404, detail="Force not found") + + full_snapshot = FullSnapshot( + id=payload.id, + force_id=force_id, + snapshot_id=payload.snapshotId, + force_data=payload.forceData, + created_at=payload.createdAt, + ) + session.add(full_snapshot) + await session.commit() + return full_snapshot_to_dict(full_snapshot) + + +@router.delete("/full-snapshots/{full_snapshot_id}", status_code=204) +async def delete_full_snapshot(full_snapshot_id: str, session: AsyncSession = Depends(get_session)): + full_snapshot = await session.get(FullSnapshot, full_snapshot_id) + if not full_snapshot: + raise HTTPException(status_code=404, detail="Full snapshot not found") + await session.delete(full_snapshot) + await session.commit() + return Response(status_code=204) diff --git a/backend/server.py b/backend/server.py index 5f5de01..b46bd53 100644 --- a/backend/server.py +++ b/backend/server.py @@ -22,6 +22,7 @@ from routers.elementals import router as elementals_router from routers.missions_write import router as missions_write_router from routers.downtime import router as downtime_router +from routers.snapshots import router as snapshots_router @asynccontextmanager @@ -76,3 +77,4 @@ async def health_check(): app.include_router(elementals_router) app.include_router(missions_write_router) app.include_router(downtime_router) +app.include_router(snapshots_router) diff --git a/frontend/src/App.js b/frontend/src/App.js index 74b138b..b8c65ca 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -113,9 +113,7 @@ export default function App() { const handleAddForce = (newForce) => { addNewForce(newForce); // eslint-disable-next-line no-alert - alert( - `✅ Force "${newForce.name}" created!\n\n⚠️ IMPORTANT: This is a session-only force.\nTo persist:\n1. Go to Data Editor tab\n2. Click "Export Force"\n3. Save as data/forces/${newForce.id}.json\n4. Add "${newForce.id}.json" to manifest.json\n5. Commit and push to GitHub`, - ); + alert(`Force "${newForce.name}" created and saved to the server.`); }; const handleEditDate = () => { diff --git a/frontend/src/components/DataEditor.jsx b/frontend/src/components/DataEditor.jsx index 3e7f4eb..cb8106d 100644 --- a/frontend/src/components/DataEditor.jsx +++ b/frontend/src/components/DataEditor.jsx @@ -25,7 +25,7 @@ export default function DataEditor({ force, onUpdate }) { } onUpdate(parsedForce); - alert('✅ Force data saved to session!\n\n⚠️ IMPORTANT: This only updates the current session.\nTo persist changes permanently:\n1. Click "Export Force" below\n2. Replace data/forces.json in your repository\n3. Commit and push to GitHub'); + alert('✅ Force data saved to the server.'); } catch (err) { setError(`Invalid JSON: ${err.message}`); } @@ -54,15 +54,10 @@ export default function DataEditor({ force, onUpdate }) {

Data Management Notice

- Changes made here only affect your current browser session. To make permanent changes: + Edit the JSON below and click Save to Session to write the changes + to the backend database. Use Export Force to download a JSON backup + of the current force at any time.

-
    -
  1. Edit the JSON below and click Save to Session
  2. -
  3. Click Export Force to download the updated JSON
  4. -
  5. Replace data/forces.json in your repository
  6. -
  7. Commit and push to GitHub: git add data/forces.json && git commit -m "Update force" && git push
  8. -
  9. GitHub Pages will serve the updated data (wait 1-2 minutes)
  10. -
diff --git a/frontend/src/components/MechAutocomplete.jsx b/frontend/src/components/MechAutocomplete.jsx index fa9298b..6066d59 100644 --- a/frontend/src/components/MechAutocomplete.jsx +++ b/frontend/src/components/MechAutocomplete.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Input } from './ui/input'; import { Search } from 'lucide-react'; +import { searchMechCatalog } from '../lib/api'; /** * Parse a CSV line handling quoted fields (which may contain commas). @@ -145,31 +146,49 @@ export function lookupMechInCatalog(catalog, mechName) { * @param {string} placeholder - Input placeholder text */ export default function MechAutocomplete({ value, onChange, onSelect, placeholder = "Search mechs..." }) { - const [catalog, setCatalog] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(0); const wrapperRef = useRef(null); const listRef = useRef(null); + const debounceRef = useRef(null); + const requestIdRef = useRef(0); - // Load mech catalog CSV on mount (uses cached version) + // Debounced search against the backend mech catalog API useEffect(() => { - loadMechCatalog() - .then(mechs => setCatalog(mechs)) - .catch(err => console.warn('Could not load mech catalog:', err)) - .finally(() => setIsLoading(false)); - }, []); + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (!value || value.length < 2) { + setSearchResults([]); + setIsLoading(false); + return; + } + + setIsLoading(true); + const requestId = ++requestIdRef.current; + debounceRef.current = setTimeout(() => { + searchMechCatalog(value) + .then((results) => { + if (requestIdRef.current === requestId) { + setSearchResults(results); + setIsLoading(false); + } + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.warn('Mech catalog search failed:', err); + if (requestIdRef.current === requestId) { + setSearchResults([]); + setIsLoading(false); + } + }); + }, 250); + + return () => clearTimeout(debounceRef.current); + }, [value]); - // Filter mechs based on search input - const filteredMechs = catalog.filter((mech) => { - if (!value || value.length < 2) return false; - const searchLower = value.toLowerCase(); - return ( - mech.name?.toLowerCase().includes(searchLower) || - mech.chassis?.toLowerCase().includes(searchLower) || - mech.model?.toLowerCase().includes(searchLower) - ); - }).slice(0, 50); // Limit results for performance + const filteredMechs = searchResults; // Close dropdown when clicking outside useEffect(() => { @@ -280,7 +299,7 @@ export default function MechAutocomplete({ value, onChange, onSelect, placeholde > {filteredMechs.map((mech, index) => (