Compare commits

..

12 Commits

53 changed files with 316758 additions and 74 deletions

View File

@@ -12,8 +12,8 @@ Each role is tagged with its name — run one or more with `--tags`:
```bash ```bash
# Run one role # Run one role
ansible-playbook -i inventory.ini ansible-playbook.yml --tags minecraft --ask-become-pass
ansible-playbook -i inventory.ini ansible-playbook.yml --tags minecraft --ask-become-pass
# Run several # Run several
ansible-playbook -i inventory.ini ansible-playbook.yml --tags "jellyfin,gitea" --ask-become-pass ansible-playbook -i inventory.ini ansible-playbook.yml --tags "jellyfin,gitea" --ask-become-pass

View File

@@ -30,10 +30,13 @@
tags: [jellyfin] tags: [jellyfin]
- role: wazuh - role: wazuh
tags: [wazuh] tags: [wazuh]
- role: minecraft - role: trade_hub
tags: [minecraft] tags: [trade_hub]
- role: 7dtd
tags: [7dtd] # - role: minecraft
# tags: [minecraft]
# - role: 7dtd
# tags: [7dtd]
# - role: zomboid # - role: zomboid
# tags: [zomboid] # tags: [zomboid]

View File

Binary file not shown.

View File

@@ -0,0 +1,33 @@
<configs>
<!--
More Animals per Area - v3.x spawn-density patch for the dedicated server.
Written for the v3.0+ spawning.xml schema (validated against the v3.0 shipped file):
- maxcount is now per 80x80m "biome chunk" (vanilla = 1 for every animal group).
Friendly groups: WildGameForest (day/Any, all biomes), WildGameForestNight
(pine/burnt), WildGameStagNight (snow/wasteland). Predators: EnemyAnimalsForest,
EnemyAnimalsBurntForestAny/Night, EnemyAnimalsDesert/Night, EnemyAnimalsSnow/Night,
EnemyAnimalsWasteland/Night. The starts-with() selectors cover ALL current and future
variants and cannot hit non-animal spawns (EnemyVulture*, ZombiesAll do not match).
- respawndelay is SIX comma-separated game-day values, one per sandbox Respawn speed:
Default,VerySlow,Slow,Normal,Fast,VeryFast (multipliers 1.75,1.35,1,0.65,0.35).
A single scalar corrupts the entry on v3.x. Base values: 0.25 for friendly game
(~6x faster than the 1.5 vanilla), 0.6 for night/predator groups.
- GATE: XML mods are only honored while the sandbox "Density" options (Day/Night Animal
+ Enemy) are set to Default. If a SandboxCode sets any density != Default, THIS PATCH
IS IGNORED and the sandbox value wins - use SandboxCode density in that case.
- EnemyAnimalsSnowNight / EnemyAnimalsWasteland* are zombie animals (undead), included
by the selector but worth knowing about.
Effect vs vanilla (pine forest): WildGameForest 1->4 alive per 80x80m chunk, respawn
1.5d->0.25d; predators 1->3, respawn 2.9d->0.6d - roughly a 3-4x wildlife increase.
-->
<!-- Friendly animals (day + night, all biomes): 4 alive per chunk, base respawn 0.25d -->
<set xpath="/spawning/biome/spawn[starts-with(@entitygroup,'WildGame')]/@maxcount">4</set>
<set xpath="/spawning/biome/spawn[starts-with(@entitygroup,'WildGame')]/@respawndelay">0.25,0.438,0.338,0.25,0.163,0.088</set>
<!-- Hostile animals (all biome variants incl. zombie-animal night groups): 3 alive per chunk, base respawn 0.6d -->
<set xpath="/spawning/biome/spawn[starts-with(@entitygroup,'EnemyAnimals')]/@maxcount">3</set>
<set xpath="/spawning/biome/spawn[starts-with(@entitygroup,'EnemyAnimals')]/@respawndelay">0.6,1.05,0.81,0.6,0.39,0.21</set>
</configs>

View File

@@ -0,0 +1,7 @@
<xml>
<Name value="MoreAnimals"/>
<DisplayName value="More Animals per Area"/>
<Version value="1.1"/>
<Description value="Raises wild-game and predator spawn density (maxcount) and respawn rate in every biome. Pure XML data patch - no DLLs, no client install, EAC-safe."/>
<Author value="boxswapper"/>
</xml>

View File

@@ -36,33 +36,39 @@
# Mods live in the bind-mounted serverfiles; the container loads them on its next start # Mods live in the bind-mounted serverfiles; the container loads them on its next start
# (recreate on every deploy re-reads them). zips come from roles/7dtd/files/mods (sdtd_mods # (recreate on every deploy re-reads them). zips come from roles/7dtd/files/mods (sdtd_mods
# in vars.yml), extracted one level deep (each zip carries its own mod subfolder; prefix # in vars.yml), extracted one level deep (each zip carries its own mod subfolder; prefix
# folders with z- if they must sort LAST for load order). unarchive only ADDS — it never # folders with z- if they must sort LAST for load order).
# removes, so a mod dropped from sdtd_mods would keep loading: with an empty list we wipe # Deployed now: MoreAnimals — a PURE XML data patch (no DLL/Harmony) raising wild-game/predator
# the whole dir and the create-task below rebuilds it empty. Partial mod swaps are not # spawn density (v3.x schema: maxcount per 80x80m chunk + 6-value respawndelay tuples). Works
# auto-cleaned (the role only knows zip names, not the folders zips unpack into) — remove # with EAC ON, needs NO client install (spawning is server-authoritative; the server pushes its
# those retired folders from the box by hand. # XML state to clients on connect). GATE: XML values apply only while the sandbox Density options
# are Default — once sdtd_sandbox_code sets any density != Default, the sandbox value wins and
# this patch is inert; use SandboxCode Day/Night Animal Density + Respawn instead.
# unarchive only ADDS — it never removes, so a mod dropped from sdtd_mods would keep loading:
# with an empty list we wipe the whole dir and the create-task below rebuilds it empty.
# Partial mod swaps are not auto-cleaned (the role only knows zip names, not the folders zips
# unpack into) — remove those retired folders from the box by hand.
- name: Remove 7 Days to Die Mods directory when no mods are deployed - name: Remove 7 Days to Die Mods directory when no mods are deployed
ansible.builtin.file: ansible.builtin.file:
path: /opt/apps/7dtd/serverfiles/Mods path: /opt/apps/7dtd/serverfiles/Mods
state: absent state: absent
when: sdtd_mods | length == 0 when: sdtd_mods | length == 0
# - name: Create 7 Days to Die Mods directory (container user) - name: Create 7 Days to Die Mods directory (container user)
# ansible.builtin.file: ansible.builtin.file:
# path: /opt/apps/7dtd/serverfiles/Mods path: /opt/apps/7dtd/serverfiles/Mods
# state: directory state: directory
# owner: '1000' owner: '1000'
# group: '1000' group: '1000'
# mode: '0755' mode: '0755'
# - name: Deploy 7 Days to Die mods from vendored zips - name: Deploy 7 Days to Die mods from vendored zips
# ansible.builtin.unarchive: ansible.builtin.unarchive:
# src: "mods/{{ item }}" src: "mods/{{ item }}"
# dest: /opt/apps/7dtd/serverfiles/Mods dest: /opt/apps/7dtd/serverfiles/Mods
# owner: '1000' owner: '1000'
# group: '1000' group: '1000'
# mode: '0755' mode: '0755'
# loop: "{{ sdtd_mods }}" loop: "{{ sdtd_mods }}"
- name: Template 7 Days to Die serverconfig from vars.yml - name: Template 7 Days to Die serverconfig from vars.yml
ansible.builtin.template: ansible.builtin.template:

View File

@@ -62,52 +62,35 @@
<property name="GameName" value="{{ sdtd_game_name }}" /> <property name="GameName" value="{{ sdtd_game_name }}" />
<property name="GameMode" value="{{ sdtd_game_mode }}" /> <property name="GameMode" value="{{ sdtd_game_mode }}" />
<!-- Difficulty: 0 = Scavenger, 1 = Adventurer, 2 = Nomad, 3 = Warrior, 4 = Survivalist, 5 = Insane --> <!-- v3.x GAMEPLAY TUNING MOVED TO SANDBOXCODE: GameDifficulty, XPMultiplier, DayNightLength,
<property name="GameDifficulty" value="{{ sdtd_difficulty }}" /> DayLightLength, DeathPenalty, DropOnDeath/DropOnQuit, zombie speeds (ZombieMove etc.),
<property name="BlockDamagePlayer" value="100" /> BloodMoon*, LootAbundance, LootRespawnDays, AirDropFrequency, BlockDamage*, EnemySpawnMode
<property name="BlockDamageAI" value="100" /> were REMOVED from serverconfig.xml in v3.0 and are silently IGNORED - the game reads only
<property name="BlockDamageAIBM" value="100" /> the SandboxCode. Empty code = Adventurer defaults. To restore the server's intended tuning:
<property name="XPMultiplier" value="{{ sdtd_xp_multiplier }}" /> client -> New Game -> Sandbox Options -> mirror the sdtd_* reference vars in vars.yml ->
copy code -> set sdtd_sandbox_code in vars.yml. MaxSpawnedZombies/MaxSpawnedAnimals remain
live serverconfig properties (see below). -->
{% if sdtd_sandbox_code %}
<property name="SandboxCode" value="{{ sdtd_sandbox_code }}" />
{% endif %}
<!-- Still-live v3.x gameplay properties (not moved to sandbox) -->
<property name="PlayerSafeZoneLevel" value="5" /> <property name="PlayerSafeZoneLevel" value="5" />
<property name="PlayerSafeZoneHours" value="5" /> <property name="PlayerSafeZoneHours" value="5" />
<!-- Day/night: DayNightLength = real minutes per in-game day, DayLightLength = in-game hours of daylight -->
<property name="BuildCreate" value="false" /> <property name="BuildCreate" value="false" />
<property name="DayNightLength" value="{{ sdtd_day_night_length }}" />
<property name="DayLightLength" value="{{ sdtd_day_light_length }}" />
<property name="DeathPenalty" value="1" />
<!-- DropOnDeath: 0 = nothing, 1 = everything, 2 = toolbelt only, 3 = backpack only, 4 = delete all -->
<property name="DropOnDeath" value="{{ sdtd_drop_on_death }}" />
<property name="DropOnQuit" value="0" />
<property name="BedrollDeadZoneSize" value="15" /> <property name="BedrollDeadZoneSize" value="15" />
<property name="BedrollExpiryTime" value="45" /> <property name="BedrollExpiryTime" value="45" />
<!-- Performance related --> <!-- Performance / spawn caps (live properties; animal density itself is patched by the
MoreAnimals modlet on Data/Config/spawning.xml - XML values apply only while the sandbox
Density options are Default) -->
<property name="MaxSpawnedZombies" value="{{ sdtd_max_spawned_zombies }}" /> <property name="MaxSpawnedZombies" value="{{ sdtd_max_spawned_zombies }}" />
<property name="MaxSpawnedAnimals" value="{{ sdtd_max_spawn_animals }}" /> <property name="MaxSpawnedAnimals" value="{{ sdtd_max_spawn_animals }}" />
<property name="ServerMaxAllowedViewDistance" value="12" /> <property name="ServerMaxAllowedViewDistance" value="12" />
<property name="MaxQueuedMeshLayers" value="1000" /> <property name="MaxQueuedMeshLayers" value="1000" />
<!-- Zombie settings. ZombieMove: 0 = walk, 1 = jog, 2 = run, 3 = sprint, 4 = nightmare -->
<property name="EnemySpawnMode" value="true" />
<property name="EnemyDifficulty" value="0" />
<property name="ZombieFeralSense" value="0" />
<property name="ZombieMove" value="{{ sdtd_zombie_move }}" />
<property name="ZombieMoveNight" value="{{ sdtd_zombie_move_night }}" />
<property name="ZombieFeralMove" value="3" />
<property name="ZombieBMMove" value="3" />
<property name="BloodMoonFrequency" value="{{ sdtd_bloodmoon_frequency }}" />
<property name="BloodMoonRange" value="0" />
<property name="BloodMoonWarning" value="8" />
<property name="BloodMoonEnemyCount" value="{{ sdtd_bloodmoon_enemy_count }}" />
<!-- Loot -->
<property name="LootAbundance" value="{{ sdtd_loot_abundance }}" />
<property name="LootRespawnDays" value="7" />
<property name="AirDropFrequency" value="{{ sdtd_airdrop_frequency }}" />
<property name="AirDropMarker" value="true" />
<!-- Multiplayer. PlayerKillingMode: 0 = no killing, 1 = kill allies only, 2 = kill strangers only, 3 = kill everyone --> <!-- Multiplayer. PlayerKillingMode: 0 = no killing, 1 = kill allies only, 2 = kill strangers only, 3 = kill everyone -->
<property name="AirDropMarker" value="true" />
<property name="PartySharedKillRange" value="100" /> <property name="PartySharedKillRange" value="100" />
<property name="PlayerKillingMode" value="{{ sdtd_player_killing_mode }}" /> <property name="PlayerKillingMode" value="{{ sdtd_player_killing_mode }}" />

95
roles/trade_hub/README.md Normal file
View File

@@ -0,0 +1,95 @@
# trade_hub
Deploys the Free People of the Verse trade hub: a FastAPI backend, an nginx
frontend and PostgreSQL, all run by podman on the target host. The role stages
its own copy of the application source from `files/trade-hub/` and builds the
backend and frontend images locally, so it does not need a registry.
## Usage
Add the role to an existing play. It is safe to run after a `packages` role that
already installs podman:
```yaml
- hosts: internal-apps
become: true
vars_files:
- ./vars.yml
roles:
- role: packages
tags: [packages]
# ...
- role: trade_hub
tags: [trade_hub]
```
The play-level `become: true` makes podman run as root. If you run the role
without become, podman runs rootless and the containers belong to the connecting
user.
The playbook's `Pull images` task is unrelated to this role: the role builds its
own backend/frontend images from the staged source. The PostgreSQL image is
pulled by the role if it is not already present.
## Required variables
Set a real database password in `vars.yml` (or the environment of the control
node):
```yaml
trade_hub_postgres_password: "choose-something-strong"
```
If it is omitted, the role falls back to `POSTGRES_PASSWORD` from the control
node environment, then to the development default `sc_inventory_dev`.
## Common variables
| Variable | Default | Purpose |
| --- | --- | --- |
| `trade_hub_app_dir` | `/opt/trade-hub` | Where the build context is staged |
| `trade_hub_install_podman` | `false` | Install podman if it is missing |
| `trade_hub_build_images` | `true` | Build backend/frontend images on the target |
| `trade_hub_force_rebuild` | `false` | Rebuild images and restart containers |
| `trade_hub_image_tag` | `latest` | Tag applied to built images |
| `trade_hub_backend_image` | `localhost/sc-inventory-backend` | Backend image name |
| `trade_hub_frontend_image` | `localhost/sc-inventory-frontend` | Frontend image name |
| `trade_hub_postgres_image` | `docker.io/library/postgres:16-alpine` | Database image |
| `trade_hub_postgres_user` | `sc_inventory` | Database user |
| `trade_hub_postgres_db` | `sc_inventory` | Database name |
| `trade_hub_postgres_password` | env `POSTGRES_PASSWORD` or `sc_inventory_dev` | Database password |
| `trade_hub_network` | `sc-inventory` | Podman network name |
| `trade_hub_volume` | `sc-inventory-data` | PostgreSQL data volume |
| `trade_hub_frontend_bind` | `0.0.0.0` | Address the frontend publishes on |
| `trade_hub_frontend_port` | `8071` | Host publish and container listen port for the frontend |
| `trade_hub_frontend_ip` | `10.10.0.5` | Static address on the shared `npm_internal` proxy network |
| `trade_hub_cookie_secure` | `false` | Mark session cookies `Secure` (enable behind TLS) |
| `trade_hub_healthcheck_url` | `http://127.0.0.1:<port>/healthz` | Post-deploy check |
| `trade_hub_remove_legacy_apache` | `false` | Remove a container named `my-apache-server` |
| `trade_hub_enable_restart_service` | `false` | Enable `podman-restart.service` |
The container names also default to `sc-inventory-db`, `sc-inventory-backend`
and `sc-inventory-frontend`, matching the previous standalone playbook so an
existing installation is upgraded in place.
## Example deployment
```bash
ansible-playbook site.yml --tags trade_hub -e trade_hub_postgres_password='...'
```
The role verifies `http://127.0.0.1:8071/healthz` after starting the containers.
Only the frontend publishes a port; the database and backend stay on the
internal podman network.
## Proxy manager
The frontend is pinned to `10.10.0.5` on the shared `npm_internal` podman
network, which also hosts Nginx Proxy Manager (10.10.0.19). Configure the proxy
host upstream as `http://10.10.0.5:8071`.
The `network` role must run before this one so `npm_internal` exists:
```bash
ansible-playbook -i inventory.ini ansible-playbook.yml --tags "network,trade_hub"
```

View File

@@ -0,0 +1,66 @@
---
# Where the application build context is staged on the target host.
trade_hub_app_dir: /opt/trade-hub
# Install podman as part of this role. Leave false when another role (for
# example "packages") already provides podman.
trade_hub_install_podman: false
# Images. These are built locally by default; point them at a registry if you
# prefer to pull prebuilt images.
trade_hub_image_tag: latest
trade_hub_backend_image: localhost/sc-inventory-backend
trade_hub_frontend_image: localhost/sc-inventory-frontend
trade_hub_postgres_image: docker.io/library/postgres:16-alpine
trade_hub_build_images: true
trade_hub_force_rebuild: false
# Directories created under trade_hub_app_dir before the source is staged.
# ansible.builtin.copy does NOT create parent directories for single-file
# sources, so every directory a staged file lands in must exist first.
trade_hub_app_subdirs:
- backend
- backend/app
- backend/scripts
- frontend
- frontend/src
# Container names. Kept compatible with the previous standalone playbook so an
# existing installation is upgraded in place.
trade_hub_db_container: sc-inventory-db
trade_hub_backend_container: sc-inventory-backend
trade_hub_frontend_container: sc-inventory-frontend
# Database.
trade_hub_postgres_user: sc_inventory
trade_hub_postgres_db: sc_inventory
trade_hub_postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default(trade_hub_postgres_pw, true) }}"
trade_hub_database_url: "postgresql+psycopg://{{ trade_hub_postgres_user }}:{{ trade_hub_postgres_password }}@db:5432/{{ trade_hub_postgres_db }}"
trade_hub_db_wait_seconds: 60
# Networking.
trade_hub_network: sc-inventory
trade_hub_volume: sc-inventory-data
trade_hub_frontend_bind: 0.0.0.0
# Published on the host and used as the container's nginx listen port. Nginx
# Proxy Manager proxies to http://<trade_hub_frontend_ip>:<trade_hub_frontend_port>.
trade_hub_frontend_port: 8071
# Static address for the frontend on the shared npm_internal proxy network
# (created by the "network" role), so the proxy-manager upstream never changes.
trade_hub_frontend_ip: "10.10.0.5"
# Set to true (or "true") once the site is served over TLS.
trade_hub_cookie_secure: "false"
# Post-deploy health check.
trade_hub_healthcheck_url: "http://127.0.0.1:{{ trade_hub_frontend_port }}/healthz"
trade_hub_healthcheck_retries: 12
trade_hub_healthcheck_delay: 5
# The original standalone playbook removed an ad-hoc Apache container that
# used the same host. Disabled by default so the role never touches unrelated
# containers.
trade_hub_remove_legacy_apache: false
trade_hub_legacy_apache_container: my-apache-server
# Enable podman-restart.service so the containers come back after a reboot.
trade_hub_enable_restart_service: false

View File

@@ -0,0 +1,25 @@
FROM docker.io/library/python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Download the full scunpacked dataset at build time so the image is
# self-contained; fetch_data.py reduces items.json to commodities.json.
RUN python scripts/fetch_data.py --output-dir /app/data/full
ENV DATA_DIR=/app/data/full
RUN useradd --create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1 @@
"""Star Citizen Inventory backend package (scunpacked data)."""

View File

