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/.emergent/emergent.yml b/.emergent/emergent.yml index c9b0db1..6c4a4ae 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-19T19:07:11.326316+00:00Z" } diff --git a/.gitignore b/.gitignore index dc92321..9b18084 100644 --- a/.gitignore +++ b/.gitignore @@ -40,49 +40,25 @@ logs/ !index.html !static/ !.nojekyll --e -# Environment files -*.env -*.env.* --e -# Environment files -*.env -*.env.* -frontend/node_modules/.cache/default-development/1.pack -frontend/node_modules/.cache/default-development/3.pack --e -# Environment files -*.env -*.env.* -frontend/node_modules/.cache/default-development/4.pack --e -# Environment files -*.env -*.env.* -frontend/node_modules/.cache/default-development/5.pack -frontend/node_modules/.cache/default-development/6.pack --e -# Environment files -*.env -*.env.* --e --e -*.env.* --e -# Environment files -*.env -*.env.* --e -# Environment files -*.env -*.env.* --e + # Environment files -*.env -*.env.* .env .env.* credentials.json *.pem *.key .credentials + +# Backend (Python / SQLite) +__pycache__/ +*.pyc +backend/data/ +*.db +*.db-journal +*.sqlite3 + +# Keep committed env templates +!.env.example +!backend/.env.example +!backend/.env.docker.example +*.env diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..8f0c272 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +data +.env +.env.* +*.db +*.db-journal +*.sqlite3 +.git 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..e819e0f --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,87 @@ +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 +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: +# 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/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/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/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/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/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/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/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/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/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/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/import_mech_catalog.py b/backend/import_mech_catalog.py new file mode 100644 index 0000000..22b17e4 --- /dev/null +++ b/backend/import_mech_catalog.py @@ -0,0 +1,131 @@ +"""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 + 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)) + + if existing: + existing.chassis = chassis + existing.model = model + existing.bv = bv + existing.tonnage = tonnage + 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: + entry = MechCatalogEntry( + mul_id=mul_id, + chassis=chassis, + model=model, + bv=bv, + tonnage=tonnage, + 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) + 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/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/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 new file mode 100644 index 0000000..069bb91 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,213 @@ +from sqlalchemy import String, Integer, Boolean, Float, 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="") + + +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 + ) + + +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="") + + +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 + ) + + +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) + 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/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2bb5879 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,135 @@ +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-asyncio==1.4.0 +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 +watchdog==6.0.0 +watchfiles==1.2.0 +websockets==16.1 +yarl==1.24.2 +zipp==4.1.0 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/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/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..49caeb6 --- /dev/null +++ b/backend/routers/elementals.py @@ -0,0 +1,112 @@ +import uuid +from typing import List, 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", + "activityLog": "activity_log", +} + + +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 + activityLog: Optional[List[dict]] = None + + +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 + activityLog: Optional[List[dict]] = 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=payload.activityLog if payload.activityLog is not None else [], + ) + 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.py b/backend/routers/forces.py new file mode 100644 index 0000000..a2fcd69 --- /dev/null +++ b/backend/routers/forces.py @@ -0,0 +1,105 @@ +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, + SpecialAbility, + ForceSpecialAbility, + PilotAchievement, + MissionSpPurchase, +) +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() + special_abilities = ( + await session.execute( + select(SpecialAbility) + .join(ForceSpecialAbility, ForceSpecialAbility.ability_id == SpecialAbility.id) + .where(ForceSpecialAbility.force_id == force_id) + ) + ).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, + achievements_by_pilot, + sp_purchases_by_mission, + ) 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/mech_catalog.py b/backend/routers/mech_catalog.py new file mode 100644 index 0000000..c3d7901 --- /dev/null +++ b/backend/routers/mech_catalog.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session +from models import MechCatalogEntry +import watcher + +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, + "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, + } + + +@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]] + + +@router.get("/mech-catalog/import-status") +async def get_mech_catalog_import_status(): + return watcher.get_status() diff --git a/backend/routers/mechs.py b/backend/routers/mechs.py new file mode 100644 index 0000000..84e1ae9 --- /dev/null +++ b/backend/routers/mechs.py @@ -0,0 +1,96 @@ +import uuid +from typing import List, 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", + "activityLog": "activity_log", +} + + +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 + activityLog: Optional[List[dict]] = None + + +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 + activityLog: Optional[List[dict]] = 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=payload.activityLog if payload.activityLog is not None else [], + ) + 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..b1a2d19 --- /dev/null +++ b/backend/routers/missions_write.py @@ -0,0 +1,380 @@ +import uuid +from typing import Optional, List, Dict + +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, + 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): + id: Optional[str] = None + choiceId: str + + +class MissionCreateIn(BaseModel): + id: Optional[str] = None + 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 + completed: Optional[bool] = None + completedAt: Optional[str] = None + recap: Optional[str] = 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", + "completed": "completed", + "completedAt": "completed_at", + "recap": "recap", +} + + +@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 = payload.id or _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=choice_in.id or _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.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) +): + 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/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/routers/pilots.py b/backend/routers/pilots.py new file mode 100644 index 0000000..c17f668 --- /dev/null +++ b/backend/routers/pilots.py @@ -0,0 +1,104 @@ +import uuid +from typing import List, 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", + "activityLog": "activity_log", + "combatRecord": "combat_record", +} + + +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 + activityLog: Optional[List[dict]] = None + combatRecord: Optional[dict] = None + + +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 + activityLog: Optional[List[dict]] = None + combatRecord: Optional[dict] = 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=payload.activityLog if payload.activityLog is not None else [], + combat_record=payload.combatRecord, + 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/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/routers/sp_choices.py b/backend/routers/sp_choices.py new file mode 100644 index 0000000..8f0809f --- /dev/null +++ b/backend/routers/sp_choices.py @@ -0,0 +1,74 @@ +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response +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): + id: Optional[str] = None + 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=payload.id or 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) + + +@router.delete("/sp-purchases/{purchase_id}", status_code=204) +async def delete_mission_sp_purchase(purchase_id: str, session: AsyncSession = Depends(get_session)): + purchase = await session.get(MissionSpPurchase, purchase_id) + if not purchase: + raise HTTPException(status_code=404, detail="SP purchase not found") + await session.delete(purchase) + await session.commit() + return Response(status_code=204) 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 new file mode 100644 index 0000000..7e461e1 --- /dev/null +++ b/backend/serializers.py @@ -0,0 +1,163 @@ +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, achievement_ids=None): + 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": achievement_ids if achievement_ids is not None else (p.achievements or []), + } + if p.combat_record: + d["combatRecord"] = p.combat_record + return d + + +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, + "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 + 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: + 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, + 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, + "description": force.description, + "image": force.image, + "startingWarchest": force.starting_warchest, + "currentWarchest": force.current_warchest, + "wpMultiplier": force.wp_multiplier, + "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, + "mechs": [mech_to_dict(m) for m in mechs], + "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, 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 new file mode 100644 index 0000000..b46bd53 --- /dev/null +++ b/backend/server.py @@ -0,0 +1,80 @@ +from contextlib import asynccontextmanager +import asyncio + +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 +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 +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 +from routers.snapshots import router as snapshots_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + watcher.start_watcher(asyncio.get_event_loop()) + yield + watcher.stop_watcher() + await engine.dispose() + + +app = FastAPI( + title="BTForceManager API", + lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", +) + +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} + + +app.get("/health")(health_check) + +router = APIRouter(prefix="/api") +router.get("/health")(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) +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) +app.include_router(snapshots_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/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 " 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/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/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/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/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/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/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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..71ef981 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + backend: + build: ./backend + env_file: + - ./backend/.env.docker + volumes: + # Bind-mounted (not a named Docker volume) so the SQLite database file + # is a real file under this host path - browsable/backupable directly + # from DSM File Station, not hidden inside Docker's internal storage. + - ${DB_DATA_PATH:-./docker-data/db}:/data + # Dedicated "drop zone" for the Phase 7 watched-folder auto-import: + # copy/drag a mech catalog CSV into this host folder and it gets + # imported automatically within a few seconds. Separate from the DB + # folder above so it can be pointed at a more convenient/shared path. + - ${MECH_CATALOG_WATCH_HOST_DIR:-./docker-data/mech-catalog-drop}:/watch + ports: + - "${BACKEND_PORT:-8000}:8000" + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + REACT_APP_BACKEND_URL: ${REACT_APP_BACKEND_URL:-} + ports: + - "${FRONTEND_PORT:-3000}:80" + depends_on: + - backend + restart: unless-stopped diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f5855b2 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +build +dist +.env +.env.* +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.git diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d22771b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile +COPY . . + +# Left empty on purpose: the nginx stage below proxies /api/* to the backend +# container, so the bundle should call relative "/api/..." paths and work +# regardless of what hostname/IP/port the NAS is reached on. Only override +# this at build time if the frontend is deployed separately from the backend +# (e.g. --build-arg REACT_APP_BACKEND_URL=https://your-backend-host:8000). +ARG REACT_APP_BACKEND_URL= +ENV REACT_APP_BACKEND_URL=$REACT_APP_BACKEND_URL +RUN yarn build + +# Serve stage +FROM nginx:alpine +COPY --from=build /app/build /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e4f9575 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Forward every /api/* request to the backend container. Keeping this on + # the same origin/port as the UI means the built frontend never needs to + # know the NAS's IP/hostname - it just calls relative "/api/..." paths. + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # React Router / SPA fallback - any unmatched path serves index.html so + # client-side routing keeps working after a hard refresh. + location / { + try_files $uri $uri/ /index.html; + } +} 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) => (