@@ -0,0 +1,127 @@
"""FastAPI routes for the Star Citizen inventory (scunpacked data).
The catalog endpoints below are read-only. Accounts and personal inventories
are served by the auth and inventory routers and persisted in PostgreSQL.
"""
import math
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from app import config
from app.auth import router as auth_router
from app.available import router as available_router
from app.catalog import store
from app.db import db_healthy, init_db
from app.inventory import router as inventory_router
from app.orders import router as orders_router
@asynccontextmanager
async def lifespan(_app: FastAPI):
if config.AUTO_INIT_DB:
init_db(wait_seconds=config.DB_WAIT_SECONDS)
yield
app = FastAPI(
title="Star Citizen Inventory API",
description="Read-only item catalog built from scunpacked game data, plus "
"per-user accounts and personal inventories.",
version="1.1.0",
lifespan=lifespan,
)
# Same-origin deployment by default. Cross-origin access must be opted into
# explicitly, because credentialed cookies and a wildcard origin are
# incompatible (and unsafe).
if config.CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=config.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type"],
)
app.include_router(auth_router)
app.include_router(inventory_router)
app.include_router(available_router)
app.include_router(orders_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"}
def _filter_items(category, search, manufacturer, item_type, size):
result = store.items
if category is not None:
if category not in VALID_CATEGORIES:
raise HTTPException(
status_code=422,
detail=f"Invalid category '{category}'. Valid: {sorted(VALID_CATEGORIES)}",
)
result = [i for i in result if i["category"] == category]
if search is not None and search.strip():
needle = search.strip().lower()
result = [
i
for i in result
if needle in i["name"].lower() or needle in i["class_name"].lower()
]
if manufacturer is not None and manufacturer.strip():
code = manufacturer.strip().lower()
result = [i for i in result if i["manufacturer"]["code"].lower() == code]
if item_type is not None and item_type.strip():
wanted = item_type.strip().lower()
result = [i for i in result if (i["type"] or "").lower() == wanted]
if size is not None:
result = [i for i in result if i["size"] == size]
return result
@app.get("/healthz")
def healthz():
return {"status": "ok", "counts": store.counts, "db": db_healthy()}
@app.get("/api/v1/stats")
def stats():
return {"categories": store.counts, "manufacturers": len(store.manufacturers)}
@app.get("/api/v1/manufacturers")
def manufacturers():
return store.manufacturers
@app.get("/api/v1/items")
def list_items(
category: str | None = None,
search: str | None = None,
manufacturer: str | None = None,
item_type: str | None = Query(default=None, alias="type"),
size: int | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
):
page_size = min(page_size, 100) # cap, don't reject
filtered = _filter_items(category, search, manufacturer, item_type, size)
total = len(filtered)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"items": filtered[start : start + page_size],
}
@app.get("/api/v1/items/{class_name}")
def item_detail(class_name: str):
summary = store.by_class_name.get(class_name)
if summary is None:
raise HTTPException(status_code=404, detail=f"Item '{class_name}' not found")
return {**summary, "details": store.details.get(class_name, {})}

View File

@@ -0,0 +1,160 @@
"""Authentication routes: register, login, logout and current-user lookup.
Identity is a self-hosted username/password pair. Passwords are hashed with
Argon2; sessions are opaque random tokens whose SHA-256 is persisted, delivered
in an httpOnly cookie.
"""
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session as DbSession
from app import config, security
from app.db import get_db
from app.deps import get_current_user, require_same_origin
from app.models import User, UserSession, utcnow
from app.schemas import AuthResponse, LoginRequest, RegisterRequest, UserOut
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
throttle = security.LoginThrottle(
config.LOGIN_MAX_ATTEMPTS, config.LOGIN_WINDOW_SECONDS
)
_COOKIE_MAX_AGE = config.SESSION_TTL_DAYS * 24 * 60 * 60
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=config.COOKIE_NAME,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=config.COOKIE_SECURE,
path="/",
)
def _issue_session(db: DbSession, user: User) -> str:
token = security.new_session_token()
now = utcnow()
# Drop this user's expired sessions while we are here.
db.execute(
delete(UserSession).where(
UserSession.user_id == user.id, UserSession.expires_at <= now
)
)
db.add(
UserSession(
token_hash=security.hash_token(token),
user_id=user.id,
created_at=now,
expires_at=now + timedelta(days=config.SESSION_TTL_DAYS),
)
)
db.commit()
return token
@router.post(
"/register",
status_code=201,
response_model=AuthResponse,
dependencies=[Depends(require_same_origin)],
)
def register(payload: RegisterRequest, response: Response, db: DbSession = Depends(get_db)):
username = payload.username.strip()
if not security.valid_username(username):
raise HTTPException(
status_code=422,
detail="Username must be 3-32 characters: letters, digits, '.', '_' or '-'",
)
username_key = security.normalize_username(username)
existing = db.execute(
select(User).where(User.username_key == username_key)
).scalar_one_or_none()
if existing is not None:
raise HTTPException(status_code=409, detail="Username is already taken")
user = User(
username=username,
username_key=username_key,
password_hash=security.hash_password(payload.password),
)
db.add(user)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Username is already taken")
db.refresh(user)
_set_session_cookie(response, _issue_session(db, user))
return {"user": UserOut.model_validate(user)}
@router.post(
"/login",
response_model=AuthResponse,
dependencies=[Depends(require_same_origin)],
)
def login(payload: LoginRequest, request: Request, response: Response, db: DbSession = Depends(get_db)):
username_key = security.normalize_username(payload.username)
ip = security.client_ip(request)
wait = throttle.retry_after(username_key, ip)
if wait:
raise HTTPException(
status_code=429,
detail="Too many failed login attempts. Try again later.",
headers={"Retry-After": str(wait)},
)
user = db.execute(
select(User).where(User.username_key == username_key)
).scalar_one_or_none()
if user is None:
security.burn_password_check(payload.password)
throttle.record_failure(username_key, ip)
raise HTTPException(status_code=401, detail="Invalid username or password")
if not security.verify_password(payload.password, user.password_hash):
throttle.record_failure(username_key, ip)
raise HTTPException(status_code=401, detail="Invalid username or password")
throttle.reset(username_key, ip)
if security.needs_rehash(user.password_hash):
user.password_hash = security.hash_password(payload.password)
db.commit()
_set_session_cookie(response, _issue_session(db, user))
return {"user": UserOut.model_validate(user)}
@router.post(
"/logout",
status_code=204,
dependencies=[Depends(require_same_origin)],
)
def logout(request: Request, db: DbSession = Depends(get_db)):
token = request.cookies.get(config.COOKIE_NAME)
if token:
db.execute(
delete(UserSession).where(
UserSession.token_hash == security.hash_token(token)
)
)
db.commit()
response = Response(status_code=204)
response.delete_cookie(config.COOKIE_NAME, path="/")
return response
@router.get("/me", response_model=AuthResponse)
def me(user: User = Depends(get_current_user)):
return {"user": UserOut.model_validate(user)}

View File

@@ -0,0 +1,69 @@
"""Public listing of inventory items owners marked as `available`.
Read-only and unauthenticated, like the catalog. Notes are never included: the
listing only reveals the owner's username, quantity and catalog details.
"""
import math
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession
from app.db import get_db
from app.inventory import SORT_KEYS, enrich_entry
from app.models import InventoryEntry, User
from app.schemas import AvailablePage
router = APIRouter(prefix="/api/v1/available", tags=["available"])
MAX_PAGE_SIZE = 100
@router.get("", response_model=AvailablePage)
def list_available(
category: str | None = None,
search: str | None = None,
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
db: DbSession = Depends(get_db),
):
page_size = min(page_size, MAX_PAGE_SIZE)
rows = db.execute(
select(InventoryEntry, User.username)
.join(User, User.id == InventoryEntry.user_id)
.where(InventoryEntry.visibility == "available")
).all()
items = []
for entry, username in rows:
enriched = enrich_entry(entry)
if not enriched["available"]:
continue
enriched["username"] = username
items.append(enriched)
if category is not None and category.strip():
wanted = category.strip()
items = [i for i in items if i["category"] == wanted]
if search is not None and search.strip():
needle = search.strip().lower()
items = [
i
for i in items
if needle in i["name"].lower()
or needle in i["class_name"].lower()
or needle in i["username"].lower()
]
items.sort(key=SORT_KEYS[sort])
total = len(items)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"items": items[start : start + page_size],
}

View File

@@ -0,0 +1,8 @@
"""Single owner of the immutable, read-only scunpacked catalog.
Keeping the store here (instead of in app/api.py) lets the auth and inventory
routers import it without importing the FastAPI app itself.
"""
from app import loader
store = loader.load_store()

View File

@@ -0,0 +1,58 @@
"""Configuration for the inventory backend.
DATA_DIR selects which dataset the API serves:
- local dev / tests: small real-schema samples (default) or a fixture dir
- container image: the full scunpacked dataset downloaded at build time
The catalog is read-only. Everything mutable (accounts, sessions, per-user
inventories) lives in the database configured by DATABASE_URL.
"""
import os
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_BACKEND_DIR = os.path.dirname(_APP_DIR)
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(_BACKEND_DIR, "data", "samples"))
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
# --- Persistence ----------------------------------------------------------
DATABASE_URL = os.environ.get(
"DATABASE_URL", "postgresql+psycopg://sc_inventory:sc_inventory_dev@db:5432/sc_inventory"
)
# Create missing tables on startup. Disabled by the test suite, which builds
# its own schema against an in-memory SQLite database.
AUTO_INIT_DB = _env_bool("AUTO_INIT_DB", True)
# How long startup waits for PostgreSQL to accept connections (container boot).
DB_WAIT_SECONDS = int(os.environ.get("DB_WAIT_SECONDS", "60"))
# --- Session cookies ------------------------------------------------------
# Enable COOKIE_SECURE once the site is served over TLS. Defaults to false so
# plain http://localhost:8080 keeps working out of the box.
COOKIE_SECURE = _env_bool("COOKIE_SECURE", False)
COOKIE_NAME = os.environ.get("COOKIE_NAME", "sc_session")
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
# --- CORS -----------------------------------------------------------------
# Empty by default: the frontend is served same-origin through nginx, so no
# cross-origin access is needed. Comma-separated origins opt in explicitly.
CORS_ORIGINS = [
origin.strip()
for origin in os.environ.get("CORS_ORIGINS", "").split(",")
if origin.strip()
]
# --- Login hardening ------------------------------------------------------
LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
# --- Input limits ---------------------------------------------------------
MAX_NOTES_LENGTH = 1000
MAX_QUANTITY = 1_000_000
MAX_QUALITY = 1000

View File

@@ -0,0 +1,147 @@
"""Database engine, session factory and schema bootstrap.
The engine is created lazily so importing the app (and loading the read-only
catalog) never requires a reachable database.
"""
import logging
import time
from sqlalchemy import create_engine, text
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app import config
from app.models import Base
log = logging.getLogger(__name__)
_engine = None
_session_factory = None
def get_engine():
"""Return the process-wide engine, creating it on first use."""
global _engine, _session_factory
if _engine is None:
url = config.DATABASE_URL
kwargs = {"pool_pre_ping": True, "future": True}
if url.startswith("sqlite"):
kwargs["connect_args"] = {"check_same_thread": False}
if ":memory:" in url or url in {"sqlite://", "sqlite+pysqlite://"}:
kwargs["poolclass"] = StaticPool
_engine = create_engine(url, **kwargs)
_session_factory = sessionmaker(
bind=_engine, autoflush=False, autocommit=False, expire_on_commit=False
)
return _engine
def get_session_factory():
get_engine()
return _session_factory
def get_db():
"""FastAPI dependency yielding a request-scoped SQLAlchemy session."""
db = get_session_factory()()
try:
yield db
finally:
db.close()
def _upgrade_schema(engine) -> None:
"""Add columns that `create_all` cannot add to an existing table.
PostgreSQL only: the SQLite database used by the tests is created fresh
from the models on every run, so it already has every column.
"""
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"visibility VARCHAR(16) NOT NULL DEFAULT 'private'"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_visibility'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_visibility
CHECK (visibility IN ('private', 'available'));
END IF;
END $$
"""
)
)
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"quality INTEGER"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_quality_range'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_quality_range
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
END IF;
END $$
"""
)
)
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
"""Create missing tables, retrying while PostgreSQL finishes booting.
`create_all(checkfirst=True)` is idempotent and safe to run on every start.
Additive columns for existing deployments are applied afterwards; changing
or dropping an existing column still requires Alembic.
"""
deadline = time.monotonic() + max(wait_seconds, 0)
while True:
try:
engine = get_engine()
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
Base.metadata.create_all(bind=engine, checkfirst=True)
_upgrade_schema(engine)
return
except (OperationalError, IntegrityError, ProgrammingError) as exc:
# A concurrent startup may have created the tables first.
if "already exists" in str(exc).lower():
return
if time.monotonic() >= deadline:
raise
log.warning(
"database not ready (%s); retrying in %.1fs",
exc.__class__.__name__,
interval,
)
time.sleep(interval)
def db_healthy() -> bool:
try:
with get_engine().connect() as conn:
conn.execute(text("SELECT 1"))
return True
except Exception:
return False

View File

@@ -0,0 +1,39 @@
"""Shared FastAPI dependencies: origin check and current-user resolution."""
from datetime import timezone
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session as DbSession
from app import config, security
from app.db import get_db
from app.models import User, UserSession, utcnow
def require_same_origin(request: Request) -> None:
if not security.same_origin(request):
raise HTTPException(status_code=403, detail="Cross-origin request rejected")
def _as_utc(value):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def get_current_user(
request: Request, db: DbSession = Depends(get_db)
) -> User:
token = request.cookies.get(config.COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
session = db.get(UserSession, security.hash_token(token))
if session is None:
raise HTTPException(status_code=401, detail="Not authenticated")
if _as_utc(session.expires_at) <= utcnow():
db.delete(session)
db.commit()
raise HTTPException(status_code=401, detail="Session expired")
return session.user

View File

@@ -0,0 +1,286 @@
"""Per-user inventory routes.
Rows reference immutable catalog items by `class_name`; responses are enriched
live from the in-memory catalog. A missing catalog item is reported as
`available: false` instead of deleting the user's data.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session as DbSession
from app import catalog
from app.db import get_db
from app.deps import get_current_user, require_same_origin
from app.models import InventoryEntry, User
from app.schemas import (
InventoryAddRequest,
InventoryAddResponse,
InventoryEntryOut,
InventoryPage,
InventoryPatchRequest,
InventorySummary,
)
router = APIRouter(
prefix="/api/v1/me/inventory",
tags=["inventory"],
dependencies=[Depends(require_same_origin)],
)
MAX_PAGE_SIZE = 100
def enrich_entry(entry: InventoryEntry) -> dict:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
return {
"class_name": entry.class_name,
"name": entry.class_name.replace("_", " "),
"category": None,
"type": None,
"size": None,
"grade": None,
"manufacturer": None,
"quantity": entry.quantity,
"notes": entry.notes,
"quality": entry.quality,
"quality_applicable": False,
"visibility": entry.visibility,
"available": False,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
return {
"class_name": entry.class_name,
"name": item["name"],
"category": item["category"],
"type": item["type"],
"size": item["size"],
"grade": item["grade"],
"manufacturer": item["manufacturer"],
"quantity": entry.quantity,
"notes": entry.notes,
"quality": entry.quality,
"quality_applicable": item.get("quality_applicable", False),
"visibility": entry.visibility,
"available": True,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
def _validate_quality(item: dict, quality: int | None) -> None:
if quality is not None and not item.get("quality_applicable", False):
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{item['name']}'",
)
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
return db.execute(
select(InventoryEntry).where(
InventoryEntry.user_id == user.id,
InventoryEntry.class_name == class_name,
)
).scalar_one_or_none()
def _sort_key_name(item):
return (item["name"].lower(), item["class_name"])
def _sort_key_category(item):
return ((item["category"] or "").lower(), item["name"].lower(), item["class_name"])
def _sort_key_quantity(item):
return (-item["quantity"], item["name"].lower(), item["class_name"])
def _sort_key_updated(item):
return (-item["updated_at"].timestamp(), item["name"].lower())
SORT_KEYS = {
"name": _sort_key_name,
"category": _sort_key_category,
"quantity": _sort_key_quantity,
"updated": _sort_key_updated,
}
@router.get("", response_model=InventoryPage)
def list_inventory(
category: str | None = None,
search: str | None = None,
sort: str = Query(default="name", pattern="^(name|category|quantity|updated)$"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
page_size = min(page_size, MAX_PAGE_SIZE)
rows = db.execute(
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
).scalars().all()
items = [enrich_entry(row) for row in rows]
if category is not None and category.strip():
wanted = category.strip()
items = [i for i in items if i["category"] == wanted]
if search is not None and search.strip():
needle = search.strip().lower()
items = [
i
for i in items
if needle in i["name"].lower() or needle in i["class_name"].lower()
]
items.sort(key=SORT_KEYS[sort])
total = len(items)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"items": items[start : start + page_size],
}
@router.get("/summary", response_model=InventorySummary)
def inventory_summary(
user: User = Depends(get_current_user), db: DbSession = Depends(get_db)
):
rows = db.execute(
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
).scalars().all()
categories: dict[str, int] = {}
unavailable = 0
total_quantity = 0
for row in rows:
item = catalog.store.by_class_name.get(row.class_name)
if item is None:
unavailable += 1
else:
key = item["category"]
categories[key] = categories.get(key, 0) + row.quantity
total_quantity += row.quantity
return {
"items": len(rows),
"quantity": total_quantity,
"categories": categories,
"unavailable": unavailable,
}
@router.post("", response_model=InventoryAddResponse)
def add_to_inventory(
payload: InventoryAddRequest,
response: Response,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
class_name = payload.class_name.strip()
item = catalog.store.by_class_name.get(class_name)
if item is None:
raise HTTPException(
status_code=404, detail=f"Catalog item '{class_name}' not found"
)
_validate_quality(item, payload.quality)
entry = _find(db, user, class_name)
created = entry is None
if created:
entry = InventoryEntry(
user_id=user.id,
class_name=class_name,
quantity=payload.quantity,
notes=payload.notes,
quality=payload.quality,
)
db.add(entry)
else:
entry.quantity += payload.quantity
if payload.notes:
entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
try:
db.commit()
except IntegrityError:
# Lost a race against a concurrent add: re-read and increment instead.
db.rollback()
entry = _find(db, user, class_name)
if entry is None:
raise HTTPException(status_code=409, detail="Could not add item; retry")
entry.quantity += payload.quantity
if payload.notes:
entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
db.commit()
created = False
db.refresh(entry)
response.status_code = 201 if created else 200
return {"entry": InventoryEntryOut.model_validate(enrich_entry(entry)), "created": created}
@router.patch("/{class_name}", response_model=InventoryEntryOut)
def update_inventory_entry(
class_name: str,
payload: InventoryPatchRequest,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
entry = _find(db, user, class_name)
if entry is None:
raise HTTPException(status_code=404, detail="Item is not in your inventory")
if payload.quantity is not None:
entry.quantity = payload.quantity
if payload.notes is not None:
entry.notes = payload.notes
if payload.visibility is not None:
entry.visibility = payload.visibility
if "quality" in payload.model_fields_set:
# Explicit null clears the value; omitted keeps the existing one.
if payload.quality is not None:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{entry.class_name}'",
)
_validate_quality(item, payload.quality)
entry.quality = payload.quality
db.commit()
db.refresh(entry)
return InventoryEntryOut.model_validate(enrich_entry(entry))
@router.delete("/{class_name}", status_code=204)
def remove_inventory_entry(
class_name: str,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
entry = _find(db, user, class_name)
if entry is None:
raise HTTPException(status_code=404, detail="Item is not in your inventory")
db.delete(entry)
db.commit()
return Response(status_code=204)

View File

@@ -0,0 +1,254 @@
"""Load and normalize scunpacked JSON data into the inventory store.
Source data: https://github.com/richardthombs/scunpacked
(mirrored at https://github.com/StarCitizenWiki/scunpacked-data)
Real schemas this loader consumes:
- ships.json: {"UUID","ClassName","Name","Description","Career","Role",
"Manufacturer":{"UUID","Code","Name"},"Size","Length","Width",
"Height","Crew","Mass","IsVehicle","IsGravlev","IsSpaceship",...}
- ship-items.json / fps-items.json:
{"className","reference","itemName","type","subType","size",
"grade","name","tags","classification",
"stdItem":{"UUID","ClassName","Size","Grade","Width","Height",
"Length","Mass","Type","Name","Description","DescriptionText",
"Manufacturer":{"Name","Code","UUID"},"Tags":[...],"Ports":[...]}}
- manufacturers.json:{"Code","Name","Reference","Description"}
"""
import json
import os
import re
from app import config
PLACEHOLDER = "<= PLACEHOLDER =>"
CATEGORY_SHIPS = "ships"
CATEGORY_SHIP_ITEMS = "ship-items"
CATEGORY_FPS_ITEMS = "fps-items"
CATEGORY_COMMODITIES = "commodities"
# Commodities that can carry a mining/refining quality value (1-1000). These are
# the ores, minerals and gems that players actually mine (and their refined
# counterparts); crafted alloys, gases, organics, processed goods and junk
# by-products are deliberately excluded. Keep in sync with the Ore quality table
# at https://starcitizen.tools/Ore_quality.
QUALITY_MATERIALS = frozenset(
name.lower()
for name in (
"Agricium",
"Aluminum",
"Aphorite",
"Aslarite",
"Beradom",
"Beryl",
"Bexalite",
"Borase",
"Carinite",
"Copper",
"Corundum",
"Diamond",
"Dolivine",
"Feynmaline",
"Glacosite",
"Gold",
"Hadanite",
"Hephaestanite",
"Iron",
"Jaclium",
"Janalite",
"Laranite",
"Lindinium",
"Ouratite",
"Pressurized Ice",
"Quantainium",
"Quartz",
"Riccite",
"Sadaryx",
"Saldynium",
"Savrilium",
"Silicon",
"Stileron",
"Taranite",
"Tin",
"Titanium",
"Torite",
"Tungsten",
)
)
# Variant suffixes are presentation labels, not a different material: "Iron
# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)".
_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE)
_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE)
def _quality_applicable(name, category):
if category != CATEGORY_COMMODITIES:
return False
base_name = _QUALITY_VARIANT.sub("", name or "").strip()
base_name = _RAW_PREFIX.sub("", base_name).strip().lower()
return base_name in QUALITY_MATERIALS
def _load_json(data_dir, filename):
path = os.path.join(data_dir, filename)
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as f:
return json.load(f)
def _resolve_name(*candidates, class_name=None):
"""Pick the first real display name; fall back to a prettified ClassName.
Many scunpacked entries have names literally set to "<= PLACEHOLDER =>"
(untranslated labels), so the fallback rule is mandatory.
"""
for candidate in candidates:
if candidate and str(candidate).strip() and str(candidate).strip() != PLACEHOLDER:
return str(candidate).strip()
if class_name:
return str(class_name).replace("_", " ")
return "Unknown"
def _manufacturer(raw):
if not raw:
return {"code": "UNKN", "name": "Unknown Manufacturer"}
code = raw.get("Code") or raw.get("code") or "UNKN"
name = _resolve_name(raw.get("Name"), raw.get("name"), class_name=code)
return {"code": code, "name": name}
def _normalize_ship(ship):
class_name = ship.get("ClassName") or ""
manufacturer = ship.get("Manufacturer") or {}
flags = {
"is_spaceship": bool(ship.get("IsSpaceship")),
"is_vehicle": bool(ship.get("IsVehicle")),
"is_gravlev": bool(ship.get("IsGravlev")),
}
tags = [flag for flag, value in flags.items() if value]
return {
"uuid": ship.get("UUID") or "",
"class_name": class_name,
"name": _resolve_name(ship.get("Name"), class_name=class_name),
"category": CATEGORY_SHIPS,
"type": ship.get("Career") or "Ship",
"sub_type": ship.get("Role") or "",
"size": ship.get("Size") or 0,
"grade": None,
"manufacturer": _manufacturer(manufacturer),
"classification": "Ship",
"description": ship.get("Description") or ship.get("DescriptionText") or "",
"tags": tags,
"quality_applicable": False,
}, {
"career": ship.get("Career") or "",
"role": ship.get("Role") or "",
"length": ship.get("Length"),
"width": ship.get("Width"),
"height": ship.get("Height"),
"crew": ship.get("Crew"),
"mass": ship.get("Mass"),
**flags,
}
def _normalize_item(item, category):
class_name = item.get("className") or ""
std = item.get("stdItem") or {}
manufacturer = std.get("Manufacturer") or {}
raw_tags = std.get("Tags")
tags = list(raw_tags) if isinstance(raw_tags, list) else []
summary = {
"uuid": std.get("UUID") or item.get("reference") or "",
"class_name": class_name,
"name": _resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
"category": category,
"type": item.get("type") or std.get("Type") or "",
"sub_type": item.get("subType") or "",
"size": item.get("size") if item.get("size") is not None else std.get("Size"),
"grade": item.get("grade") if item.get("grade") is not None else std.get("Grade"),
"manufacturer": _manufacturer(manufacturer),
"classification": item.get("classification") or "",
"description": std.get("Description") or std.get("DescriptionText") or "",
"tags": tags,
"quality_applicable": _quality_applicable(
_resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
category,
),
}
if category == CATEGORY_COMMODITIES:
summary["size"] = None
details = {
"ports": std.get("Ports") or [],
"mass": std.get("Mass"),
"width": std.get("Width"),
"height": std.get("Height"),
"length": std.get("Length"),
}
return summary, details
class Store:
"""In-memory, read-only inventory store built from the scunpacked JSON."""
def __init__(self, items, details, manufacturers):
self.items = items
self.details = details
self.manufacturers = manufacturers
self.by_class_name = {item["class_name"]: item for item in items}
self.counts = {
CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS),
CATEGORY_SHIP_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_SHIP_ITEMS),
CATEGORY_FPS_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_FPS_ITEMS),
CATEGORY_COMMODITIES: sum(
1 for i in items if i["category"] == CATEGORY_COMMODITIES
),
}
def load_store(data_dir=None):
data_dir = data_dir or config.DATA_DIR
items = []
details = {}
for ship in _load_json(data_dir, "ships.json"):
summary, detail = _normalize_ship(ship)
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "ship-items.json"):
summary, detail = _normalize_item(entry, CATEGORY_SHIP_ITEMS)
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "fps-items.json"):
summary, detail = _normalize_item(entry, CATEGORY_FPS_ITEMS)
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "commodities.json"):
summary, detail = _normalize_item(entry, CATEGORY_COMMODITIES)
items.append(summary)
details[summary["class_name"]] = detail
# Deterministic order so pagination is stable.
items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"]))
manufacturers = sorted(
(
{
"code": m.get("Code") or "",
"name": _resolve_name(m.get("Name"), class_name=m.get("Code") or ""),
"description": m.get("Description") or "",
}
for m in _load_json(data_dir, "manufacturers.json")
),
key=lambda m: m["name"].lower(),
)
return Store(items, details, manufacturers)

View File

@@ -0,0 +1,157 @@
"""SQLAlchemy models for mutable user data (accounts + personal inventories).
The scunpacked catalog is deliberately NOT modeled here. It stays read-only in
memory (see app/catalog.py); inventory rows reference catalog items only by
`class_name`, so the catalog can be replaced wholesale without touching user
data.
"""
from datetime import datetime, timezone
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
def utcnow() -> datetime:
"""Timezone-aware current time (portable across SQLite and PostgreSQL)."""
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Display name as typed at registration.
username: Mapped[str] = mapped_column(String(32), nullable=False)
# Lowercased form, unique: gives case-insensitive uniqueness without citext.
username_key: Mapped[str] = mapped_column(
String(32), nullable=False, unique=True, index=True
)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
sessions: Mapped[list["UserSession"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
inventory: Mapped[list["InventoryEntry"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
orders: Mapped[list["Order"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class UserSession(Base):
__tablename__ = "sessions"
# Only the SHA-256 of the cookie token is persisted.
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
user: Mapped[User] = relationship(back_populates="sessions")
class InventoryEntry(Base):
__tablename__ = "inventory_entries"
__table_args__ = (
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"),
CheckConstraint(
"visibility IN ('private', 'available')",
name="ck_inventory_visibility",
),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_inventory_quality_range",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
# Mining/refining quality (1-1000) for commodities where it applies. NULL
# means "not specified"; non-quality commodities must keep this NULL. The
# applicability itself is catalog data, not stored here.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
# "private" (default): only the owner sees the row. "available": the owner
# is offering the item. Unrelated to the response flag `available`, which
# only reports whether the catalog still contains the referenced item.
visibility: Mapped[str] = mapped_column(
String(16), nullable=False, default="private", server_default="private"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
)
user: Mapped[User] = relationship(back_populates="inventory")
class Order(Base):
"""A public request for a catalog item placed by a logged-in user.
Orders are intentionally visible to everyone (see app/orders.py). They
reference catalog items by `class_name` only, exactly like inventory rows,
so a catalog refresh cannot break them.
"""
__tablename__ = "orders"
__table_args__ = (
CheckConstraint("quantity >= 1", name="ck_orders_quantity_positive"),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_orders_quality_range",
),
CheckConstraint(
"status IN ('open', 'fulfilled')",
name="ck_orders_status",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Mining/refining quality (1-1000); only valid for quality-applicable ores.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="open", server_default="open"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
)
user: Mapped[User] = relationship(back_populates="orders")

View File

@@ -0,0 +1,209 @@
"""Public order board.
Any logged-in user can place an order (a request) for a catalog item. Every
order is visible to everyone, with the requester's display name, quantity,
notes, and — for quality-applicable ores — the requested quality. Orders are
read-only for the public; only the owner can edit or remove their own order.
Like inventory rows, orders reference catalog items by `class_name` only, so a
dataset refresh cannot break them.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession
from app import catalog
from app.db import get_db
from app.deps import get_current_user, require_same_origin
from app.inventory import SORT_KEYS
from app.models import Order, User
from app.schemas import OrderCreateRequest, OrderOut, OrderPage, OrderPatchRequest
router = APIRouter(prefix="/api/v1/orders", tags=["orders"])
MAX_PAGE_SIZE = 100
def enrich_order(order: Order, username: str) -> dict:
item = catalog.store.by_class_name.get(order.class_name)
if item is None:
item_data = {
"name": order.class_name.replace("_", " "),
"category": None,
"type": None,
"size": None,
"grade": None,
"manufacturer": None,
"quality_applicable": False,
"available": False,
}
else:
item_data = {
"name": item["name"],
"category": item["category"],
"type": item["type"],
"size": item["size"],
"grade": item["grade"],
"manufacturer": item["manufacturer"],
"quality_applicable": item.get("quality_applicable", False),
"available": True,
}
return {
"id": order.id,
"class_name": order.class_name,
**item_data,
"quantity": order.quantity,
"notes": order.notes,
"quality": order.quality,
"username": username,
"status": order.status,
"created_at": order.created_at,
"updated_at": order.updated_at,
}
def _validate_quality(item: dict, quality: int | None) -> None:
if quality is not None and not item.get("quality_applicable", False):
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{item['name']}'",
)
@router.get("", response_model=OrderPage)
def list_orders(
category: str | None = None,
search: str | None = None,
status: str | None = Query(default=None, pattern="^(open|fulfilled)$"),
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
db: DbSession = Depends(get_db),
):
page_size = min(page_size, MAX_PAGE_SIZE)
rows = db.execute(
select(Order, User.username).join(User, User.id == Order.user_id)
).all()
items = [enrich_order(order, username) for order, username in rows]
if status is not None:
items = [i for i in items if i["status"] == status]
if category is not None and category.strip():
wanted = category.strip()
items = [i for i in items if i["category"] == wanted]
if search is not None and search.strip():
needle = search.strip().lower()
items = [
i
for i in items
if needle in i["name"].lower()
or needle in i["class_name"].lower()
or needle in i["username"].lower()
]
items.sort(key=SORT_KEYS[sort])
total = len(items)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"items": items[start : start + page_size],
}
@router.post(
"",
response_model=OrderOut,
status_code=201,
dependencies=[Depends(require_same_origin)],
)
def create_order(
payload: OrderCreateRequest,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
class_name = payload.class_name.strip()
item = catalog.store.by_class_name.get(class_name)
if item is None:
raise HTTPException(
status_code=404, detail=f"Catalog item '{class_name}' not found"
)
_validate_quality(item, payload.quality)
order = Order(
user_id=user.id,
class_name=class_name,
quantity=payload.quantity,
notes=payload.notes,
quality=payload.quality,
)
db.add(order)
db.commit()
db.refresh(order)
return enrich_order(order, user.username)
def _own_order(db: DbSession, user: User, order_id: int) -> Order:
order = db.get(Order, order_id)
if order is None or order.user_id != user.id:
# Do not reveal whether somebody else's order exists.
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.patch(
"/{order_id}",
response_model=OrderOut,
dependencies=[Depends(require_same_origin)],
)
def update_order(
order_id: int,
payload: OrderPatchRequest,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
order = _own_order(db, user, order_id)
if payload.quantity is not None:
order.quantity = payload.quantity
if payload.notes is not None:
order.notes = payload.notes
if payload.status is not None:
order.status = payload.status
if "quality" in payload.model_fields_set:
# Explicit null clears the value; omitted keeps the existing one.
if payload.quality is not None:
item = catalog.store.by_class_name.get(order.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{order.class_name}'",
)
_validate_quality(item, payload.quality)
order.quality = payload.quality
db.commit()
db.refresh(order)
return enrich_order(order, user.username)
@router.delete(
"/{order_id}",
status_code=204,
dependencies=[Depends(require_same_origin)],
)
def delete_order(
order_id: int,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
order = _own_order(db, user, order_id)
db.delete(order)
db.commit()
return Response(status_code=204)

View File

@@ -0,0 +1,156 @@
"""Pydantic request/response models for auth and personal inventory."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from app import config
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=32)
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=32)
password: str = Field(min_length=1, max_length=128)
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
created_at: datetime
class AuthResponse(BaseModel):
user: UserOut
class InventoryAddRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
visibility: Literal["private", "available"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryEntryOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
# Mining/refining quality (1-1000); only meaningful when
# `quality_applicable` is true. None means the owner has not set it.
quality: int | None = None
quality_applicable: bool = False
# Owner-chosen classification. Unrelated to `available` below.
visibility: Literal["private", "available"]
# False when the catalog no longer contains the referenced item (e.g. after
# a dataset refresh). The row is preserved rather than silently deleted.
available: bool
created_at: datetime
updated_at: datetime
class InventoryPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[InventoryEntryOut]
class InventorySummary(BaseModel):
items: int
quantity: int
categories: dict[str, int]
unavailable: int
class AvailableItemOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
quality: int | None = None
quality_applicable: bool = False
# Public listing: only the owner's display name, never notes.
username: str
updated_at: datetime
class AvailablePage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[AvailableItemOut]
class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut
created: bool
class OrderCreateRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
status: Literal["open", "fulfilled"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderOut(BaseModel):
id: int
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
quality: int | None = None
quality_applicable: bool = False
# Public listing: the requesting user's display name.
username: str
status: Literal["open", "fulfilled"]
# False when the catalog no longer contains the referenced item.
available: bool
created_at: datetime
updated_at: datetime
class OrderPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[OrderOut]

View File

@@ -0,0 +1,140 @@
"""Password hashing, session tokens, origin checks and login throttling."""
import hashlib
import re
import secrets
import threading
import time
from collections import deque
from urllib.parse import urlsplit
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
_hasher = PasswordHasher()
# Hash of a throwaway secret, verified when a username does not exist so that
# "unknown user" and "wrong password" take comparable time.
_DUMMY_HASH = _hasher.hash(secrets.token_urlsafe(16))
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_LENGTH = 128
def normalize_username(username: str) -> str:
return username.strip().lower()
def valid_username(username: str) -> bool:
return bool(USERNAME_RE.match(username.strip()))
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def burn_password_check(password: str) -> None:
"""Constant-ish work for unknown users, to blunt username enumeration."""
try:
_hasher.verify(_DUMMY_HASH, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
pass
def needs_rehash(password_hash: str) -> bool:
return _hasher.check_needs_rehash(password_hash)
def new_session_token() -> str:
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def client_ip(request) -> str:
"""Real client address as seen by the reverse proxy.
nginx overwrites X-Real-IP with the actual peer address, so it is safe to
trust here. X-Forwarded-For is client-appendable and is never used.
"""
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
def same_origin(request) -> bool:
"""Reject cross-origin state-changing requests (CSRF defense in depth)."""
origin = request.headers.get("origin")
if not origin:
return True # non-browser client; the session cookie is still required
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or ""
return urlsplit(origin).netloc.lower() == host.lower()
class LoginThrottle:
"""In-process sliding-window limiter for failed logins.
Tracks failures per (username, ip) and, with a higher ceiling, per ip so a
single address cannot spray passwords across many usernames.
"""
def __init__(self, max_attempts: int, window_seconds: int):
self.max_attempts = max_attempts
self.window = window_seconds
self._events: dict[tuple[str, str], deque[float]] = {}
self._lock = threading.Lock()
def _keys(self, username_key: str, ip: str) -> list[tuple[tuple[str, str], int]]:
return [
((username_key, ip), self.max_attempts),
(("*", ip), self.max_attempts * 4),
]
def _recent(self, key: tuple[str, str], now: float) -> deque[float] | None:
events = self._events.get(key)
if not events:
return None
cutoff = now - self.window
while events and events[0] < cutoff:
events.popleft()
if not events:
self._events.pop(key, None)
return None
return events
def retry_after(self, username_key: str, ip: str) -> int:
"""Seconds to wait before another attempt is allowed (0 if allowed)."""
now = time.monotonic()
with self._lock:
waits = []
for key, limit in self._keys(username_key, ip):
events = self._recent(key, now)
if events and len(events) >= limit:
waits.append(int(self.window - (now - events[0])) + 1)
return max(waits) if waits else 0
def record_failure(self, username_key: str, ip: str) -> None:
now = time.monotonic()
with self._lock:
if len(self._events) > 5000: # opportunistic cleanup
for key in list(self._events):
self._recent(key, now)
for key, _limit in self._keys(username_key, ip):
self._events.setdefault(key, deque()).append(now)
def reset(self, username_key: str, ip: str) -> None:
with self._lock:
for key, _limit in self._keys(username_key, ip):
self._events.pop(key, None)
def clear(self) -> None:
with self._lock:
self._events.clear()

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,838 @@
[
{
"Code": "987",
"Name": "987",
"Reference": "3b54a8cc-5700-45f1-940a-2436b213926f",
"Description": "Known for their more aggressive urban styling. Asymmetrical. Bold graphic prints."
},
{
"Code": "ACAS",
"Name": "Ace Astrogation",
"Reference": "cfc0122d-5275-415a-a656-3dcb3346feb5",
"Description": "Corp focusing on the structural (non-combat) aspects of the ship. Builds sleek, elegant but powerful power plants."
},
{
"Code": "ACDO",
"Name": "Aciedo Communications",
"Reference": "b99ddb18-b325-44de-abfa-ce2e978424e4",
"Description": "Responsible for building the CommRelay infrastructure around the UEE, Aciedo also developed the technology to allow for system to system communications."
},
{
"Code": "ACOM",
"Name": "ACOM",
"Reference": "5d616e6f-3257-4d19-90c3-11ab974f12e7",
"Description": "In racing circles, the term ACOM has come to have two meanings. The first is an abbreviation of the phrase ‘a commanding lead’ – meaning to pull so far ahead that the rest of the pack doesn’t stand a chance of catching up. The second is a more recent usage in that it refers to the start-up manufacturing company who took the phrase as their epithet. ACOM focuses on creating ship components that handle as much power as possible as quickly as possible in order to give pilots the all-important extra edge during a race. Founded by the children of famed pit crew chief Garrit Bijarani, siblings Jayce and Zan took four generations of racing knowledge and gave it form in ACOM’s full line of power plants and coolers."
},
{
"Code": "AEGS",
"Name": "Aegis Dynamics",
"Reference": "cf4a74bf-eb2c-462a-9b78-f7f2724c31d2",
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
},
{
"Code": "AEHA",
"Name": "Aeroview Hangars",
"Reference": "c7865487-2114-4863-b691-2d7d29ce1bfa"
},
{
"Code": "ALB",
"Name": "Alejo Brothers",
"Reference": "dd14fd7f-eead-4f7d-8057-2d95405a0183",
"Description": "Reflect your rugged independence and attitude with Alejo Brothers gear."
},
{
"Code": "AMRS",
"Name": "Amon & Reese Co.",
"Reference": "eccba58a-f6d9-42e8-95c6-d0a65763e583",
"Description": "Formed early in the Messer era by recent veterans Marcelo Amon and Travis Reese, A&R Co. is one of the oldest energy weapon manufacturers in the UEE. Their designs have always been user focused and they have a well-earned reputation for dependability and ease of use, something the company claims derives from a dedication to field testing that well exceeds what other companies do."
},
{
"Code": "ANVL",
"Name": "Anvil Aerospace",
"Reference": "b922abdb-0634-4358-9eee-60118f3cf3a7",
"Description": "For civilians, Anvil Aerospace produces small fighting craft but with less of the pirate stigma. These ships are more expensive, less spit-and-glue, and built to take more hits than a comparable Drake model. The bulk of Anvil's income is from its military contracts with the UEE to design and build their warships, especially the iconic Hornet."
},
{
"Code": "APAR",
"Name": "Apocalypse Arms",
"Reference": "77f7999a-b23b-4812-94c2-09b5e15e1b0e",
"Description": "Apocalypse Arms produces aggressive, devastating weapons inspired by Messer-Era technology."
},
{
"Code": "APLL",
"Name": "Aparelli",
"Reference": "27e371d2-2dcb-428c-a32a-a17e246f02e8"
},
{
"Code": "ARCC",
"Name": "ArcCorp",
"Reference": "0ade6e45-ecdd-42a9-9046-3e96dbe4f5d0",
"Description": "This massive conglomerate owns one of the planets in the Stanton system, but got its start building fusion engines for industrial craft. They also have a division for terraforming that's diverged into more of a mining consortium."
},
{
"Code": "ARGO",
"Name": "Argo Astronautics",
"Reference": "9e1f5a35-edcc-4d19-bede-08e2730043c5",
"Description": "This commercial ship manufacturer focuses on building shuttles, transport vessels and utility vehicles that can be seen around the universe."
},
{
"Code": "ARMA",
"Name": "ArmaMod",
"Reference": "f2ca879c-deb6-49bf-a4b1-dc3a9cf91e7a",
"Description": "ArmaMod"
},
{
"Code": "ASAD",
"Name": "Associated Sciences & Development",
"Reference": "055fe7c5-2e02-4e3a-aacb-f289563fabbd",
"Description": "PH Associated Science and Development Description"
},
{
"Code": "ASAS",
"Name": "Ascension Astro",
"Reference": "abca0ae0-a819-4aeb-a1a7-f8f1d7e277cf",
"Description": "Ascension Astro started off as a boutique specialty company; selling high-end, low IR thrusters to wealthy clients who wanted to fly their luxury ships while avoiding the eyes of greedy pirates. As good as their thrusters were at being low-sig, the effect was often ruined when the engines or shielding made by other manufacturers would be screaming the ship’s presence. Slowly, Ascension began expanding their line to include a whole suite of components that, when working in conjunction, would provide an extremely low profile and a safer journey."
},
{
"Code": "ATRO",
"Name": "Astro Armada",
"Reference": "db2fdcd3-aa45-4878-bdba-8d6062885f45"
},
{
"Code": "BANU",
"Name": "Banu Souli",
"Reference": "2b5ba252-e826-4356-ac0d-8d88007ea386",
"Description": "Banu society combines the Human concepts of family and corporations into a single social unit know as a Souli, a cohabitation focused centered around a specific skillset. These Banu guilds do not control an entire market, rather there are always numerous Souli competing for the same market as the guilds are constantly in flux. \n\nBecause of this, the Banu do not market specific brand Soulis. Instead they simply market all their wares to Humans as Banu built."
},
{
"Code": "BASL",
"Name": "Basilisk",
"Reference": "2948496b-45d2-41f8-bdff-97587f1e6981",
"Description": "Defensive system manufacturer who produces shield systems, ship armor and personal armor for both the military and private sector."
},
{
"Code": "BEHR",
"Name": "Behring Applied Technology",
"Reference": "65a5d887-3b21-4046-a718-6912c0c7c3be",
"Description": "The Behring Research Consortium was founded in 2554 on Terra as the end result of several years of buyouts and consolidation of dozens of small research and development firms by Behring Equity Investments. By having so many different research units focusing on a wide variety of developments, the Behring group hoped to increase the likelihood of expensive research resulting in a marketable product. Their history speaks for itself as the famed laboratory is accredited for the standardization of laser weaponry and dozens of other innovations. Today, Behring is still going strong with their technological forward products featuring straight forward and simple designs, making everything from guns, missiles, sentry drones, to shields and armor."
},
{
"Code": "BLTR",
"Name": "Blue Triangle Inc.",
"Reference": "bcea197e-ac9d-49f4-a692-d77c6927077f",
"Description": "Makers of advanced computer and analytic systems, Blue Triangle prides itself on hiring only the best engineers and programmers, many of whom started their careers as black hat hackers."
},
{
"Code": "BRRA",
"Name": "Broad & Rabiee",
"Reference": "ad865899-f5ba-4818-ae8c-fe80a55108b7",
"Description": "Founded by a pair of aerospace engineers, Broad & Rabiee began with a line of computers and components before branching out to turrets."
},
{
"Code": "CBA",
"Name": "Code Blue Apparel",
"Reference": "d9179d05-55be-4eed-8b1b-93bb8450ff04",
"Description": "Code Blue Apparel produces a wide variety of longwear professional uniforms and jumpsuits that focus on comfort and personality without sacrificing functionality."
},
{
"Code": "CBD",
"Name": "Custom Built Duds",
"Reference": "64eef2d9-5d3d-431d-a83b-ae077a16b4e2",
"Description": "CBD. They're clothes. Either buy them or don't."
},
{
"Code": "CCC",
"Name": "CC's Conversions",
"Reference": "be4a26fd-0585-4aaf-bad9-d234091bd02e",
"Description": "CC's Conversions Description"
},
{
"Code": "CDS",
"Name": "Clark Defense Systems",
"Reference": "8a7bd715-5b97-404f-b02e-0d0cc593f608",
"Description": "Ground-combat defensive systems, covers a lot of commercial territory from personal armor to defense systems. Naming convention tend to be pretty straight-forward and logical.\n\nModels: PAB-4 (Personal Ablative/Ballistic Light Armor)\n\nQDB-12 QuiKCade (Quick Deployment Barricade)"
},
{
"Code": "CHCO",
"Name": "Chimera Communications",
"Reference": "8af71a8a-7da0-4e31-afc7-648fc916e387",
"Description": "A dedicated manufacturer of communication, radar and scan systems. They are the rugged go-to company for miners and explorers alike with their sturdy, well-tested and dependable products."
},
{
"Code": "CLDA",
"Name": "Caldera",
"Reference": "eddc1a94-7840-4855-9889-544821d4d739",
"Description": "Caldera Description."
},
{
"Code": "CNOU",
"Name": "Consolidated Outland",
"Reference": "ec227ef3-fb08-4897-abdb-579eb4e87c25",
"Description": "Founded by maverick trillionaire Silas Koerner, Consolidated Outland is an up-and-coming spacecraft concern, the first ever headquartered on a frontier world. Koerner, who made his sizable fortune in jump-communications, founded the company as an attempt to strike back at what he sees as the overly-regulated spacecraft industry. With facilities established as far from possible from the prying eyes of competing corporations, Outland is quickly becoming one to watch."
},
{
"Code": "CNTR",
"Name": "CenterMass",
"Reference": "e441348b-6b41-4d00-a381-5ff20e014c2c"
},
{
"Code": "CONO",
"Name": "Conscientious Objects",
"Reference": "10d3571e-dd95-4e5d-a644-ccaee0f1fdb9"
},
{
"Code": "CORD",
"Name": "Cordry's",
"Reference": "918384f8-9a35-465a-a515-ba256e96d6e8"
},
{
"Code": "CRLF",
"Name": "CureLife",
"Reference": "dd55bc19-5212-4365-bbc8-73474f0257ed",
"Description": "Makes medical packs for commercial purposes. Formed by a group of doctors, CureLife was the first company to offer heavy medical solutions to the general public."
},
{
"Code": "CRUS",
"Name": "Crusader Industries",
"Reference": "63685f47-b0cc-4ebe-8f16-72793a5cd6e0",
"Description": "Makers of an extensive fleet of cargo, vehicle and data transport ships, and cutting edge shuttles, Crusader Industries is a titan of the aerospace industry. It is a company so powerful and prominent that it purchased a gas giant in the Stanton system to house its current headquarters."
},
{
"Code": "CTL",
"Name": "City Lights",
"Reference": "5ca8a669-44c2-40e4-89b2-7a5da790b832",
"Description": "City Lights makes timeless, affordable fashion for the entire family."
},
{
"Code": "CUBY",
"Name": "Cubby Blast",
"Reference": "d73a45ce-6b59-4791-937d-1bd7a0819ee0"
},
{
"Code": "Commodity_UILO_C",
"Name": "<= PLACEHOLDER =>",
"Reference": "9fcc226b-9200-4966-9ea4-2054dac4d897"
},
{
"Code": "DMC",
"Name": "Denim Manufacture Corporation",
"Reference": "a53bbc2b-aa43-483d-967e-0cebc5600c04",
"Description": "Still renowned for their denim, DMC (Denim Manufacture Corporation) has become so much more. From durable outerwear to rugged fashion, DMC knows that feeling good is just as important as looking good."
},
{
"Code": "DOOM",
"Name": "Doomsday",
"Reference": "b495993e-c10a-41e4-876d-1de96de54e33",
"Description": "Extremely aggressive looking homemade armor meant to protect and intimidate. This kind of armor is not only expected to survive the apocalypse, but also dominate its aftermath."
},
{
"Code": "DRAK",
"Name": "Drake Interplanetary",
"Reference": "6116471c-891d-4830-8968-650b6deafc00",
"Description": "Makes affordable, modular ships that have become the go-to manufacturer for anyone looking to get their first ship, but have become associated with criminal elements."
},
{
"Code": "DRN",
"Name": "Derion",
"Reference": "2ab62815-6273-44b9-bbc6-28d36fed6fd8",
"Description": "With their muted colors and classic cuts, Derion has become the definition of high-end business wear for men and women."
},
{
"Code": "DYL",
"Name": "Dyna Lei",
"Reference": "92b20988-dbf3-4e95-b4a9-7e5e22112516",
"Description": "- Dyna Lei Description"
},
{
"Code": "ELD",
"Name": "Escar Limited",
"Reference": "190a2e0e-6d60-4719-8238-ac7e1c746626",
"Description": "Escar Limited takes comfortable casual wear to the next level through interesting textiles and exquisite craftsmanship."
},
{
"Code": "ESPR",
"Name": "Esperia",
"Reference": "4ecc1a24-d228-4dd5-8857-21ba6145a1d3",
"Description": "Originally formed as an archive for ship designs, Esperia began to build historically accurate reproductions for collectors, enthusiasts, and even the military."
},
{
"Code": "EVL",
"Name": "EvaLight",
"Reference": "c0635984-4973-41c6-ac27-0c9a39ae051d",
"Description": "They can't define you. They can't tell you who you are. EvaLight and let your voice be heard."
},
{
"Code": "FFSY",
"Name": "Flashfire Systems",
"Reference": "b9b97673-c585-4e0a-b229-a0a3c79668c3",
"Description": "Failed laser weapon manufacturer. Transformed into an engineering firm that came up with the weapon mount system that's almost uniformly used."
},
{
"Code": "FIO",
"Name": "Fiore",
"Reference": "503b0974-5027-4c69-bce6-a38c42e87ebe",
"Description": "A timeless elegance lends an ease of wear that defines each sophisticated piece in Fiore's signature collection."
},
{
"Code": "FSKI",
"Name": "FireStorm Kinetics",
"Reference": "5f81335d-44e6-4104-841e-c5af9df06829",
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
},
{
"Code": "FTA",
"Name": "Fortified Armor",
"Reference": "0b32addb-fed0-4a4a-85b2-e3b397c4f062"
},
{
"Code": "FTLC",
"Name": "FTL Courier Service",
"Reference": "7db7b41d-ff7f-4ed9-9550-7aaaae9fa265"
},
{
"Code": "GAMA",
"Name": "Gatac Manufacture",
"Reference": "31907472-a884-4ebc-86db-cd4f7797e514",
"Description": "Responsible for the design and construction of any ships with commercial or industrial applications, Gatac Manufacture has a long-standing reputation as a ‘knowledge-driven’ corporation producing not only cutting edge ships, but extremely reliable ones."
},
{
"Code": "GATS",
"Name": "Gallenson Tactical Systems",
"Reference": "a834198a-3465-4b02-86a5-887e922ec06d",
"Description": "This Earth-based company is responsible for hard-hitting array of ship weapons."
},
{
"Code": "GEND",
"Name": "Consumable",
"Reference": "f80ab8de-4d04-4a1f-a8f2-ee2bb01fd125",
"Description": "PH Generic Drink Description"
},
{
"Code": "GENF",
"Name": "Consumable",
"Reference": "1b6be99e-f430-459f-ab2a-a912fd6722f8",
"Description": "PH Generic Food Description"
},
{
"Code": "GLSN",
"Name": "Grey's Market",
"Reference": "768b4380-6486-4187-8c22-bc3deaf80770",
"Description": "A fleet of mobile marketplaces that caters to high-end clients interested in experiencing the wild side of the 'verse to gain access to unusual and unique items."
},
{
"Code": "GMNI",
"Name": "Gemini",
"Reference": "b33050cd-6ca9-4c57-baf8-f4114eb85237",
"Description": "Personal weapon manufacturer. Generally makes kinetic weapons, but has recently acquired Octa, a small energy weapon start-up. Weapons are named sequentially."
},
{
"Code": "GODI",
"Name": "Gorgon Defender Industries",
"Reference": "57bfcf3d-13f6-4c2c-b7c7-6bf8eacb8a24",
"Description": "When Humanity started being attacked by Vanduul, it was often those who lived in the border system who suffered the most. Believing that civilian defensive components were not up to facing this deadly enemy, Gorgon Defender Industries, founded by former inhabitants of Caliban, was established to take military strength products and deliver it the population at large so they could properly protect themselves. With the strength of their designs, Gorgon earned a reputation that soon caught the eye of the military."
},
{
"Code": "GRIN",
"Name": "Greycat Industrial",
"Reference": "819545f4-18af-49a6-814e-ca4fd08f56e5",
"Description": "A trusted manufacturer of industrial tools since 2337, the company’s extensive line of industrial equipment and armor make it one of the most respected brand names around. Greycat has remained a UEE staple for centuries thanks to their streamlined-yet-inventive designs, exacting safety standards, and a diverse product line."
},
{
"Code": "GRNP",
"Name": "Groupe Nouveau Paradigme",
"Reference": "3909dad3-eab3-4da7-9d90-74d7064d5900",
"Description": "Seeking to stand out in the crowded field of manufacturers and to compete with the more developed “homeworlds,” the governments of four outer systems banded together to subsidize the creation of GNP, an entirely new kind of company that specialized in designing everything from full-range sensor suites to ship engines."
},
{
"Code": "GSB",
"Name": "Grindstone Boots",
"Reference": "6d4b7375-8e0c-445a-a243-21f8f94f99cb",
"Description": "GSB description"
},
{
"Code": "GYS",
"Name": "Gyson Inc.",
"Reference": "d56a3985-2f29-4095-b2f2-542376cc74f4",
"Description": "If you need it, Gyson makes it. The company and its subsidiaries produce everything from carpets to cleaning solvents to industrial uniforms and even EVA suits at prices that can't be beat."
},
{
"Code": "HDGW",
"Name": "Hedeby Gunworks",
"Reference": "9d3ee727-bb52-4ed2-bacf-47d93d8bb0d5",
"Description": "Hedeby Gunworks Description"
},
{
"Code": "HDH",
"Name": "Habidash",
"Reference": "70ce6432-3a1e-45a1-8179-fe30a3ee54dc",
"Description": "Very simply made clothing that is made for extreme environments. Takes inspiration from middle east and inuit designs."
},
{
"Code": "HDTC",
"Name": "Hardin Tactical",
"Reference": "8368a760-84b4-4359-bda1-7b015dc0c884",
"Description": "PH Hardin Tactical Desc"
},
{
"Code": "HRST",
"Name": "Hurston Dynamics",
"Reference": "ee8b7bb3-d420-435c-924f-561e67e64588",
"Description": "Hurston Dynamics Manufacturer placeholder info about company."
},
{
"Code": "HWK",
"Name": "Hawksworth's",
"Reference": "d7a8b196-8778-4e18-8c8c-38229eef01f1",
"Description": "When they say that shoes make the man, Hawksworth's are the shoes they are talking about. Formal, classic, and constructed to the same exacting standards that has made the Hawksworth brand the standard to beat."
},
{
"Code": "JOKR",
"Name": "Joker Engineering",
"Reference": "19fbaf6d-d6dc-4e15-8fdf-67101934ff3c",
"Description": "From the mind of underground engineer Barlowe Elias, Joker Engineering is here to push the boundaries of tech, the foundations of civilization with their wild products."
},
{
"Code": "JSPN",
"Name": "J-Span",
"Reference": "6cf93625-789a-4e8f-a857-325d64a58c93",
"Description": "J-Span does only one thing, but does it well. For over a century they have focused on producing quality coolers at a reasonable price. Half the time these days, when you pop the hull of a spacecraft, chances are you’re going to find a J-Span. Plus, their coolers can be found in more than just ships. They have a wide variety of sizes and shapes that can be found in everything from land transports, to computer systems, to refrigerator units. Appealing to the broader consumer market, their units might not have all the bells and whistles as some of the other manufacturer’s lines, but for the most part they avoid their quirks as well."
},
{
"Code": "JUST",
"Name": "Juno Starwerk",
"Reference": "d7cc897f-b760-4a73-93c0-27e23c52db0a",
"Description": "Second only to RSI in terms of longevity in the space industry, Juno Starwerk was founded in the 24th century to build long haul freighters to support the rapid Human expansion into other star systems. Utilitarian above all us, these ships were designed to do one thing: haul as much weight as possible as cheaply as possible. While, over time, as competition increased, their share of the ship market shrank, their components had earned a reputation for dependability and ease of maintenance. Their direct sales of these components soon made up the majority of the companies income and in 2513, the ceased ship manufacture entirely to focus on supplying industrial strength components. In parts of the ’verse there are generations of mechanics who have passed knowledge and techniques for working on Juno power plants like family heirlooms. This long history also means that Juno parts and repairs are easy to come by."
},
{
"Code": "KAP",
"Name": "Kilgore and Poole",
"Reference": "a349d45f-cda5-4fdb-89ea-4ea91804a163",
"Description": "Kilgore and Poole caters its armor and image to a high-end clientele. Criticized by some as prioritizing form over function, their eye-catching armor favors ease of movement and agility over heavy defense."
},
{
"Code": "KBAR",
"Name": "KnightBridge Arms",
"Reference": "7b4402a1-f17f-4125-afcc-4a159dcd29e0",
"Description": "Headquartered in the Kiel system, KnightBridge Arms is there to protect you and yours. With a weapon for every price point, you can rest assured that KnightBridge has your back."
},
{
"Code": "KCTR",
"Name": "KC Trending",
"Reference": "c52dc2d9-624c-4722-b63a-33a38ac0039c"
},
{
"Code": "KLWE",
"Name": "Klaus & Werner",
"Reference": "1aa076d5-ac29-4c61-a905-d3ea39cf167a",
"Description": "Formed in 2893, Klaus & Werner makes guns, and they make them well. This simple philosophy was the brainchild of Hector Klaus, a successful weapons inventor who believed above all else that simplicity of design would triumph in a combat situation."
},
{
"Code": "KRIG",
"Name": "Kruger Intergalactic",
"Reference": "7b5d1ed3-11e7-49e6-9ed3-9d0cb52a3dc2",
"Description": "Kruger Intergalactic started as a custom machined parts manufacturer on Borea in the Magnus system, eventually expanded into weapon development and finally as a manufacturer of short-range ships like the P-52."
},
{
"Code": "KRON",
"Name": "Kroneg",
"Reference": "2a64f92e-8f6d-47ac-a6ed-643465e91d4b",
"Description": "Kroneg Description"
},
{
"Code": "KSAR",
"Name": "Kastak Arms",
"Reference": "709e8255-a0b1-4c5e-8b0e-28781be25984",
"Description": "Kastak Arms makes inexpensive energy weapons with intimidating names like Devastator and Firestorm, and aggressively markets them to distressed parts of the Empire. Even though the company portrays itself as providing weapons for personal defense, the brand has become more readily identified with people who use them for aggressive and unlawful purposes."
},
{
"Code": "LBCO",
"Name": "Lightning Bolt Co.",
"Reference": "98bb2e9e-fcc7-46e5-9ba6-d34c42c414e0"
},
{
"Code": "LOCO",
"Name": "Lortell Computing",
"Reference": "4ee4ba50-f4e3-4c54-9f44-9a6c5691c77e",
"Description": "A manufacturer of affordable computers, computer parts, and accessories. There products don't have a lot of bells and whistles and often incorporate last gen tech, but because of that, what they do produce is reliable. Produce the Re-Authorized brand cryptokey."
},
{
"Code": "LPLT",
"Name": "Lightning Power Ltd.",
"Reference": "60d0f346-0c22-4869-81d3-1168b526a258",
"Description": "Made out of less expensive materials, Lightning Power continues to introduce new aesthetic changes to their ship components while relying on tried and true technology to run them."
},
{
"Code": "MISC",
"Name": "Musashi Industrial and Starflight Concern",
"Reference": "b28a5c61-63a4-478b-8047-5fba545d5b8a",
"Description": "Mass produces very efficient, modular ships, mostly armored freighters of different sizes that make them the preferred brand for traders and larger corporations. Over thirty years ago, MISC signed a lend-lease agreement to establish a tech trade with Xi’An engineers."
},
{
"Code": "MITE",
"Name": "microTech",
"Reference": "9f0f7735-d389-4c7d-95f1-c4d0c57f7214",
"Description": "Ubiquitous around the Empire, microTech and their range of electronics, datapads, mobile interfaces, handheld sensors and computer software upgrades for starships are used daily by almost everyone. Their line of mobiGlas personal computers have become an integral part of daily life for millions of people."
},
{
"Code": "MRAI",
"Name": "Mirai",
"Reference": "cdf6ea1e-7de3-4b58-bf9d-13c5fbafc49a",
"Description": "The Mirai design team is dedicated to pairing the craftsmanship of their parent company MISC with Xi’an technology and traditional Saisei aesthetics to create the next generation of cutting-edge vehicles."
},
{
"Code": "MXOX",
"Name": "MaxOx",
"Reference": "de0d3594-9f18-42c0-8a90-6edd723d80f8",
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
},
{
"Code": "MYM",
"Name": "MuyMuy",
"Reference": "cc1731c4-8156-4750-a138-96838df79117",
"Description": "MYM description"
},
{
"Code": "NAVE",
"Name": "Nav-E7 Gadgets",
"Reference": "afff101e-eb2c-4f93-960f-cec9936b9f39",
"Description": "In the short time the company has been around, Nav-E7 has become an institution. Manufacturing various pieces of equipment used on a wide range of ships has given them quick renown as most ships in the 'verse feature at least one of their products."
},
{
"Code": "NOVP",
"Name": "Nova Pyrotechnica",
"Reference": "67fd3077-ee8c-4a7c-8bad-91500b2a9509",
"Description": "An company out of Terra that offers missiles and rockets at a low price."
},
{
"Code": "NRS",
"Name": "NorthStar",
"Reference": "2d1922e0-f8dc-46d3-a453-6a85711f3771",
"Description": "NorthStar Description"
},
{
"Code": "NVS",
"Name": "Navy Surplus",
"Reference": "f8efa046-7f79-4e09-b2fc-35d0df2429cf",
"Description": "PH - Navy Surplus Description"
},
{
"Code": "NVTC",
"Name": "NV-TAC",
"Reference": "f113387e-0905-489a-829d-8cebfdc19e66",
"Description": "High-end company known for manufacturing technically precise and experimental optics and weapon attachments. Founded in 2811, NV-TAC initially built sensors for long-range scanning stations and surveying operations before shifting operations to the more profitable personal weapon market. Seen by some as too expensive and feature-dense, but beloved by soldiers and security professionals for their durable design, precision adjustments, and reliable performance."
},
{
"Code": "NVY",
"Name": "United Empire of Earth Navy",
"Reference": "d133f90d-bc69-4273-9867-6c03631f0c4b",
"Description": "(PH) UEE Navy - Description"
},
{
"Code": "NYX_A",
"Name": "",
"Reference": "33c31854-6b5e-4687-9b75-322b6610d734"
},
{
"Code": "OCT",
"Name": "Octagon",
"Reference": "4a05001d-0511-415f-8e32-618fbd75242b",
"Description": "PH Octagon Description"
},
{
"Code": "OGPO",
"Name": "Omega Pro",
"Reference": "9b460be8-0c7c-47fb-a6ac-21aa88a6c09f"
},
{
"Code": "OPS",
"Name": "OpalSky",
"Reference": "6f5af1a4-a50d-41e5-8bb0-c1acd89d4b9e",
"Description": "Classic, simple and timeless, OpalSky creates casual attire. What millionaires wear when lounging on their space yachts."
},
{
"Code": "ORIG",
"Name": "Origin Jumpworks",
"Reference": "1e47a9ec-a3f0-431d-be0e-377712e1ed84",
"Description": "Known primarily for high-end ship manufacturing, but also provides some custom parts for their ships - particularly maneuvering thrusters. Sleek and elegant fusion of art and engineering"
},
{
"Code": "OUTL",
"Name": "Outlaw",
"Reference": "7e8a8608-3b02-42cc-83ea-4efe430c60a8"
},
{
"Code": "PCDF",
"Name": "Procyon CDF",
"Reference": "1b4babeb-5c3d-4c31-a9dd-5f53e0e8893c"
},
{
"Code": "PRAR",
"Name": "Preacher Armament",
"Reference": "a2ba1db6-a7e8-49db-ba52-c61219693531",
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
},
{
"Code": "QRT",
"Name": "Quirinus Tech",
"Reference": "64f8b397-8c0d-40b0-96a0-52bbeb41e5ad",
"Description": "Quirinus Tech Description"
},
{
"Code": "R6P",
"Name": "R6 Pro",
"Reference": "1ddcadf0-7bb0-4fa9-94f0-6af19432631c",
"Description": "R6 Pro's rugged footwear is made to withstand wear and tear in even the most extreme environments, so even if you get knocked down, we're there to help you get back up again."
},
{
"Code": "RACO",
"Name": "RAMP Corporation",
"Reference": "57432775-b421-45cc-8616-ac2d35018193",
"Description": "Manufacturer of consumer and pro-grade ship components, RAMP Corp sponsors the Team RAMP M50 racing team."
},
{
"Code": "REYO",
"Name": "Revel & York",
"Reference": "c4052a24-2547-4ebc-9137-aa9f09a907af"
},
{
"Code": "RMB",
"Name": "Rambler",
"Reference": "e7489831-23c1-4629-ab36-221bb9bad06f",
"Description": "PH - Rambler Description"
},
{
"Code": "ROO",
"Name": "Republic of One",
"Reference": "4835d29c-3cdd-4983-97b2-1af4674c4c00",
"Description": "Soft drinks, high end kitchen equipment, children’s toys? Sakura Sun. Rover platforms, terraforming solutions, bioengineered livestock? Sakura Sun. High-tech wave cannons, starship power plants, antimatter ordinance? Sakura Sun! While corporate conglomerates are not uncommon, it’s rare for any company to advertise them all under the same banner. But at Sakura Sun, empire-building is in full effect: you’ll find a little bit of everything branded with a distinctive purple sun-and-cherry-blossom logo, from plastic Hi-Ho Flatcat figurines to top-of-the-line multi-particle/wave cannons. Stranger still, Sakura Sun has successfully gained a reputation as a company that not only does a little bit of everything, but does it well."
},
{
"Code": "RRS",
"Name": "Roussimoff Rehabilitation Systems",
"Reference": "73a6ba90-8f53-423d-ad0b-96e81b7e18a5",
"Description": "Roussimoff Rehabilitation Systems (RRS) is an Earth-based corporation that provides rehabilitation services, personal body armor and defensive systems throughout the UEE. Their arms division is responsible for a groundbreaking series of new weaponry designed to incapacitate opponents without taking lives."
},
{
"Code": "RSI",
"Name": "Roberts Space Industries",
"Reference": "093e6eba-93fa-4dad-b3dd-38966934e34e",
"Description": "The original creators of the engine that kickstarted Humanity’s expansion into space, Roberts Space Industries builds a wide range of spaceships that serve all needs, from basic interstellar travel to deep exploration on the outer edges of the galaxy. The tagline is “Roberts Space Industries: Delivering the Stars since 2075”"
},
{
"Code": "SASU",
"Name": "Sakura Sun",
"Reference": "40c59850-8fb5-4a0e-ab61-7fd3871a632a",
"Description": "Soft drinks, high end kitchen equipment, children’s toys? Sakura Sun. Rover platforms, terraforming solutions, bioengineered livestock? Sakura Sun. High-tech wave cannons, starship power plants, antimatter ordinance? Sakura Sun! While corporate conglomerates are not uncommon, it’s rare for any company to advertise them all under the same banner. But at Sakura Sun, empire-building is in full effect: you’ll find a little bit of everything branded with a distinctive purple sun-and-cherry-blossom logo, from plastic Hi-Ho Flatcat figurines to top-of-the-line multi-particle/wave cannons. Stranger still, Sakura Sun has successfully gained a reputation as a company that not only does a little bit of everything, but does it well."
},
{
"Code": "SCU",
"Name": "Stegman's Clothing and Uniforms",
"Reference": "20121812-97a6-4ce1-a6a4-88a5ed646c58",
"Description": "SCU description"
},
{
"Code": "SECO",
"Name": "Seal Corporation",
"Reference": "f91beb30-915c-42f6-9b1f-f5bd74633fd6",
"Description": "One of the earliest companies to begin selling energy based shielding to civilians when the technology was declassified, Seal Corp has been in the industry centuries longer than its current modern day competition. For a while, the term ‘seal’ was ubiquitous with shield and Sammy the Seal Corp seal is still one of the most recognizable mascots across the ’verse. While they have lost some market share to competitors over the years, they are still the most widely used brand of shields and their products can be bought easily in almost every system."
},
{
"Code": "SELA",
"Name": "SELF-LAND",
"Reference": "4526be72-2c19-453c-ab43-29cc23f77154"
},
{
"Code": "SHIN",
"Name": "Shubin Interstellar",
"Reference": "ab35f85f-919f-4457-8d58-4e885ea6c5c0",
"Description": "Shubin Interstellar Description"
},
{
"Code": "SPV",
"Name": "Spar Van Miles",
"Reference": "e7c9f9ee-a1db-40ee-bfa8-a8ffead4ac1d",
"Description": "SPV brings you inventive twists and inspirational detailing for a day to night collection that is whatever you want it to be."
},
{
"Code": "STOR",
"Name": "Stor*All",
"Reference": "c4a97eae-00ee-4873-93c5-ee73209e3ec8",
"Description": "Stor*All is the largest manufacturer of storage containers in the empire, making everything from general purpose boxes to highly specialized containers for hazardous materials."
},
{
"Code": "SYFB",
"Name": "Syang Fabrication",
"Reference": "0d3e6377-4be0-4c38-8269-3ac5dbd132d8",
"Description": "PH Syang Fabrication Description"
},
{
"Code": "TALN",
"Name": "Talon Weapons Systems",
"Reference": "587fcede-4fe9-4fb4-85ec-124abc8542b0",
"Description": "A Croshaw-based munitions manufacturer that focuses on missiles, bombs, and torpedoes. \n\nIf it exists, there’s a Talon that can blow it up."
},
{
"Code": "TARS",
"Name": "Tarsus",
"Reference": "2b25a2dd-47ea-4ab0-8711-03d34be214b1",
"Description": "Founded by a pair of mechanics who were attempting to make an affordable module that would convert a quantum drive into a jump drive, Tarsus has evolved to become one of the main producers of dependable jump drives in the UEE. Over time they also expanded to build their own quantum drives as well."
},
{
"Code": "TDD",
"Name": "Trade & Development Division",
"Reference": "4f6dafd0-0b7e-4aac-844a-356896049adc"
},
{
"Code": "THCN",
"Name": "Thermyte Concern",
"Reference": "d1a1bf03-89d9-4eae-ac34-11eeb8e45d8f",
"Description": "Thermyte Concern"
},
{
"Code": "THP",
"Name": "Tehachapi",
"Reference": "c0bc7b2d-9c1a-4029-9715-f28b81122863",
"Description": "PH - Tehachapi Description"
},
{
"Code": "TMBL",
"Name": "Tumbril Land Systems",
"Reference": "abe53599-feb8-4502-9c62-14d51ca80c7c",
"Description": "Tumbril Land Systems got its start manufacturing ultra-rugged land vehicles built to explore the strange and dangerous landscapes of newly discovered planets. With the onset of the First Tevarin War, Tumbril expanded their operations when they won a government contract to build tanks and other military vehicles for the UPE military. Over the centuries the company eventually folded. Today, the Tumbril brand has been revived to build land vehicles from tanks to exploration vehicles and personal HOVs in both the civilian and military market."
},
{
"Code": "TRAS",
"Name": "Trident Attack Systems",
"Reference": "d70afa6f-c7aa-4a3f-b222-d2208e6c21a9",
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
},
{
"Code": "TRMI",
"Name": "Terra Mills",
"Reference": "a2d3a876-d263-443a-bc16-39444072d787"
},
{
"Code": "TYDT",
"Name": "Tyler Design & Tech",
"Reference": "a2a97679-2a6e-4e89-80d8-439c88a01427",
"Description": "Tyler Design & Tech began its life manufacturing generators, air purifiers, and other necessities for planetary settlers. Tyler focused on making products that, while not as powerful as some other brands, were durable and extremely efficient with little heat byproduct which led to the development of low-signature power plants.  It wasn’t long before even the UEE Navy began to outfit some of their own stealth crafts with Tyler parts, and with their new success the company was able to expand their ship component line into what it is today."
},
{
"Code": "UBA",
"Name": "Universal Body Armor",
"Reference": "04ded06f-7862-4771-b40f-aa310b74ee17",
"Description": "- Universal Body Armor Description"
},
{
"Code": "UEE",
"Name": "United Empire of Earth",
"Reference": "2e06e775-940b-4c24-8974-31fb5617ea56",
"Description": "The unified collection of planets and systems under Human control, the UEE strives to create a safe and just empire, championing equality and opportunity for all its inhabitants."
},
{
"Code": "UIHI_A",
"Name": "<= PLACEHOLDER =>",
"Reference": "d55cfa9f-5dab-4b8d-a105-cf2d16976003"
},
{
"Code": "UILO_A",
"Name": "<= PLACEHOLDER =>",
"Reference": "fa121997-f0c5-43f7-a8e0-d5afff541c9e"
},
{
"Code": "UILO_B",
"Name": "<= PLACEHOLDER =>",
"Reference": "e6ba6e48-e801-45a9-b07f-6a6c206bb4ba"
},
{
"Code": "UILO_C",
"Name": "<= PLACEHOLDER =>",
"Reference": "23788dd9-6222-4a4a-b233-0f421afc8eb1"
},
{
"Code": "UILO_C_2",
"Name": "<= PLACEHOLDER =>",
"Reference": "74da93a1-462b-4ca0-8374-6b482a617b32"
},
{
"Code": "UIMI_A",
"Name": "<= PLACEHOLDER =>",
"Reference": "e289a3ba-8d54-4887-90d6-f03aa3c7961f"
},
{
"Code": "UNKN",
"Name": "Unknown",
"Reference": "587a3cae-5e66-4f22-b0fc-1a47d41cafef",
"Description": "PH Unknown Manufacturer Description"
},
{
"Code": "UPS",
"Name": "Upsiders",
"Reference": "f444bb54-a422-44a0-abdb-a0384f0f5593",
"Description": "Upsiders is a lifestyle footwear brand with excellent options for every member of your family."
},
{
"Code": "UTFL",
"Name": "UltiFlex",
"Reference": "da264d7b-755b-48a2-9580-4adc17ff7ad5",
"Description": "Been around for a couple hundred years. Survival company that makes knives. Got their start by making combat knives for the UEE Army. Expanded their operations to the public after decades of proud service."
},
{
"Code": "VFGI",
"Name": "VFG Industrial",
"Reference": "3ce33ad8-82e7-4d8d-9ad1-bc95b976c17c"
},
{
"Code": "VGL",
"Name": "Virgil Ltd",
"Reference": "0e6d8f1b-ebb8-4773-8ca4-f25d737c8709",
"Description": "High-end Commercial and Personal armor manufacturer. Aside from combat uses, they build systems for heavy commercial and mining applications.\n\nModels: Achilles PDS (Heavy Combat Armor)\n\nZeus System (Titan Armor)"
},
{
"Code": "VNCL",
"Name": "Vanduul Clans",
"Reference": "4668760a-d8a1-48d0-be4d-42304b702de0",
"Description": "Military designations for the various Vanduul ship components."
},
{
"Code": "VOLT",
"Name": "Verified Offworld Laser Technologies",
"Reference": "e2787f3b-b892-4276-bb2e-eb2b4dc9666b"
},
{
"Code": "VPW",
"Name": "Vaporwear",
"Reference": "7dfff1b9-2bfa-4ace-86d9-30329a2a0f30",
"Description": "PH - Vaporwear Description"
},
{
"Code": "WCPR",
"Name": "Wen-Cassel Propulsion",
"Reference": "d8ef0f35-e196-4d18-affa-750f6cdf8cb4",
"Description": "Subsidiary of RSI, handles a small percentage of their thruster and ship options construction as well as cooling systems."
},
{
"Code": "WETK",
"Name": "Wei-Tek",
"Reference": "0f9ffe57-8542-4d83-a246-72568a5f2f91",
"Description": "Upstart company made by former employees of Ascension Astro."
},
{
"Code": "WLOP",
"Name": "WillsOp",
"Reference": "967cd454-20d9-4cb8-8465-990ff1a1f769",
"Description": "WillsOp have products to cover the whole spectrum of electronic analysis from tracking and targeting to scanners and deep-space radar."
},
{
"Code": "XNAA",
"Name": "Aopoa",
"Reference": "70ac7035-10a0-4bbb-90b0-9bddf047b504",
"Description": "Though many attributed their elevation to the sole provider of light craft to audacity and daring, Aopoa cemented their position thanks to the innovative dual-vector thruster technology that has revolutionized their ship design."
},
{
"Code": "YORM",
"Name": "Yorm",
"Reference": "435366d8-8b47-4cbc-b0cf-688a95a0847d",
"Description": "Gotlieb Yorm was a top racing pilot on the professional circuit, who was known for his willingness to do anything to shave a few more seconds off his lap times. For a short while, before the Safety Commission stepped in, he would race naked, claiming clothes were an unnecessary weight. Another such innovation that was considerably more successful (and modest) was his insistence that the shields being used on his racer were too powerful. He really only needed to be able to fend off a few shots, because after that he would either have maneuvered his ship out of targeting range, or he would have lost the race anyway. Gutting apart an existing shield by hand, Yorm hacked together a lighter shield that would draw less power and only protect him just enough to escape. Soon, many other racers were trying to mimic his efforts, and when Yorm retired, he went on to successfully sell his modified shield design."
}
]

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
"""Uvicorn entrypoint: `uvicorn main:app --host 0.0.0.0 --port 8000`."""
from app.api import app # noqa: F401

View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8
httpx>=0.27

View File

@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.30
SQLAlchemy>=2.0
psycopg[binary]>=3.2
argon2-cffi>=23.1

View File

@@ -0,0 +1,81 @@
"""Download the full scunpacked dataset into a target directory.
Usage:
python scripts/fetch_data.py [--output-dir DIR]
Default output: backend/data/full (used by the Docker image build).
"""
import argparse
import json
import os
import sys
import urllib.request
BASE_URL = "https://raw.githubusercontent.com/StarCitizenWiki/scunpacked-data/master"
FILES = [
"ships.json",
"ship-items.json",
"fps-items.json",
"manufacturers.json",
"items.json",
]
COMMODITY_KEYS = ("className", "name", "type", "subType", "reference")
PLACEHOLDER = "<= PLACEHOLDER =>"
def _commodity_rank(entry):
class_name = entry.get("className") or ""
return (0 if "_1SCU_" in class_name else 1, entry.get("size") or 0, class_name)
def build_commodities(items_path, out_path):
with open(items_path, encoding="utf-8") as f:
items = json.load(f)
grouped = {}
for entry in items:
class_name = (entry.get("className") or "").lower()
name = (entry.get("name") or "").strip()
if entry.get("type") != "Cargo" or "commodity" not in class_name:
continue
if not name or name == PLACEHOLDER:
continue
grouped.setdefault(name, []).append(entry)
commodities = [
{key: best.get(key) for key in COMMODITY_KEYS}
for best in (min(grouped[name], key=_commodity_rank) for name in sorted(grouped))
]
with open(out_path, "w", encoding="utf-8") as f:
json.dump(commodities, f, ensure_ascii=False, separators=(",", ":"))
return len(commodities)
def main():
default_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "data", "full")
)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", default=default_dir)
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
for name in FILES:
url = f"{BASE_URL}/{name}"
dest = os.path.join(args.output_dir, name)
print(f"Downloading {url} -> {dest}", flush=True)
urllib.request.urlretrieve(url, dest)
size = os.path.getsize(dest)
print(f" done ({size / 1_000_000:.1f} MB)", flush=True)
items_path = os.path.join(args.output_dir, "items.json")
count = build_commodities(
items_path, os.path.join(args.output_dir, "commodities.json")
)
os.remove(items_path)
print(f"Wrote {count} commodities (items.json discarded)", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,31 @@
"""Test bootstrap.
Runs the suite against a throwaway in-memory SQLite database and the tiny
fixture catalog, so no PostgreSQL server is needed. Environment variables must
be set before any app module is imported, because config reads them at import
time.
"""
import os
os.environ["DATA_DIR"] = os.path.join(os.path.dirname(__file__), "fixtures")
os.environ["DATABASE_URL"] = "sqlite://"
os.environ["AUTO_INIT_DB"] = "false"
os.environ["COOKIE_SECURE"] = "false"
import pytest # noqa: E402
from app import db # noqa: E402
from app.models import Base # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db():
"""Give every test an empty schema and a clean login throttle."""
engine = db.get_engine()
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
from app.auth import throttle
throttle.clear()
yield

View File

@@ -0,0 +1,30 @@
[
{
"className": "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials",
"name": "Construction Materials",
"type": "Cargo",
"subType": "Cargo",
"reference": "5ee15a9b-4ce5-445b-a02d-415c63f43764"
},
{
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron",
"name": "Iron",
"type": "Cargo",
"subType": "Cargo",
"reference": "332096b9-1cbf-436d-b8b0-5ed422c23da9"
},
{
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron",
"name": "Iron (Ore)",
"type": "Cargo",
"subType": "Cargo",
"reference": "271effd5-3aa8-4538-b9a0-b0f33dc86d68"
},
{
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_RMC",
"name": "Recycled Material Composite",
"type": "Cargo",
"subType": "Cargo",
"reference": "2e50dde5-30d4-4faa-af8f-8e5aa8004b96"
}
]

View File

@@ -0,0 +1,3 @@
[
{"className": "BEHR_Rifle_Ballistic_01", "reference": "ref-3", "itemName": "behr_rifle_ballistic_01", "type": "WeaponPersonal", "subType": "Medium", "size": 2, "grade": 1, "name": "<= PLACEHOLDER =>", "tags": "weapon", "classification": "FPS.Weapon.Medium", "stdItem": {"UUID": "fps-uuid-1", "ClassName": "BEHR_Rifle_Ballistic_01", "Size": 2, "Grade": 1, "Width": 0.15, "Height": 0.15, "Length": 0.9, "Mass": 4, "Type": "WeaponPersonal.Medium", "Name": "Behring Rifle", "Description": "A ballistic rifle.", "DescriptionText": "", "Manufacturer": {"Name": "Behring", "Code": "BEHR", "UUID": "m-behr"}, "Tags": ["weapon"], "Ports": [{"PortName": "magazine_attach", "Size": 1}]}}
]

View File

@@ -0,0 +1,6 @@
[
{"Code": "ANVL", "Name": "Anvil Aerospace", "Reference": "m1", "Description": "Anvil makes ships."},
{"Code": "BEHR", "Name": "Behring", "Reference": "m-behr", "Description": "Behring makes weapons."},
{"Code": "UNKN", "Name": "Unknown Manufacturer", "Reference": "m-unkn", "Description": ""},
{"Code": "PHLD", "Name": "<= PLACEHOLDER =>", "Reference": "m-phld", "Description": ""}
]

View File

@@ -0,0 +1,4 @@
[
{"className": "BEHR_Shield_S1", "reference": "ref-1", "itemName": "behr_shield_s1", "type": "Shield", "subType": "UNDEFINED", "size": 1, "grade": 2, "name": "<= PLACEHOLDER =>", "tags": "shield", "classification": "Ship.Shield", "stdItem": {"UUID": "si-uuid-1", "ClassName": "BEHR_Shield_S1", "Size": 1, "Grade": 2, "Width": 0.5, "Height": 0.5, "Length": 1.0, "Mass": 20, "Type": "Shield.UNDEFINED", "Name": "Behring Shield S1", "Description": "A size 1 shield.", "DescriptionText": "", "Manufacturer": {"Name": "Behring", "Code": "BEHR", "UUID": "m-behr"}, "Tags": ["shield"], "Ports": []}},
{"className": "TEST_Ph_Item", "reference": "ref-2", "itemName": "test_ph_item", "type": "PowerPlant", "subType": "UNDEFINED", "size": 2, "grade": 1, "name": "<= PLACEHOLDER =>", "tags": "", "classification": "Ship.PowerPlant", "stdItem": {"UUID": "si-uuid-2", "ClassName": "TEST_Ph_Item", "Size": 2, "Grade": 1, "Width": 0.5, "Height": 0.5, "Length": 0.5, "Mass": 10, "Type": "PowerPlant.UNDEFINED", "Name": "<= PLACEHOLDER =>", "Description": "", "DescriptionText": "", "Manufacturer": {"Name": "<= PLACEHOLDER =>", "Code": "UNKN", "UUID": "m-unkn"}, "Tags": [], "Ports": [{"PortName": "connect", "Size": 1}]}}
]

View File

@@ -0,0 +1,4 @@
[
{"UUID": "ship-uuid-1", "ClassName": "ANVL_Carrack", "Name": "Anvil Carrack", "Description": "Long-range explorer.", "DescriptionText": "Long-range explorer.", "Career": "Exploration", "Role": "Expedition", "Manufacturer": {"UUID": "m1", "Code": "ANVL", "Name": "Anvil Aerospace"}, "Size": 4, "Length": 35.0, "Width": 23.0, "Height": 8.5, "Crew": 4, "Mass": 2000000, "IsSpaceship": true, "IsVehicle": false, "IsGravlev": false},
{"UUID": "ship-uuid-2", "ClassName": "TEST_Placeholder_Ship", "Name": "<= PLACEHOLDER =>", "Description": "", "DescriptionText": "", "Career": "", "Role": "", "Manufacturer": {"UUID": "m2", "Code": "UNKN", "Name": "Unknown Manufacturer"}, "Size": 1, "Length": 10.0, "Width": 5.0, "Height": 3.0, "Crew": 1, "Mass": 50000, "IsSpaceship": true, "IsVehicle": false, "IsGravlev": false}
]

View File

@@ -0,0 +1,193 @@
"""API tests for the Star Citizen inventory backend (fixture dataset)."""
from fastapi.testclient import TestClient
from app.api import app
client = TestClient(app)
def test_healthz():
resp = client.get("/healthz")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["counts"] == {
"ships": 2,
"ship-items": 2,
"fps-items": 1,
"commodities": 4,
}
def test_stats():
resp = client.get("/api/v1/stats")
assert resp.status_code == 200
body = resp.json()
assert body["categories"] == {
"ships": 2,
"ship-items": 2,
"fps-items": 1,
"commodities": 4,
}
assert body["manufacturers"] == 4
def test_manufacturers_sorted_by_name():
resp = client.get("/api/v1/manufacturers")
assert resp.status_code == 200
names = [m["name"] for m in resp.json()]
assert names == ["Anvil Aerospace", "Behring", "PHLD", "Unknown Manufacturer"]
def test_items_default_pagination():
resp = client.get("/api/v1/items")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 9
assert body["page"] == 1
assert body["page_size"] == 25
assert body["total_pages"] == 1
assert len(body["items"]) == 9
def test_items_page_size_capped():
resp = client.get("/api/v1/items", params={"page_size": 500})
assert resp.status_code == 200
assert resp.json()["page_size"] == 100
def test_items_page_size_minimum_enforced():
resp = client.get("/api/v1/items", params={"page_size": 0})
assert resp.status_code == 422 # pydantic ge=1 validation
def test_items_category_filter():
resp = client.get("/api/v1/items", params={"category": "ships"})
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert all(i["category"] == "ships" for i in body["items"])
def test_items_invalid_category_rejected():
resp = client.get("/api/v1/items", params={"category": "bogus"})
assert resp.status_code == 422
def test_items_search_matches_name_case_insensitive():
resp = client.get("/api/v1/items", params={"search": "carrack"})
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["name"] == "Anvil Carrack"
def test_items_search_matches_class_name():
resp = client.get("/api/v1/items", params={"search": "test_ph"})
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["class_name"] == "TEST_Ph_Item"
def test_items_manufacturer_filter_case_insensitive():
resp = client.get("/api/v1/items", params={"manufacturer": "behr"})
body = resp.json()
assert body["total"] == 2 # Behring shield + Behring rifle
assert all(i["manufacturer"]["code"] == "BEHR" for i in body["items"])
def test_items_type_filter_case_insensitive():
resp = client.get("/api/v1/items", params={"type": "weaponpersonal"})
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["class_name"] == "BEHR_Rifle_Ballistic_01"
def test_items_size_filter():
resp = client.get("/api/v1/items", params={"size": 2})
body = resp.json()
assert body["total"] == 2 # TEST_Ph_Item (size 2) + rifle (size 2)
assert {i["class_name"] for i in body["items"]} == {
"TEST_Ph_Item",
"BEHR_Rifle_Ballistic_01",
}
def test_item_detail_ship():
resp = client.get("/api/v1/items/ANVL_Carrack")
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "Anvil Carrack"
assert body["category"] == "ships"
assert body["details"]["crew"] == 4
assert body["details"]["is_spaceship"] is True
assert body["details"]["length"] == 35.0
def test_item_detail_item_ports():
resp = client.get("/api/v1/items/BEHR_Rifle_Ballistic_01")
assert resp.status_code == 200
body = resp.json()
assert body["details"]["ports"][0]["PortName"] == "magazine_attach"
assert body["details"]["mass"] == 4
def test_item_detail_404():
resp = client.get("/api/v1/items/NOPE_Does_Not_Exist")
assert resp.status_code == 404
assert resp.json()["detail"] == "Item 'NOPE_Does_Not_Exist' not found"
def test_placeholder_name_falls_back_to_class_name():
# Ship with Name == "<= PLACEHOLDER =>"
resp = client.get("/api/v1/items/TEST_Placeholder_Ship")
assert resp.status_code == 200
assert resp.json()["name"] == "TEST Placeholder Ship"
# Item with stdItem.Name == "<= PLACEHOLDER =>"
resp = client.get("/api/v1/items/TEST_Ph_Item")
assert resp.status_code == 200
assert resp.json()["name"] == "TEST Ph Item"
def test_manufacturer_placeholder_name_falls_back_to_code():
# manufacturers.json entry with Name == "<= PLACEHOLDER =>"
resp = client.get("/api/v1/manufacturers")
phld = [m for m in resp.json() if m["code"] == "PHLD"]
assert len(phld) == 1
assert phld[0]["name"] == "PHLD"
# Item whose embedded manufacturer Name is a placeholder
resp = client.get("/api/v1/items/TEST_Ph_Item")
assert resp.json()["manufacturer"]["name"] == "UNKN"
def test_deterministic_sort_order():
body = client.get("/api/v1/items", params={"page_size": 100}).json()
keys = [(i["category"], i["name"].lower()) for i in body["items"]]
assert keys == sorted(keys)
def test_items_category_filter_commodities():
resp = client.get("/api/v1/items", params={"category": "commodities"})
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 4
assert all(i["category"] == "commodities" for i in body["items"])
def test_items_search_matches_refined_and_ore_iron():
resp = client.get("/api/v1/items", params={"search": "iron"})
body = resp.json()
assert body["total"] == 2
assert {i["name"] for i in body["items"]} == {"Iron", "Iron (Ore)"}
def test_item_detail_commodity():
resp = client.get("/api/v1/items/Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron")
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "Iron (Ore)"
assert body["category"] == "commodities"
assert body["type"] == "Cargo"
assert body["size"] is None
assert isinstance(body["details"], dict)

View File

@@ -0,0 +1,151 @@
"""Authentication tests: registration, login, sessions, throttling, CSRF."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
def new_client() -> TestClient:
return TestClient(app)
def register(client: TestClient, username: str = "alice", password: str = PASSWORD):
return client.post(
"/api/v1/auth/register", json={"username": username, "password": password}
)
def login(client: TestClient, username: str = "alice", password: str = PASSWORD):
return client.post(
"/api/v1/auth/login", json={"username": username, "password": password}
)
def test_register_returns_user_and_authenticates():
client = new_client()
resp = register(client)
assert resp.status_code == 201
body = resp.json()
assert body["user"]["username"] == "alice"
assert "password" not in body["user"]
assert client.cookies.get("sc_session")
me = client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["user"]["username"] == "alice"
def test_register_duplicate_username_is_conflict_case_insensitive():
client = new_client()
assert register(client, "Alice").status_code == 201
assert register(new_client(), "alice").status_code == 409
def test_register_rejects_weak_password():
resp = register(new_client(), password="short")
assert resp.status_code == 422
def test_register_rejects_invalid_username():
for username in ["ab", "has space", "bad!", "x" * 33]:
assert register(new_client(), username).status_code == 422
def test_me_requires_authentication():
resp = new_client().get("/api/v1/auth/me")
assert resp.status_code == 401
def test_login_is_case_insensitive_and_works_after_logout():
client = new_client()
assert register(client, "alice").status_code == 201
assert client.post("/api/v1/auth/logout").status_code == 204
assert client.get("/api/v1/auth/me").status_code == 401
resp = login(client, "ALICE")
assert resp.status_code == 200
assert resp.json()["user"]["username"] == "alice"
assert client.get("/api/v1/auth/me").status_code == 200
def test_wrong_password_and_unknown_user_are_indistinguishable():
client = new_client()
assert register(client, "alice").status_code == 201
wrong = login(new_client(), "alice", "definitely wrong")
unknown = login(new_client(), "nobody", "definitely wrong")
assert wrong.status_code == unknown.status_code == 401
assert wrong.json()["detail"] == unknown.json()["detail"]
def test_logout_invalidates_session_token():
client = new_client()
assert register(client).status_code == 201
token = client.cookies.get("sc_session")
assert client.post("/api/v1/auth/logout").status_code == 204
# Replay the old cookie on a fresh client: it must be rejected.
replay = new_client()
replay.cookies.set("sc_session", token)
assert replay.get("/api/v1/auth/me").status_code == 401
def test_login_is_rate_limited():
client = new_client()
assert register(client, "alice").status_code == 201
for _ in range(5):
assert login(new_client(), "alice", "wrong password").status_code == 401
blocked = login(new_client(), "alice", PASSWORD)
assert blocked.status_code == 429
assert int(blocked.headers["Retry-After"]) > 0
def test_cross_origin_mutation_is_rejected():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={"Origin": "http://evil.example"},
)
assert resp.status_code == 403
def test_same_origin_mutation_is_allowed():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={"Origin": "http://testserver"},
)
assert resp.status_code == 201
def test_same_origin_with_forwarded_port_is_allowed():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={
"Origin": "http://testserver:8080",
"X-Forwarded-Host": "testserver:8080",
},
)
assert resp.status_code == 201
def test_forwarded_host_without_port_is_rejected():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={
"Origin": "http://testserver:8080",
"X-Forwarded-Host": "testserver",
},
)
assert resp.status_code == 403

View File

@@ -0,0 +1,156 @@
"""Public available-items listing: open access, owner attribution, no notes."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str) -> TestClient:
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
)
assert resp.status_code == 201, resp.text
return client
def add(
client: TestClient,
class_name: str,
quantity: int = 1,
notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
resp = client.post("/api/v1/me/inventory", json=payload)
assert resp.status_code in (200, 201), resp.text
return resp
def offer(client: TestClient, class_name: str, quantity: int = 1):
add(client, class_name, quantity)
resp = client.patch(
f"/api/v1/me/inventory/{class_name}", json={"visibility": "available"}
)
assert resp.status_code == 200, resp.text
def public_items(**params):
return TestClient(app).get("/api/v1/available", params=params).json()
def test_listing_is_public_and_empty_by_default():
body = public_items()
assert body["total"] == 0
assert body["items"] == []
assert body["page"] == 1
def test_private_items_are_not_listed_until_marked_available():
alice = authed_client("alice")
add(alice, CARROCK)
assert public_items()["total"] == 0
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
body = public_items()
assert body["total"] == 1
assert body["items"][0]["class_name"] == CARROCK
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"})
assert public_items()["total"] == 0
def test_listing_shows_owner_and_catalog_details_but_not_notes():
alice = authed_client("alice")
offer(alice, CARROCK, quantity=3)
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"notes": "hidden stash"})
item = public_items()["items"][0]
assert item["username"] == "alice"
assert item["name"] == "Anvil Carrack"
assert item["category"] == "ships"
assert item["manufacturer"]["code"] == "ANVL"
assert item["quantity"] == 3
assert "notes" not in item
assert "hidden stash" not in str(public_items())
def test_each_owner_appears_as_its_own_offer():
offer(authed_client("alice"), CARROCK, quantity=1)
offer(authed_client("bob"), CARROCK, quantity=2)
items = public_items(sort="quantity")["items"]
assert [(i["username"], i["quantity"], i["class_name"]) for i in items] == [
("bob", 2, CARROCK),
("alice", 1, CARROCK),
]
def test_removing_an_offer_removes_it_from_the_listing():
alice = authed_client("alice")
offer(alice, CARROCK)
assert public_items()["total"] == 1
assert alice.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
assert public_items()["total"] == 0
assert public_items()["items"] == []
def test_listing_filters_by_category_and_searches_owner_or_item():
offer(authed_client("alice"), CARROCK)
offer(authed_client("bob"), RIFLE)
assert [i["class_name"] for i in public_items(category="ships")["items"]] == [CARROCK]
assert [i["username"] for i in public_items(search="bob")["items"]] == ["bob"]
assert [i["class_name"] for i in public_items(search="carrack")["items"]] == [CARROCK]
def test_listing_paginates_and_rejects_unknown_sort():
for quantity, class_name in ((1, CARROCK), (2, RIFLE)):
offer(authed_client(f"pilot{quantity}"), class_name, quantity=quantity)
body = public_items(page=2, page_size=1)
assert body["total"] == 2
assert body["total_pages"] == 2
assert len(body["items"]) == 1
assert public_items(page_size=500)["page_size"] == 100
assert TestClient(app).get("/api/v1/available?sort=bogus").status_code == 422
def test_commodity_offers_are_listed_with_owner():
alice = authed_client("alice")
offer(alice, IRON_ORE, quantity=12)
body = public_items()
assert body["total"] == 1
item = body["items"][0]
assert item["name"] == "Iron (Ore)"
assert item["category"] == "commodities"
assert item["quantity"] == 12
assert item["username"] == "alice"
def test_commodity_quality_is_listed_publicly():
alice = authed_client("alice")
offer(alice, IRON_ORE, quantity=12)
add(alice, IRON_ORE, quality=875)
item = public_items()["items"][0]
assert item["quality"] == 875
assert item["quality_applicable"] is True
def test_non_quality_commodity_is_listed_without_quality():
alice = authed_client("alice")
offer(alice, CONSTRUCTION)
item = public_items()["items"][0]
assert item["quality"] is None
assert item["quality_applicable"] is False

View File

@@ -0,0 +1,356 @@
"""Personal inventory tests: auth gating, CRUD, isolation, catalog immutability."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
IRON = "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str = "alice") -> TestClient:
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
)
assert resp.status_code == 201, resp.text
return client
def add(
client: TestClient,
class_name: str,
quantity: int = 1,
notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
return client.post("/api/v1/me/inventory", json=payload)
def test_new_account_starts_with_empty_inventory():
client = authed_client("freshpilot")
listing = client.get("/api/v1/me/inventory").json()
assert listing["total"] == 0
assert listing["items"] == []
assert client.get("/api/v1/me/inventory/summary").json() == {
"items": 0,
"quantity": 0,
"categories": {},
"unavailable": 0,
}
def test_inventory_requires_authentication():
client = TestClient(app)
assert client.get("/api/v1/me/inventory").status_code == 401
assert add(client, CARROCK).status_code == 401
assert client.get("/api/v1/me/inventory/summary").status_code == 401
def test_add_unknown_catalog_item_is_not_found():
client = authed_client()
assert add(client, "NOPE_Does_Not_Exist").status_code == 404
def test_add_creates_enriched_entry():
client = authed_client()
resp = add(client, CARROCK)
assert resp.status_code == 201
body = resp.json()
assert body["created"] is True
entry = body["entry"]
assert entry["class_name"] == CARROCK
assert entry["name"] == "Anvil Carrack"
assert entry["category"] == "ships"
assert entry["quantity"] == 1
assert entry["visibility"] == "private"
assert entry["available"] is True
assert entry["manufacturer"]["code"] == "ANVL"
def test_add_same_item_increments_quantity():
client = authed_client()
assert add(client, CARROCK, quantity=1).status_code == 201
resp = add(client, CARROCK, quantity=2, notes="pledged")
assert resp.status_code == 200
body = resp.json()
assert body["created"] is False
assert body["entry"]["quantity"] == 3
assert body["entry"]["notes"] == "pledged"
listing = client.get("/api/v1/me/inventory").json()
assert listing["total"] == 1
def test_inventories_are_isolated_between_users():
alice = authed_client("alice")
bob = authed_client("bob")
assert add(alice, CARROCK).status_code == 201
assert add(bob, RIFLE).status_code == 201
alice_items = alice.get("/api/v1/me/inventory").json()["items"]
bob_items = bob.get("/api/v1/me/inventory").json()["items"]
assert [i["class_name"] for i in alice_items] == [CARROCK]
assert [i["class_name"] for i in bob_items] == [RIFLE]
def test_patch_updates_quantity_and_notes():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 4, "notes": "hangar 3"}
)
assert resp.status_code == 200
body = resp.json()
assert body["quantity"] == 4
assert body["notes"] == "hangar 3"
def test_visibility_toggles_between_private_and_available():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"}
)
assert resp.status_code == 200
assert resp.json()["visibility"] == "available"
listed = client.get("/api/v1/me/inventory").json()["items"]
assert listed[0]["visibility"] == "available"
back = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"}
)
assert back.json()["visibility"] == "private"
def test_patch_rejects_unknown_visibility():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "public"})
assert resp.status_code == 422
def test_visibility_is_kept_when_quantity_changes_and_item_is_readded():
client = authed_client()
add(client, CARROCK)
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 3})
assert add(client, CARROCK, quantity=1).status_code == 200
entry = client.get("/api/v1/me/inventory").json()["items"][0]
assert entry["quantity"] == 4
assert entry["visibility"] == "available"
def test_patch_unknown_entry_is_not_found():
client = authed_client()
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404
def test_patch_rejects_zero_quantity():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 0})
assert resp.status_code == 422
def test_delete_removes_entry():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
assert client.get("/api/v1/me/inventory").json()["total"] == 0
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 404
def test_list_filters_by_category_and_search():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert add(client, RIFLE).status_code == 201
ships = client.get("/api/v1/me/inventory", params={"category": "ships"}).json()
assert ships["total"] == 1
assert ships["items"][0]["class_name"] == CARROCK
found = client.get("/api/v1/me/inventory", params={"search": "carrack"}).json()
assert found["total"] == 1
assert found["items"][0]["class_name"] == CARROCK
def test_list_pagination_envelope_and_cap():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert add(client, RIFLE).status_code == 201
body = client.get(
"/api/v1/me/inventory", params={"page_size": 1, "page": 2}
).json()
assert body["total"] == 2
assert body["page"] == 2
assert body["page_size"] == 1
assert body["total_pages"] == 2
assert len(body["items"]) == 1
capped = client.get("/api/v1/me/inventory", params={"page_size": 500}).json()
assert capped["page_size"] == 100
def test_summary_aggregates_quantity_and_categories():
client = authed_client()
assert add(client, CARROCK, quantity=2).status_code == 201
assert add(client, RIFLE, quantity=3).status_code == 201
summary = client.get("/api/v1/me/inventory/summary").json()
assert summary["items"] == 2
assert summary["quantity"] == 5
assert summary["unavailable"] == 0
assert summary["categories"]["ships"] == 2
def test_notes_length_is_bounded():
client = authed_client()
resp = add(client, CARROCK, notes="x" * 1001)
assert resp.status_code == 422
def test_catalog_is_not_mutated_by_inventory_operations():
client = authed_client()
before = client.get("/api/v1/items", params={"page_size": 100}).json()
assert add(client, CARROCK).status_code == 201
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 9})
client.delete(f"/api/v1/me/inventory/{CARROCK}")
after = client.get("/api/v1/items", params={"page_size": 100}).json()
assert after == before
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
assert "quantity" not in carrack
assert "notes" not in carrack
def test_sort_by_category_groups_categories():
client = authed_client()
add(client, CARROCK)
add(client, RIFLE)
items = client.get("/api/v1/me/inventory?sort=category").json()["items"]
assert [i["class_name"] for i in items] == [RIFLE, CARROCK]
def test_sort_by_quantity_is_descending():
client = authed_client()
add(client, CARROCK, quantity=5)
add(client, RIFLE, quantity=1)
items = client.get("/api/v1/me/inventory?sort=quantity").json()["items"]
assert [i["class_name"] for i in items] == [CARROCK, RIFLE]
def test_sort_defaults_to_name_and_rejects_unknown_values():
client = authed_client()
add(client, CARROCK)
listing = client.get("/api/v1/me/inventory").json()
assert [i["class_name"] for i in listing["items"]] == [CARROCK]
assert client.get("/api/v1/me/inventory?sort=bogus").status_code == 422
def test_add_commodity_uses_catalog_name_and_quantity():
client = authed_client("refiner")
resp = add(client, IRON_ORE, quantity=12, notes="from Aaron Halo")
assert resp.status_code == 201
entry = client.get("/api/v1/me/inventory").json()["items"][0]
assert entry["name"] == "Iron (Ore)"
assert entry["category"] == "commodities"
assert entry["quantity"] == 12
assert entry["available"] is True
assert entry["visibility"] == "private"
def test_catalog_marks_only_mineable_commodities_as_quality_applicable():
client = TestClient(app)
items = client.get(
"/api/v1/items", params={"category": "commodities", "page_size": 100}
).json()["items"]
flags = {item["class_name"]: item["quality_applicable"] for item in items}
assert flags[IRON] is True
assert flags[IRON_ORE] is True
assert flags[CONSTRUCTION] is False
assert flags["Carryable_TBO_FL_1SCU_Commodity_Metal_RMC"] is False
ships = client.get("/api/v1/items", params={"category": "ships"}).json()["items"]
assert all(item["quality_applicable"] is False for item in ships)
def test_quality_can_be_set_and_cleared_on_a_quality_commodity():
client = authed_client()
assert add(client, IRON_ORE).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": 750})
assert resp.status_code == 200
body = resp.json()
assert body["quality"] == 750
assert body["quality_applicable"] is True
cleared = client.patch(
f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": None}
)
assert cleared.status_code == 200
assert cleared.json()["quality"] is None
def test_add_persists_quality_and_keeps_it_when_quantity_is_incremented():
client = authed_client()
first = add(client, IRON, quality=900)
assert first.status_code == 201
assert first.json()["entry"]["quality"] == 900
second = add(client, IRON, quantity=2)
assert second.status_code == 200
entry = second.json()["entry"]
assert entry["quality"] == 900
assert entry["quantity"] == 3
replaced = add(client, IRON, quality=400)
assert replaced.json()["entry"]["quality"] == 400
def test_quality_is_rejected_for_non_mineable_commodities():
client = authed_client()
assert add(client, CONSTRUCTION).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CONSTRUCTION}", json={"quality": 500}
)
assert resp.status_code == 422
assert "does not apply" in resp.json()["detail"]
add_resp = add(client, CONSTRUCTION, quality=500)
assert add_resp.status_code == 422
def test_quality_is_rejected_for_ships():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quality": 100})
assert resp.status_code == 422
def test_quality_range_is_enforced():
client = authed_client()
assert add(client, IRON_ORE, quality=1).status_code == 201
assert add(client, IRON, quality=1000).status_code == 201
assert add(client, IRON_ORE, quality=0).status_code == 422
assert add(client, IRON_ORE, quality=1001).status_code == 422
assert add(client, IRON, quantity=1).status_code == 200
assert client.patch(
f"/api/v1/me/inventory/{IRON}", json={"quality": 1001}
).status_code == 422

View File

@@ -0,0 +1,176 @@
"""Public order board: authenticated creation, public read, owner-only edits."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str) -> TestClient:
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
)
assert resp.status_code == 201, resp.text
return client
def place(
client: TestClient,
class_name: str,
quantity: int = 1,
notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
return client.post("/api/v1/orders", json=payload)
def public_orders(**params):
return TestClient(app).get("/api/v1/orders", params=params).json()
def test_order_board_is_public_and_empty_by_default():
body = public_orders()
assert body["total"] == 0
assert body["items"] == []
assert body["page"] == 1
def test_placing_an_order_requires_authentication():
assert place(TestClient(app), CARROCK).status_code == 401
def test_placing_an_order_returns_enriched_public_entry():
alice = authed_client("alice")
resp = place(alice, CARROCK, quantity=2, notes="Need a hauler")
assert resp.status_code == 201, resp.text
order = resp.json()
assert order["class_name"] == CARROCK
assert order["name"] == "Anvil Carrack"
assert order["category"] == "ships"
assert order["manufacturer"]["code"] == "ANVL"
assert order["quantity"] == 2
assert order["notes"] == "Need a hauler"
assert order["username"] == "alice"
assert order["status"] == "open"
assert order["available"] is True
public = public_orders()
assert public["total"] == 1
assert public["items"][0]["id"] == order["id"]
assert public["items"][0]["username"] == "alice"
def test_orders_from_every_user_are_visible():
place(authed_client("alice"), CARROCK)
place(authed_client("bob"), RIFLE)
items = public_orders(sort="name")["items"]
assert [(i["username"], i["class_name"]) for i in items] == [
("alice", CARROCK),
("bob", RIFLE),
]
def test_order_quality_is_stored_and_public_for_ore():
alice = authed_client("alice")
resp = place(alice, IRON_ORE, quantity=10, quality=875)
assert resp.status_code == 201, resp.text
assert resp.json()["quality"] == 875
assert resp.json()["quality_applicable"] is True
item = public_orders()["items"][0]
assert item["quality"] == 875
assert item["quality_applicable"] is True
def test_quality_is_rejected_for_non_mineable_items():
alice = authed_client("alice")
assert place(alice, CONSTRUCTION, quality=500).status_code == 422
assert place(alice, CARROCK, quality=100).status_code == 422
assert place(alice, IRON_ORE, quality=1001).status_code == 422
def test_unknown_catalog_item_is_rejected():
assert place(authed_client("alice"), "NOPE_Does_Not_Exist").status_code == 404
def test_listing_filters_by_category_search_and_status():
alice = authed_client("alice")
bob = authed_client("bob")
first = place(alice, CARROCK).json()
place(bob, RIFLE)
place(alice, IRON_ORE, quality=400)
assert [i["class_name"] for i in public_orders(category="ships")["items"]] == [
CARROCK
]
assert [i["username"] for i in public_orders(search="bob")["items"]] == ["bob"]
assert [
i["class_name"] for i in public_orders(search="iron")["items"]
] == [IRON_ORE]
alice.patch(f"/api/v1/orders/{first['id']}", json={"status": "fulfilled"})
assert [i["class_name"] for i in public_orders(status="open")["items"]] == [
IRON_ORE,
RIFLE,
]
assert [i["class_name"] for i in public_orders(status="fulfilled")["items"]] == [
CARROCK
]
def test_listing_paginates_and_rejects_unknown_sort():
alice = authed_client("alice")
place(alice, CARROCK)
place(alice, RIFLE)
body = public_orders(page=2, page_size=1)
assert body["total"] == 2
assert body["total_pages"] == 2
assert len(body["items"]) == 1
assert public_orders(page_size=500)["page_size"] == 100
assert TestClient(app).get("/api/v1/orders?sort=bogus").status_code == 422
assert TestClient(app).get("/api/v1/orders?status=bogus").status_code == 422
def test_only_the_owner_can_edit_or_remove_an_order():
alice = authed_client("alice")
bob = authed_client("bob")
order = place(alice, IRON_ORE, quality=500).json()
assert (
bob.patch(
f"/api/v1/orders/{order['id']}", json={"quantity": 99}
).status_code
== 404
)
assert bob.delete(f"/api/v1/orders/{order['id']}").status_code == 404
updated = alice.patch(
f"/api/v1/orders/{order['id']}",
json={"quantity": 3, "notes": "Updated", "status": "fulfilled"},
)
assert updated.status_code == 200, updated.text
body = updated.json()
assert body["quantity"] == 3
assert body["notes"] == "Updated"
assert body["status"] == "fulfilled"
assert body["quality"] == 500
cleared = alice.patch(
f"/api/v1/orders/{order['id']}", json={"quality": None}
)
assert cleared.status_code == 200, cleared.text
assert cleared.json()["quality"] is None
assert alice.delete(f"/api/v1/orders/{order['id']}").status_code == 204
assert public_orders()["total"] == 0

View File

@@ -0,0 +1,6 @@
FROM docker.io/library/nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY src/ /usr/share/nginx/html/
EXPOSE 8071

View File

@@ -0,0 +1,39 @@
server {
listen 8071;
server_name _;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# API and health checks are proxied to the backend container on the
# shared podman network (service DNS name: backend).
#
# X-Real-IP and X-Forwarded-Proto are set here (not passed through), so the
# backend can trust them for login throttling and redirect/scheme decisions.
location /api/ {
proxy_pass http://backend:8000;
# $http_host keeps the client's port, which the backend's Origin check
# compares against; $host would drop the port and reject real browsers.
proxy_set_header Host $http_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;
proxy_set_header X-Forwarded-Host $http_host;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
location = /healthz {
proxy_pass http://backend:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
}
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FPOV Logistics Readiness</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="site-header">
<div class="header-inner">
<div class="brand">
<h1>Free People of the Verse <span>NEXUS</span></h1>
<p class="subtitle">Network for Exchange of Xeno and Utility Supplies</p>
</div>
<div class="header-right">
<div class="header-status">
<div id="api-status" class="status-pill checking">CHECKING API…</div>
<div id="stats-line" class="stats-line">Loading counts…</div>
</div>
<div class="account-bar" id="account-bar"></div>
</div>
</div>
</header>
<nav class="tabs" id="category-tabs">
<button class="tab active" data-view="catalog" data-category="">All Items</button>
<button class="tab" data-view="catalog" data-category="ships">Ships</button>
<button class="tab" data-view="catalog" data-category="ship-items">Ship Equipment</button>
<button class="tab" data-view="catalog" data-category="fps-items">FPS Items</button>
<button class="tab" data-view="catalog" data-category="commodities">Commodities</button>
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
<button class="tab" id="tab-available" data-view="available">Available</button>
<button class="tab" id="tab-orders" data-view="orders">Orders</button>
</nav>
<div id="catalog-view">
<section class="controls">
<input type="search" id="search-input" placeholder="Search name or class name…" autocomplete="off">
<select id="manufacturer-select">
<option value="">All manufacturers</option>
</select>
<input type="text" id="type-input" placeholder="Type (e.g. WeaponPersonal)">
<input type="number" id="size-input" placeholder="Size" min="0" max="12">
<button id="reset-filters" class="btn-secondary">Reset</button>
</section>
<main>
<div id="error-banner" class="error-banner hidden"></div>
<div id="loading" class="loading">Loading inventory…</div>
<div id="empty-state" class="empty-state hidden">
<p>No items match your filters.</p>
<p class="hint">Try clearing the search or resetting the filters.</p>
</div>
<div id="results-grid" class="results-grid"></div>
<div class="pagination" id="pagination">
<button id="prev-page" class="btn-secondary">&larr; Prev</button>
<span id="page-info">Page 1 of 1</span>
<button id="next-page" class="btn-secondary">Next &rarr;</button>
</div>
</main>
</div>
<section id="inventory-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="inv-search" placeholder="Search my inventory…" autocomplete="off">
<select id="inv-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="inv-sort" aria-label="Sort inventory">
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
<option value="updated">Sort: Recently updated</option>
</select>
</div>
<div id="inv-summary" class="inv-summary"></div>
<div id="inv-empty" class="empty-state hidden">
<p>Your inventory is empty.</p>
<p class="hint">Browse the catalog and use “Add to my inventory” on any item.</p>
</div>
<div id="inv-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Notes</th>
<th>Visibility</th>
<th></th>
</tr>
</thead>
<tbody id="inv-tbody"></tbody>
</table>
</div>
</section>
<section id="available-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="avail-search" placeholder="Search item or pilot…" autocomplete="off">
<select id="avail-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="avail-sort" aria-label="Sort available items">
<option value="updated">Sort: Recently updated</option>
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
</select>
</div>
<div id="avail-summary" class="inv-summary"></div>
<div id="avail-empty" class="empty-state hidden">
<p>Nothing is being offered right now.</p>
<p class="hint">Mark items as available in My Inventory to list them here.</p>
</div>
<div id="avail-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Offered by</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="avail-tbody"></tbody>
</table>
</div>
</section>
<section id="orders-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="order-search" placeholder="Search item or pilot…" autocomplete="off">
<select id="order-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="order-status" aria-label="Filter by order status">
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="fulfilled">Fulfilled</option>
</select>
<select id="order-sort" aria-label="Sort orders">
<option value="updated">Sort: Recently updated</option>
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
</select>
</div>
<div id="order-summary" class="inv-summary"></div>
<div id="order-empty" class="empty-state hidden">
<p>No orders yet.</p>
<p class="hint">Log in, open any catalog item and use “Request this item” to set it up here.</p>
</div>
<div id="order-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Notes</th>
<th>Requested by</th>
<th>Status</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody id="order-tbody"></tbody>
</table>
</div>
</section>
<footer class="site-footer">
<p>Data source:
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
— unofficial Star Citizen fansite content, not affiliated with Cloud Imperium Games.
</p>
</footer>
<div id="modal-backdrop" class="modal-backdrop hidden">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<button class="modal-close" id="modal-close" aria-label="Close">&times;</button>
<h2 id="modal-title">Item</h2>
<div id="modal-body"></div>
</div>
</div>
<div id="confirm-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="alertdialog" aria-modal="true"
aria-labelledby="confirm-title" aria-describedby="confirm-message">
<h2 id="confirm-title">Remove item</h2>
<p id="confirm-message" class="modal-desc"></p>
<div class="modal-actions">
<button type="button" id="confirm-cancel" class="btn-secondary">Cancel</button>
<button type="button" id="confirm-ok" class="btn-danger">Remove</button>
</div>
</div>
</div>
<div id="auth-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title">
<button class="modal-close" id="auth-close" aria-label="Close">&times;</button>
<h2 id="auth-title">Log in</h2>
<form id="auth-form" class="auth-form" novalidate>
<label>Username
<input type="text" id="auth-username" name="username" autocomplete="username"
minlength="3" maxlength="32" required>
</label>
<label>Password
<input type="password" id="auth-password" name="password"
autocomplete="current-password" minlength="8" maxlength="128" required>
</label>
<p id="auth-error" class="auth-error" role="alert"></p>
<button type="submit" id="auth-submit" class="btn-primary">Log in</button>
</form>
<p class="auth-switch">
<button type="button" id="auth-toggle" class="btn-link">Need an account? Register</button>
</p>
</div>
</div>
<div id="toast" class="toast hidden" role="status"></div>
<script src="app.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,598 @@
/* Star Citizen Inventory — dark sci-fi theme */
:root {
--bg: #0b0e14;
--bg-panel: #11151f;
--bg-card: #151a26;
--border: #232b3d;
--text: #d7dce8;
--text-dim: #8b93a7;
--accent: #38bdf8;
--accent-warm: #f59e0b;
--danger: #ef4444;
--success: #22c55e;
--cat-ships: #38bdf8;
--cat-ship-items: #a78bfa;
--cat-fps-items: #f59e0b;
--cat-commodities: #4ade80;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* Header */
.site-header {
background: linear-gradient(180deg, #0d1220 0%, var(--bg) 100%);
border-bottom: 1px solid var(--border);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 1.25rem 1rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.brand h1 {
margin: 0;
font-size: 1.6rem;
letter-spacing: 0.12em;
font-weight: 800;
}
.brand h1 span { color: var(--accent); }
.subtitle { margin: 0.25rem 0 0; color: var(--text-dim); font-size: 0.85rem; }
.header-status { text-align: right; }
.status-pill {
display: inline-block;
padding: 0.3rem 0.9rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
border: 1px solid transparent;
}
.status-pill.online { color: var(--success); border-color: var(--success); background: rgba(34,197,94,0.08); }
.status-pill.offline { color: var(--danger); border-color: var(--danger); background: rgba(239,68,68,0.08); }
.status-pill.checking { color: var(--text-dim); border-color: var(--border); }
.stats-line { margin-top: 0.4rem; font-size: 0.8rem; color: var(--text-dim); }
/* Tabs */
.tabs {
max-width: 1200px;
margin: 1rem auto 0;
padding: 0 1.25rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tab {
background: var(--bg-panel);
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 8px 8px 0 0;
padding: 0.6rem 1.1rem;
font-size: 0.9rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.tab-mine { margin-left: auto; }
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom: 2px solid var(--accent);
background: var(--bg-card);
}
/* Controls */
.controls {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 1.25rem;
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
background: var(--bg-card);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
}
.controls input, .controls select {
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
}
#search-input { flex: 2 1 240px; }
#manufacturer-select { flex: 1 1 180px; }
#type-input { flex: 1 1 160px; }
#size-input { flex: 0 1 90px; }
.controls input:focus, .controls select:focus {
outline: none;
border-color: var(--accent);
}
.btn-secondary {
background: var(--bg-panel);
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
}
.btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-danger {
background: var(--danger);
color: #1b0505;
border: 1px solid var(--danger);
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.btn-danger:hover { filter: brightness(1.1); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1.2rem;
}
/* Main */
main {
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem;
}
.error-banner {
background: rgba(239,68,68,0.1);
border: 1px solid var(--danger);
color: #fca5a5;
border-radius: 8px;
padding: 0.8rem 1rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.loading, .empty-state {
text-align: center;
color: var(--text-dim);
padding: 3rem 1rem;
font-size: 1rem;
}
.empty-state .hint { font-size: 0.85rem; opacity: 0.7; }
.hidden { display: none !important; }
/* Results grid */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.9rem;
}
.item-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem 1rem;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.item-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.item-card .item-name { font-weight: 700; font-size: 0.98rem; line-height: 1.3; }
.item-card .item-manufacturer { color: var(--text-dim); font-size: 0.8rem; }
.item-card .item-meta {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: center;
margin-top: auto;
}
.badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.15rem 0.55rem;
border-radius: 999px;
border: 1px solid;
}
.badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); }
.badge.cat-ship-items { color: var(--cat-ship-items); border-color: var(--cat-ship-items); }
.badge.cat-fps-items { color: var(--cat-fps-items); border-color: var(--cat-fps-items); }
.badge.cat-commodities { color: var(--cat-commodities); border-color: var(--cat-commodities); }
.badge.size { color: var(--text-dim); border-color: var(--border); }
.item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; }
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
#page-info { color: var(--text-dim); font-size: 0.9rem; }
/* Footer */
.site-footer {
border-top: 1px solid var(--border);
margin-top: 2rem;
padding: 1.25rem;
text-align: center;
color: var(--text-dim);
font-size: 0.8rem;
}
.site-footer a { color: var(--accent); text-decoration: none; }
.site-footer a:hover { text-decoration: underline; }
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5,8,14,0.75);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 50;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
max-width: 640px;
width: 100%;
max-height: 85vh;
overflow-y: auto;
padding: 1.5rem;
position: relative;
}
.modal-close {
position: absolute;
top: 0.75rem;
right: 0.9rem;
background: none;
border: none;
color: var(--text-dim);
font-size: 1.6rem;
cursor: pointer;
line-height: 1;
}
.modal-close:hover { color: var(--text); }
.modal h2 { margin: 0 2rem 0.25rem 0; font-size: 1.25rem; }
.modal .modal-sub { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem; }
.modal .modal-desc { font-size: 0.92rem; line-height: 1.55; margin-bottom: 1rem; }
.modal .tag-row { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; }
.spec-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.spec-table th, .spec-table td {
text-align: left;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--border);
}
.spec-table th { color: var(--text-dim); font-weight: 600; width: 40%; }
.spec-table td { font-family: ui-monospace, monospace; }
/* Account controls */
.header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.6rem;
}
.account-bar {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
}
.account-bar .who {
color: var(--accent);
font-weight: 700;
letter-spacing: 0.04em;
}
.btn-primary {
background: var(--accent);
color: #04121c;
border: none;
border-radius: 6px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-link {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 0.85rem;
text-decoration: underline;
padding: 0;
}
.btn-link:hover { color: var(--accent); }
/* Card add button */
.card-add {
margin-left: auto;
width: 1.6rem;
height: 1.6rem;
line-height: 1;
font-size: 1rem;
font-weight: 700;
background: var(--bg-panel);
color: var(--accent);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.card-add:hover { border-color: var(--accent); }
/* Personal inventory view */
.inventory-controls {
max-width: 1200px;
margin: 1rem auto 0;
padding: 0 1.25rem;
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.inventory-controls input,
.inventory-controls select {
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
}
#inv-search { flex: 2 1 280px; }
#inv-category { flex: 1 1 180px; }
#inv-sort { flex: 1 1 170px; }
.inventory-controls input:focus,
.inventory-controls select:focus { outline: none; border-color: var(--accent); }
#inventory-view,
#available-view {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 1.25rem 0;
}
.inv-summary {
color: var(--text-dim);
font-size: 0.85rem;
margin: 0.75rem 0 1rem;
}
.inv-table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 10px;
}
.inv-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.inv-table th,
.inv-table td {
text-align: left;
padding: 0.65rem 0.8rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.inv-table th {
color: var(--text-dim);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.inv-table tr:last-child td { border-bottom: none; }
.inv-item-name { font-weight: 600; }
.inv-item-link {
display: block;
width: 100%;
background: none;
border: none;
padding: 0;
text-align: left;
color: inherit;
font: inherit;
cursor: pointer;
}
.inv-item-link:hover .inv-item-name { text-decoration: underline; }
.inv-item-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.inv-item-link.is-static { cursor: default; }
.inv-item-link.is-static:hover .inv-item-name { text-decoration: none; }
.inv-item-sub {
color: var(--text-dim);
font-size: 0.75rem;
font-family: ui-monospace, monospace;
}
.inv-qty {
width: 5rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-quality {
width: 5rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-quality-na { color: var(--text-dim); }
.inv-quality:focus { outline: none; border-color: var(--accent); }
.inv-notes {
width: 100%;
min-width: 12rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-qty:focus, .inv-notes:focus { outline: none; border-color: var(--accent); }
.inv-remove {
background: none;
border: 1px solid var(--border);
color: var(--danger);
border-radius: 6px;
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
}
.inv-remove:hover { border-color: var(--danger); }
.visibility-toggle {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 999px;
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
cursor: pointer;
white-space: nowrap;
}
.visibility-toggle:hover { border-color: var(--accent); color: var(--text); }
.visibility-toggle.is-available {
border-color: var(--success);
color: var(--success);
background: rgba(34, 197, 94, 0.12);
}
.visibility-toggle:disabled { opacity: 0.6; cursor: progress; }
.avail-user { font-weight: 600; }
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
/* Orders board */
.order-notes {
color: var(--text-dim);
font-size: 0.82rem;
min-width: 10rem;
max-width: 18rem;
white-space: normal;
}
.order-status {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
white-space: nowrap;
}
.order-status.open {
color: var(--accent);
border-color: var(--accent);
}
.order-status.fulfilled {
color: var(--success);
border-color: var(--success);
background: rgba(34, 197, 94, 0.12);
}
.order-actions {
display: flex;
gap: 0.35rem;
white-space: nowrap;
}
/* Auth modal */
.modal-narrow { max-width: 400px; }
.auth-form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
.auth-form label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--text-dim);
}
.auth-form input {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.55rem 0.75rem;
font-size: 0.95rem;
}
.auth-form input:focus { outline: none; border-color: var(--accent); }
.auth-error {
color: #fca5a5;
font-size: 0.85rem;
min-height: 1.1rem;
margin: 0;
}
.auth-switch { margin-top: 0.9rem; font-size: 0.85rem; color: var(--text-dim); }
/* Detail modal add row */
.add-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 1.2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--text-dim);
}
.add-row input {
width: 5rem;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.5rem 0.6rem;
}
.add-row input:focus { outline: none; border-color: var(--accent); }
.add-row input.order-notes-input {
width: auto;
flex: 1;
min-width: 10rem;
}
/* Toast */
.toast {
position: fixed;
bottom: 1.2rem;
left: 50%;
transform: translateX(-50%);
background: var(--bg-panel);
border: 1px solid var(--accent);
color: var(--text);
padding: 0.6rem 1.1rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 100;
max-width: calc(100vw - 2rem);
}
.toast.error { border-color: var(--danger); color: #fca5a5; }
@media (max-width: 640px) {
.header-inner { flex-direction: column; align-items: flex-start; }
.header-status { text-align: left; }
.header-right { align-items: flex-start; }
.controls input, .controls select { flex: 1 1 100%; }
.inventory-controls input, .inventory-controls select { flex: 1 1 100%; }
}

View File

@@ -0,0 +1,38 @@
services:
db:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_USER: sc_inventory
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sc_inventory_dev}
POSTGRES_DB: sc_inventory
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sc_inventory -d sc_inventory"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
backend:
build: ./backend
image: localhost/sc-inventory-backend:latest
environment:
DATABASE_URL: postgresql+psycopg://sc_inventory:${POSTGRES_PASSWORD:-sc_inventory_dev}@db:5432/sc_inventory
DB_WAIT_SECONDS: "60"
COOKIE_SECURE: ${COOKIE_SECURE:-false}
depends_on:
- db
restart: unless-stopped
frontend:
build: ./frontend
image: localhost/sc-inventory-frontend:latest
ports:
- "8071:8071"
depends_on:
- backend
restart: unless-stopped
volumes:
pgdata:

View File

@@ -0,0 +1,16 @@
---
galaxy_info:
role_name: trade_hub
author: boxswapper
description: Deploy the Free People of the Verse trade hub (FastAPI, nginx, PostgreSQL) with Podman.
license: MIT
min_ansible_version: "2.15"
platforms:
- name: GenericLinux
versions:
- all
galaxy_tags:
- podman
- fastapi
- postgresql
dependencies: []

View File

@@ -0,0 +1,245 @@
---
- name: Check whether podman is installed
ansible.builtin.command: podman --version
register: trade_hub_podman_check
changed_when: false
failed_when: false
check_mode: false
- name: Install podman when requested
ansible.builtin.package:
name: podman
state: present
become: true
when:
- trade_hub_podman_check.rc != 0
- trade_hub_install_podman | bool
- name: Re-check podman after installation
ansible.builtin.command: podman --version
register: trade_hub_podman_recheck
changed_when: false
failed_when: false
check_mode: false
when: trade_hub_podman_check.rc != 0
- name: Require podman to be available
ansible.builtin.assert:
that:
- trade_hub_podman_check.rc == 0 or (trade_hub_podman_recheck.rc | default(1)) == 0
fail_msg: >-
podman is not installed on {{ inventory_hostname }}. Install it first
(for example with the "packages" role) or run this role with
-e trade_hub_install_podman=true.
quiet: true
- name: Remove the legacy Apache container when requested
containers.podman.podman_container:
name: "{{ trade_hub_legacy_apache_container }}"
state: absent
when: trade_hub_remove_legacy_apache | bool
- name: Create the application directory
ansible.builtin.file:
path: "{{ trade_hub_app_dir }}"
state: directory
mode: "0755"
- name: Create the application source directories
ansible.builtin.file:
path: "{{ trade_hub_app_dir }}/{{ item }}"
state: directory
mode: "0755"
loop: "{{ trade_hub_app_subdirs }}"
- name: Stage the application build context
ansible.builtin.copy:
src: "{{ role_path }}/files/trade-hub/{{ item.src }}"
dest: "{{ trade_hub_app_dir }}/{{ item.dest }}"
mode: preserve
loop:
# Only the files needed by the two Dockerfiles are staged. Copying the
# whole tree would also ship local development artefacts such as .venv.
- { src: "backend/Dockerfile", dest: "backend/Dockerfile" }
- { src: "backend/requirements.txt", dest: "backend/requirements.txt" }
- { src: "backend/main.py", dest: "backend/main.py" }
- { src: "backend/app/", dest: "backend/app/" }
- { src: "backend/scripts/", dest: "backend/scripts/" }
- { src: "frontend/Dockerfile", dest: "frontend/Dockerfile" }
- { src: "frontend/nginx.conf", dest: "frontend/nginx.conf" }
- { src: "frontend/src/", dest: "frontend/src/" }
register: trade_hub_source
- name: Check whether the backend image exists
ansible.builtin.command:
argv:
- podman
- image
- exists
- "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
register: trade_hub_backend_image_check
changed_when: false
failed_when: false
check_mode: false
- name: Check whether the frontend image exists
ansible.builtin.command:
argv:
- podman
- image
- exists
- "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
register: trade_hub_frontend_image_check
changed_when: false
failed_when: false
check_mode: false
- name: Build the backend image
ansible.builtin.command:
argv:
- podman
- build
- --tag
- "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
- "{{ trade_hub_app_dir }}/backend"
when:
- trade_hub_build_images | bool
- trade_hub_force_rebuild | bool or trade_hub_source.changed or trade_hub_backend_image_check.rc != 0
register: trade_hub_backend_build
- name: Build the frontend image
ansible.builtin.command:
argv:
- podman
- build
- --tag
- "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
- "{{ trade_hub_app_dir }}/frontend"
when:
- trade_hub_build_images | bool
- trade_hub_force_rebuild | bool or trade_hub_source.changed or trade_hub_frontend_image_check.rc != 0
register: trade_hub_frontend_build
- name: Require prebuilt images when image builds are disabled
ansible.builtin.assert:
that:
- trade_hub_backend_image_check.rc == 0
- trade_hub_frontend_image_check.rc == 0
fail_msg: >-
trade_hub_build_images is false but {{ trade_hub_backend_image }}:{{
trade_hub_image_tag }} and/or {{ trade_hub_frontend_image }}:{{
trade_hub_image_tag }} are missing on {{ inventory_hostname }}.
quiet: true
when: not trade_hub_build_images | bool
- name: Record whether the application containers need to be recreated
ansible.builtin.set_fact:
trade_hub_recreate_containers: >-
{{ (trade_hub_force_rebuild | bool)
or ((trade_hub_build_images | bool) and (trade_hub_source.changed | default(false)))
or (trade_hub_backend_build.changed | default(false))
or (trade_hub_frontend_build.changed | default(false)) }}
- name: Ensure the PostgreSQL image is present
containers.podman.podman_image:
name: "{{ trade_hub_postgres_image }}"
state: present
when: not ansible_check_mode
- name: Create the podman network
containers.podman.podman_network:
name: "{{ trade_hub_network }}"
state: present
- name: Check whether the shared proxy network exists
ansible.builtin.command: podman network exists npm_internal
register: trade_hub_npm_network_check
changed_when: false
failed_when: false
check_mode: false
- name: Require the shared proxy network to exist
ansible.builtin.assert:
that:
- trade_hub_npm_network_check.rc == 0
fail_msg: >-
The npm_internal podman network does not exist on {{ inventory_hostname }}.
Run the network role first, e.g.
ansible-playbook -i inventory.ini ansible-playbook.yml --tags "network,trade_hub".
quiet: true
- name: Create the PostgreSQL data volume
containers.podman.podman_volume:
name: "{{ trade_hub_volume }}"
state: present
- name: Run the PostgreSQL container
containers.podman.podman_container:
name: "{{ trade_hub_db_container }}"
image: "{{ trade_hub_postgres_image }}"
state: started
recreate: "{{ trade_hub_force_rebuild | bool }}"
network: "{{ trade_hub_network }}"
network_aliases:
- db
env:
POSTGRES_USER: "{{ trade_hub_postgres_user }}"
POSTGRES_PASSWORD: "{{ trade_hub_postgres_password }}"
POSTGRES_DB: "{{ trade_hub_postgres_db }}"
volumes:
- "{{ trade_hub_volume }}:/var/lib/postgresql/data:Z"
restart_policy: unless-stopped
when: not ansible_check_mode
- name: Run the backend container
containers.podman.podman_container:
name: "{{ trade_hub_backend_container }}"
image: "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
state: started
# podman does not recreate a container when its image tag resolves to a new
# ID, so recreate whenever the image was rebuilt or the app changed.
recreate: "{{ trade_hub_recreate_containers | bool }}"
network: "{{ trade_hub_network }}"
# The nginx upstream is http://backend:8000, so this alias must match.
network_aliases:
- backend
env:
DATABASE_URL: "{{ trade_hub_database_url }}"
DB_WAIT_SECONDS: "{{ trade_hub_db_wait_seconds }}"
COOKIE_SECURE: "{{ trade_hub_cookie_secure | bool | lower }}"
restart_policy: unless-stopped
when: not ansible_check_mode
- name: Run the frontend container
containers.podman.podman_container:
name: "{{ trade_hub_frontend_container }}"
image: "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
state: started
recreate: "{{ trade_hub_recreate_containers | bool }}"
# Keep sc-inventory for the "backend" DNS alias and join the shared proxy
# network with a fixed address so the proxy-manager upstream never changes.
network:
- "{{ trade_hub_network }}"
- "npm_internal:ip={{ trade_hub_frontend_ip }}"
ports:
- "{{ trade_hub_frontend_bind }}:{{ trade_hub_frontend_port }}:{{ trade_hub_frontend_port }}"
restart_policy: unless-stopped
when: not ansible_check_mode
- name: Enable podman-restart so containers survive a reboot
ansible.builtin.systemd:
name: podman-restart.service
enabled: true
state: started
when: trade_hub_enable_restart_service | bool
- name: Wait for the application to answer health checks
ansible.builtin.uri:
url: "{{ trade_hub_healthcheck_url }}"
status_code: 200
register: trade_hub_health
until: trade_hub_health.status == 200
retries: "{{ trade_hub_healthcheck_retries }}"
delay: "{{ trade_hub_healthcheck_delay }}"
changed_when: false
when: not ansible_check_mode

View File

@@ -51,6 +51,10 @@ POSTGRES_ADMIN_USER: theuseriwant
POSTGRES_PASSWORD: theuserthatineedpaswoord POSTGRES_PASSWORD: theuserthatineedpaswoord
POSTGRES_DB: thatoneit POSTGRES_DB: thatoneit
## trade_hub database password (the role also honours POSTGRES_PASSWORD exported
## on the control node; this variable is the in-repo fallback).
trade_hub_postgres_pw: "82uBeJkCzm483"
## WireGuard Easy VPN ## WireGuard Easy VPN
## IMPORTANT: After first run, change wireguard_ui_password to a strong password ## IMPORTANT: After first run, change wireguard_ui_password to a strong password
## Public IP or DDNS hostname clients connect to — CHANGE THIS before deploying ## Public IP or DDNS hostname clients connect to — CHANGE THIS before deploying
@@ -178,14 +182,25 @@ sdtd_visibility: "1"
## leave empty ("" ) to allow the Steam relay fallback if port forwards break. ## leave empty ("" ) to allow the Steam relay fallback if port forwards break.
sdtd_disabled_network_protocols: SteamNetworking sdtd_disabled_network_protocols: SteamNetworking
sdtd_port: 26900 sdtd_port: 26900
sdtd_max_players: 8 sdtd_max_players: 10
sdtd_game_mode: GameModeSurvival sdtd_game_mode: GameModeSurvival
sdtd_game_world: RWG sdtd_game_world: RWG
sdtd_worldgen_seed: boxswapper sdtd_worldgen_seed: boxswapper
sdtd_worldgen_size: 10240 sdtd_worldgen_size: 10240
sdtd_game_name: Boxswapper sdtd_game_name: Boxswapper
## v3.0+ SandboxCode — the ONLY gameplay tuning v3.x reads. The vars below this block
## (sdtd_difficulty .. sdtd_airdrop_frequency) are REFERENCE-ONLY: v3.0 removed them from
## serverconfig.xml, the template no longer renders them, and the game IGNORES them. Empty
## code = Adventurer defaults. To restore the server's intended tuning generate a code:
## client -> New Game -> Sandbox Options -> match the vars below — Difficulty Nomad, XP 200
## (2x), DayNight 60, DayLight 18, Blood Moon every 7 days, DropOnDeath Everything, Loot 150,
## AirDrop 24h — with BOTH Day/Night Animal Density and Respawn = Default (so the MoreAnimals
## modlet stays in charge of wildlife; XML applies only while density is Default) ->
## copy -> paste here.
sdtd_sandbox_code: ""
## ── REFERENCE VARS FOR SANDBOXCODE GENERATION (not rendered to serverconfig.xml on v3.x) ──
## Difficulty: 0 = Scavenger, 1 = Adventurer, 2 = Nomad, 3 = Warrior, 4 = Survivalist, 5 = Insane ## Difficulty: 0 = Scavenger, 1 = Adventurer, 2 = Nomad, 3 = Warrior, 4 = Survivalist, 5 = Insane
sdtd_difficulty: "2" sdtd_difficulty: "1"
sdtd_day_night_length: 60 sdtd_day_night_length: 60
sdtd_day_light_length: 18 sdtd_day_light_length: 18
## DropOnDeath: 0 = nothing, 1 = everything, 2 = toolbelt only, 3 = backpack only, 4 = delete all ## DropOnDeath: 0 = nothing, 1 = everything, 2 = toolbelt only, 3 = backpack only, 4 = delete all
@@ -193,23 +208,25 @@ sdtd_drop_on_death: "1"
## ZombieMove: 0 = walk, 1 = jog, 2 = run, 3 = sprint, 4 = nightmare ## ZombieMove: 0 = walk, 1 = jog, 2 = run, 3 = sprint, 4 = nightmare
sdtd_zombie_move: "0" sdtd_zombie_move: "0"
sdtd_zombie_move_night: "3" sdtd_zombie_move_night: "3"
sdtd_bloodmoon_frequency: 7 sdtd_bloodmoon_frequency: 2
sdtd_bloodmoon_enemy_count: 8 sdtd_bloodmoon_enemy_count: 16
sdtd_loot_abundance: 150 sdtd_loot_abundance: 150
sdtd_airdrop_frequency: 24 sdtd_airdrop_frequency: 24
## PlayerKillingMode: 0 = no killing, 1 = kill allies only, 2 = kill strangers only, 3 = kill everyone ## PlayerKillingMode: 0 = no killing, 1 = kill allies only, 2 = kill strangers only, 3 = kill everyone
sdtd_player_killing_mode: "3" sdtd_player_killing_mode: "3"
sdtd_max_spawned_zombies: 64 sdtd_max_spawned_zombies: 100
## EAC is ON (default protection). It was OFF while code mods were deployed — EAC blocks sdtd_max_spawn_animals: "200"
## their Harmony hooks, so keep "false" if mods are ever reinstalled. ## EAC is ON (default protection). EAC blocks CODE mods (Harmony DLL hooks) — that is why it was
## OFF while code mods were deployed. PURE XML data modlets (like MoreAnimals below) carry no DLL
## and work fine with EAC ON; keep "false" only if code mods are ever reinstalled.
sdtd_eac_enabled: "true" sdtd_eac_enabled: "true"
## Game mods: NONE deployed (Singularity 4.1.3 + No Respawn Near Backpack 2.0.0.0 were ## Game mods deployed: MoreAnimals.zip — pure XML spawn-density patch (roles/7dtd/files/mods).
## removed; EAC was flipped back on as part of that change). To re-add a mod: drop the zip ## Server-side only: clients join vanilla with EAC ON, nothing to install. To re-add/swap a mod:
## into roles/7dtd/files/mods/ and list its name here. NOTE: the role's unarchive task only ## drop the zip into roles/7dtd/files/mods/ and list its name here. unarchive only ADDS — it
## ADDS mods — retiring to an EMPTY list wipes the whole server Mods dir; swapping one mod ## never removes, so a mod dropped from this list would keep loading: with an EMPTY list we wipe
## for another (non-empty list) requires removing the retired folder from the box by hand. ## the whole server Mods dir; swapping one mod for another (non-empty list) requires removing the
sdtd_mods: [] ## retired folder from the box by hand.
sdtd_xp_multiplier: 250 sdtd_mods: [MoreAnimals.zip]
sdtd_xp_multiplier: 200
sdtd_telnet_enabled: "true" sdtd_telnet_enabled: "true"
sdtd_max_spawn_animals: "80"